From e1cf5d5b909c5d8d16ca011ef0f8556885d5db2e Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Sun, 23 Aug 2026 10:59:54 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=E5=95=86=E5=93=81=E9=A2=84=E7=BA=A6?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20feat:=E4=B8=AA=E4=BA=BA=E8=8F=9C=E8=B0=B1?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E7=BB=9F=E8=AE=A1=20feat:=E9=A2=84=E5=AE=9A?= =?UTF-8?q?=E6=89=A3=E5=BA=93=E5=AD=98=20feat:=E8=BF=87=E6=9C=9F=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=87=AA=E5=8A=A8=E9=80=80=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260822_add_expired_reserve_refund.sql | 49 + .../api/feign/RemoteCanteenSpuService.java | 11 + .../dto/CanteenSpuUpdateStockReqDTO.java | 5 +- .../controller/CanteenSpuController.java | 14 + .../canteen/mapper/CanteenSpuMapper.java | 37 +- .../canteen/service/CanteenSpuService.java | 10 +- .../service/impl/CanteenSpuServiceImpl.java | 171 +- .../refund/HardAfterSaleOperateTypeEnum.java | 3 + .../refund/HardAutoRefundTriggerTypeEnum.java | 7 +- .../dto/order/HardAutoRefundTaskPageDto.java | 4 + .../hard/api/dto/order/HardCarDto.java | 6 + .../order/HardAutoRefundTaskEntity.java | 17 +- .../api/entity/order/HardOrderEntity.java | 16 + .../api/vo/order/HardAutoRefundTaskVo.java | 8 + .../order/HardAutoRefundTaskController.java | 29 + .../order/HardCanteenApptTotalController.java | 94 +- .../order/HardAutoRefundTaskMapper.java | 16 + .../hard/mapper/order/HardCarMapper.java | 28 +- .../hard/service/HardAfterSaleService.java | 27 + .../service/HardApptReservationService.java | 3 +- .../service/HardAutoRefundTaskService.java | 19 + .../service/HardCanteenApptTotalService.java | 18 +- .../hard/service/HardOrderService.java | 10 + .../impl/HardApptReservationServiceImpl.java | 32 +- .../impl/hard/HardThirdSelfZyServiceImpl.java | 3 +- .../impl/order/HardAfterSaleServiceImpl.java | 467 ++++- .../order/HardAutoRefundTaskServiceImpl.java | 234 ++- ...HardCanteenApptTotalExportServiceImpl.java | 1634 +++++++++++++++++ .../HardCanteenApptTotalServiceImpl.java | 996 +++------- .../impl/order/HardOrderServiceImpl.java | 368 +++- .../mapper/order/HardAutoRefundTaskMapper.xml | 71 + .../resources/mapper/order/HardCarMapper.xml | 182 ++ .../mapper/order/HardOrderMapper.xml | 4 + .../HardApptReservationServiceImplTest.java | 71 + .../HardAutoRefundTaskServiceImplTest.java | 57 +- .../HardOrderServiceImplVerificationTest.java | 30 + .../api/dto/mch/MchAutoRefundConfigDto.java | 12 + .../dto/recharge/WalletRechargeRefundDto.java | 11 + .../entity/mch/MchAutoRefundConfigEntity.java | 12 + .../service/MchAutoRefundConfigService.java | 9 + .../mch/MchAutoRefundConfigServiceImpl.java | 67 +- .../recharge/WalletRechargeServiceImpl.java | 7 +- .../WalletRechargeServiceImplTest.java | 36 +- 43 files changed, 4060 insertions(+), 845 deletions(-) create mode 100644 db/20260822_add_expired_reserve_refund.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml diff --git a/db/20260822_add_expired_reserve_refund.sql b/db/20260822_add_expired_reserve_refund.sql new file mode 100644 index 00000000..728aa179 --- /dev/null +++ b/db/20260822_add_expired_reserve_refund.sql @@ -0,0 +1,49 @@ +-- 每日菜谱预约订单过期自动退款扩展。 +-- 只增加新快照和任务字段,不回填历史订单;执行前应确认目标字段/索引不存在。 + +ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` + ADD COLUMN `expired_reserve_refund_enabled` char(1) NOT NULL DEFAULT '0' + COMMENT '过期预约自动退款开关,0关闭,1开启' AFTER `pay_time_limit_hours`, + ADD COLUMN `expired_reserve_refund_delay_hours` int(11) NOT NULL DEFAULT 2 + COMMENT '餐段结束后的过期退款延迟小时数,允许1至168' AFTER `expired_reserve_refund_enabled`, + ADD COLUMN `expired_reserve_refund_restore_inventory` char(1) NOT NULL DEFAULT '1' + COMMENT '过期预约退款是否恢复库存,0不恢复,1恢复' AFTER `expired_reserve_refund_delay_hours`; + +ALTER TABLE `pjcloudx_hardware`.`hard_order` + ADD COLUMN `expired_refund_config_id` bigint(20) DEFAULT NULL + COMMENT '过期预约退款配置ID快照,仅新支付成功订单写入' AFTER `auto_refund_deadline`, + ADD COLUMN `expired_refund_usage_end_time` datetime DEFAULT NULL + COMMENT '支付成功时快照的实际餐段结束时间,无法解析时为空' AFTER `expired_refund_config_id`, + ADD COLUMN `expired_refund_deadline` datetime DEFAULT NULL + COMMENT '实际餐段结束时间加延迟后的过期退款触发时间' AFTER `expired_refund_usage_end_time`, + ADD COLUMN `expired_refund_restore_inventory` char(1) DEFAULT NULL + COMMENT '过期退款退库存快照,0不恢复,1恢复' AFTER `expired_refund_deadline`, + ADD KEY `idx_hard_order_expired_refund_scan` + (`tenant_id`, `expired_refund_deadline`, `order_status`, `del_flag`); + +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期预约任务首次执行前为空', + MODIFY COLUMN `rule_type` varchar(32) DEFAULT NULL COMMENT '普通售后任务规则快照,过期预约任务为空', + MODIFY COLUMN `eligibility_deadline` datetime DEFAULT NULL COMMENT '普通售后免审资格截止时间,过期预约任务为空', + ADD COLUMN `task_type` char(10) NOT NULL DEFAULT '10' + COMMENT '任务场景,10用户免审售后,20过期预约退款' AFTER `after_sale_id`, + ADD COLUMN `trigger_time` datetime DEFAULT NULL + COMMENT '过期预约任务按订单快照触发时间,旧售后任务为空' AFTER `eligibility_deadline`, + ADD COLUMN `restore_inventory` char(1) DEFAULT NULL + COMMENT '任务退库存快照,0不恢复,1恢复' AFTER `trigger_time`; + +-- MySQL 5.7 不支持函数索引,使用可索引的存储生成列承载场景业务主键: +-- 普通售后取 after_sale_id,允许同一订单存在多个售后任务;过期预约取 order_id,保证一单一任务。 +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` + ADD COLUMN `scene_biz_id` bigint(20) + GENERATED ALWAYS AS (CASE WHEN `task_type` = '20' THEN `order_id` ELSE `after_sale_id` END) STORED + COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入' AFTER `restore_inventory`, + DROP INDEX `uk_hard_auto_refund_task_after_sale`, + ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz` + (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`), + ADD KEY `idx_hard_auto_refund_task_scan_type` + (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`); + +ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log` + MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期任务创建售后前为空', + MODIFY COLUMN `refund_code` varchar(128) NOT NULL COMMENT '稳定钱包退款业务号,过期任务按订单生成'; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java index 7e8738ce..e7c8483d 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java @@ -26,6 +26,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -67,6 +68,16 @@ public interface RemoteCanteenSpuService { @PostMapping("/canteenSpu/ad/updateSkuStock") R updateSkuStock(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** + * 按稳定业务号原子应用或回滚每日菜谱基础 SPU 库存变更。 + * + * @param updateStockReqDTO 商品增量、操作类型、业务号和操作前库存快照 + * @return 操作成功时返回成功响应,重复请求按幂等成功处理 + */ + @PostMapping("/canteenSpu/inner/updateSkuStockIdempotent") + @NoToken + R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** * 根据ids查询菜品 diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java index ca682974..c09b7c08 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java @@ -25,9 +25,10 @@ public class CanteenSpuUpdateStockReqDTO { private String bizId; /** - * DEDUCT 扣减,ROLLBACK 恢复;仅在 bizId 非空时参与幂等控制。 + * 库存幂等操作类型。DEDUCT 保留给在线菜品旧协议,APPLY 按 incrCount 正负值应用基础菜品库存变更, + * ROLLBACK 使用同一业务号反向撤销 APPLY/DEDUCT;仅在 bizId 非空时参与幂等控制。 */ - @Pattern(regexp = "DEDUCT|ROLLBACK", message = "库存操作类型错误") + @Pattern(regexp = "DEDUCT|APPLY|ROLLBACK", message = "库存操作类型错误") private String operation; /** diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java index 27a6466e..6b7d4a9c 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java @@ -30,6 +30,7 @@ import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; import com.bsd.pjcloud.canteen.service.CanteenSpuService; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.common.security.annotation.Inner; import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -68,6 +69,19 @@ public class CanteenSpuController { private final CanteenMerchantScopeHelper merchantScopeHelper; + /** + * 每日菜谱订单按业务号应用或回滚基础菜品库存,调用方必须携带操作前库存快照。 + * + * @param updateStockReqDTO 库存业务号、操作类型及商品增量 + * @return 幂等库存操作结果 + */ + @Inner + @PostMapping("/inner/updateSkuStockIdempotent") + @Transactional(rollbackFor = Exception.class) + public R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO) { + return canteenSpuService.updateSkuStockIdempotent(updateStockReqDTO); + } + /** * 分页查询 * diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java index 227f5f9a..706a6b64 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java @@ -13,11 +13,11 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper { * 更新商品 SPU 库存 * * @param id 商品 SPU 编号 - * @param incrCount 增加的库存数量 + * @param incrCount 库存增量,正数恢复库存、负数扣减库存 */ default int updateStock(Long id, Integer incrCount) { // 拼接 SQL - if (incrCount == 0) { + if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE) { return 0; } String sql; @@ -29,7 +29,36 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper { // 执行更新 LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() .setSql(sql) - .eq(CanteenSpuEntity::getId, id); + .eq(CanteenSpuEntity::getId, id) + // 扣减条件必须落到同一条 UPDATE,避免并发请求都通过前置查询后把库存扣成负数。 + .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount)); return update(null, updateWrapper); } -} \ No newline at end of file + + /** + * 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 SPU 时丢失更新。 + * + * @param id 商品 SPU 编号 + * @param incrCount 库存增量,负数扣减、正数恢复 + * @param expectedStock 本次业务读取到的操作前库存 + * @return 影响行数;返回 0 表示库存已变化或扣减后会小于 0 + */ + default int updateStockIfMatch(Long id, Integer incrCount, Integer expectedStock) { + if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE + || expectedStock == null || expectedStock < 0) { + return 0; + } + String sql; + if (incrCount > 0) { + sql = " stock = stock + " + incrCount + ", sales_count = sales_count - " + incrCount; + } else { + sql = " stock = stock - " + Math.abs(incrCount) + ", sales_count = sales_count + " + Math.abs(incrCount); + } + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() + .setSql(sql) + .eq(CanteenSpuEntity::getId, id) + .eq(CanteenSpuEntity::getStock, expectedStock) + .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount)); + return update(null, updateWrapper); + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java index 6164cd82..de78463e 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java @@ -32,6 +32,14 @@ public interface CanteenSpuService extends IService { */ R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** + * 按业务号原子应用或回滚基础菜品库存,供每日菜谱下单和退款跨服务调用。 + * + * @param updateStockReqDTO 商品库存增量、操作前快照、业务号和操作类型 + * @return 成功、幂等命中或库存校验失败的统一响应 + */ + R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** * 根据菜品分类id获取菜品信息 @@ -106,4 +114,4 @@ public interface CanteenSpuService extends IService { * 批量加载套餐组成(App/每日菜谱展示) */ Map> loadComboItemVos(List comboSpuIds); -} \ No newline at end of file +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java index 20913428..008f3853 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.canteen.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; +import com.baomidou.lock.annotation.Lock4j; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -26,10 +27,14 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.CollectionUtils; +import java.time.Duration; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -44,6 +49,17 @@ import java.util.stream.Collectors; @AllArgsConstructor public class CanteenSpuServiceImpl extends ServiceImpl implements CanteenSpuService { + /** 每日菜谱基础 SPU 库存幂等状态键前缀,租户隔离由 Redis 基础设施统一处理。 */ + private static final String ORDER_STOCK_BIZ_KEY_PREFIX = "canteen:spu:order-stock:"; + + /** 库存幂等状态最长保留时间,覆盖远程超时重试和延迟请求后自动释放 Redis 空间。 */ + private static final Duration ORDER_STOCK_BIZ_STATE_TTL = Duration.ofDays(7); + + /** 已确认 APPLY 成功且随后完成反向补偿;允许业务任务使用同一幂等号显式重试。 */ + private static final String ORDER_STOCK_BIZ_STATE_COMPENSATED = "COMPENSATED"; + + /** 库存业务状态存储,用于识别跨服务超时后的重复应用和补偿请求。 */ + private final StringRedisTemplate stringRedisTemplate; private final CanteenDailyMenuMapper canteenDailyMenuMapper; private final CanteenCategoryMapper canteenCategoryMapper; @@ -58,29 +74,152 @@ public class CanteenSpuServiceImpl extends ServiceImpl { - // 查询当前库存 + if (updateStockReqDTO == null || CollUtil.isEmpty(updateStockReqDTO.getItems())) { + return R.failed("库存商品不能为空"); + } + // 必须先校验完整批次再更新,后一个商品失败时由本地事务保证前面的商品不会单独提交。 + for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) { + if (item == null || item.getId() == null || item.getIncrCount() == null + || item.getIncrCount() == 0 || item.getIncrCount() == Integer.MIN_VALUE) { + return R.failed("库存商品参数错误"); + } CanteenSpuEntity spu = baseMapper.selectById(item.getId()); if (spu == null) { - R.failed(spu.getName() + "商品不存在"); + return R.failed("商品不存在"); } - // 检查库存是否足够 - if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > spu.getStock()) { - R.failed(spu.getName() + "库存不足,无法完成操作"); + if (item.getIncrCount() == null || item.getIncrCount() == 0) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存变化数量无效"); } - int i = baseMapper.updateStock(item.getId(), item.getIncrCount()); -// if (item.getIncrCount() <= 0) { -// if (i == 0) { -// R.failed("商品库存不足"); -// } -// } - }); + if (item.getIncrCount() < 0 && (spu.getStock() == null || Math.abs(item.getIncrCount()) > spu.getStock())) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作"); + } + } + for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) { + if (baseMapper.updateStock(item.getId(), item.getIncrCount()) == 0) { + throw new RuntimeException("库存不足,无法完成操作"); + } + } return R.ok(); } + /** + * 对同一业务号串行应用或反向补偿基础 SPU 库存。APPLY 同时支持下单负增量和退款正增量, + * ROLLBACK 必须携带原 APPLY 的增量与操作前快照,避免远程响应超时时重复扣减或重复恢复。 + * + * @param dto 商品增量、操作前库存快照、稳定业务号和操作类型 + * @return 成功、幂等命中或库存状态冲突的统一响应 + */ + @Override + @Lock4j(keys = {"#dto.bizId"}) + @Transactional(rollbackFor = Exception.class) + public R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO dto) { + String operation = dto == null ? null : StrUtil.trim(dto.getOperation()); + boolean applyOperation = "APPLY".equals(operation); + boolean rollbackOperation = "ROLLBACK".equals(operation); + if (dto == null || StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems()) + || (!applyOperation && !rollbackOperation) + || dto.getItems().stream().anyMatch(item -> item == null || item.getId() == null + || item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) { + return R.failed("库存业务参数错误"); + } + if (dto.getItems().stream().map(CanteenSpuUpdateStockReqDTO.Item::getId).distinct().count() != dto.getItems().size()) { + return R.failed("库存商品不能重复"); + } + + String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId(); + String state = stringRedisTemplate.opsForValue().get(stateKey); + // ROLLED_BACK 表示补偿先于 APPLY 或无法确认 APPLY 成功,必须阻断延迟请求; + // COMPENSATED 表示已确认 APPLY 后又完成反向补偿,任务重试可以使用新库存快照重新执行。 + if (applyOperation && ("APPLIED".equals(state) || "ROLLED_BACK".equals(state))) { + return R.ok(); + } + if (rollbackOperation && ("ROLLED_BACK".equals(state) + || ORDER_STOCK_BIZ_STATE_COMPENSATED.equals(state))) { + return R.ok(); + } + for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) { + CanteenSpuEntity spu = baseMapper.selectById(item.getId()); + if (spu == null || spu.getStock() == null) { + return R.failed("商品不存在或库存异常"); + } + int expectedStock = item.getBeforeStock(); + if (expectedStock < 0) { + return R.failed("库存快照异常"); + } + int targetStock = Math.addExact(expectedStock, item.getIncrCount()); + if (targetStock < 0) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作"); + } + if (applyOperation) { + // 不得把“库存恰好等于目标值”当成本业务已经执行;该值可能由另一个业务号并发扣减得到。 + // 幂等成功只能依赖上方读取到的 APPLIED 状态,状态缺失时必须从调用方快照原子更新。 + if (spu.getStock() != expectedStock) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存已变化,请重新操作"); + } + } else if ("APPLIED".equals(state)) { + int rollbackDelta = -item.getIncrCount(); + if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) { + return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法自动补偿"); + } + } + } + + for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) { + int expectedStock = item.getBeforeStock(); + int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount(); + if (rollbackOperation && "APPLIED".equals(state)) { + // 补偿不能再要求库存等于本业务 APPLY 后的快照:期间可能已有其他订单合法改变同一 SPU。 + // 同一业务号由锁和 APPLIED 状态保证只反向一次,数据库条件更新只负责阻止库存扣成负数。 + if (baseMapper.updateStock(item.getId(), delta) == 0) { + throw new RuntimeException("库存补偿失败,请重试"); + } + continue; + } + // 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。 + if (applyOperation && baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock) == 0) { + throw new RuntimeException("库存已变化,请重新操作"); + } + } + String targetState = applyOperation ? "APPLIED" + : "APPLIED".equals(state) ? ORDER_STOCK_BIZ_STATE_COMPENSATED : "ROLLED_BACK"; + updateStockBizState(stateKey, state, targetState); + return R.ok(); + } + + /** + * 写入库存幂等状态,并在食堂本地数据库事务回滚时恢复原状态,防止出现“状态成功、库存未提交”。 + * + * @param stateKey 当前租户下的库存业务状态键 + * @param previousState 调用前状态,空值表示此前没有记录 + * @param targetState 本次库存操作完成后的状态 + */ + private void updateStockBizState(String stateKey, String previousState, String targetState) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("库存事务状态异常,请稍后重试"); + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 数据库事务回滚后同步撤销本次 Redis 状态,后续请求才能根据实际库存重新判断。 + * + * @param status Spring 本地事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_ROLLED_BACK) { + return; + } + if (previousState == null) { + stringRedisTemplate.delete(stateKey); + } else { + stringRedisTemplate.opsForValue().set(stateKey, previousState, ORDER_STOCK_BIZ_STATE_TTL); + } + } + }); + stringRedisTemplate.opsForValue().set(stateKey, targetState, ORDER_STOCK_BIZ_STATE_TTL); + } + @Override public R getCanteenSpuList(String categoryId) { List canteenSpuEntities = baseMapper.selectListByScope(new LambdaQueryWrapper() @@ -481,4 +620,4 @@ public class CanteenSpuServiceImpl extends ServiceImpl mchIds; /** 预计使用日期范围。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java index 21024beb..ad5e6842 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java @@ -18,7 +18,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** - * Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。 + * Hardware 自动退款持久化任务。售后免审任务创建时绑定售后单,过期预约任务在实际执行前允许暂不绑定, + * 失败后由该记录持续驱动恢复。 */ @Data @Builder @@ -38,11 +39,15 @@ public class HardAutoRefundTaskEntity extends Model { private Long id; /** - * 售后单 ID,是任务唯一业务键和钱包退款幂等键。 + * 售后单 ID;普通售后任务创建时必填,过期预约任务创建系统售后单前为空,完成后作为钱包退款幂等键。 */ @Schema(description = "售后单ID") private Long afterSaleId; + /** 任务场景编码;10为用户免审售后,20为过期预约退款。 */ + @Schema(description = "任务场景") + private String taskType; + /** * 原 Hardware 订单 ID,用于订单级分布式锁和退款状态聚合。 */ @@ -73,6 +78,14 @@ public class HardAutoRefundTaskEntity extends Model { @Schema(description = "免审资格截止时间") private LocalDateTime eligibilityDeadline; + /** 过期预约任务的订单快照触发时间;旧售后任务为空。 */ + @Schema(description = "过期退款触发时间") + private LocalDateTime triggerTime; + + /** 任务创建时快照的退库存开关,1恢复、0不恢复。 */ + @Schema(description = "退库存快照") + private String restoreInventory; + /** * 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index b866cf6c..7b73513d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -159,6 +159,22 @@ public class HardOrderEntity extends Model { @Schema(description = "自动退款申请截止时间") private LocalDateTime autoRefundDeadline; + /** 每日菜谱过期退款配置 ID 快照;仅支付成功且下单时开关开启的新订单写入。 */ + @Schema(description = "过期预约退款配置ID快照") + private Long expiredRefundConfigId; + + /** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */ + @Schema(description = "过期预约餐段实际结束时间快照") + private LocalDateTime expiredRefundUsageEndTime; + + /** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */ + @Schema(description = "过期预约退款触发时间") + private LocalDateTime expiredRefundDeadline; + + /** 过期退款是否恢复库存的订单快照,1恢复、0不恢复。 */ + @Schema(description = "过期预约退款退库存快照") + private String expiredRefundRestoreInventory; + /** * 订单设备类型 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java index bb2c3106..e44b13d4 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java @@ -42,4 +42,12 @@ public class HardAutoRefundTaskVo extends HardAutoRefundTaskEntity { */ @Schema(description = "售后状态") private String afterSaleStatus; + + /** 预约订单实际使用日期,过期候选列表展示用。 */ + @Schema(description = "预约日期") + private java.time.LocalDate apptUseDate; + + /** 预约餐段编码,过期候选列表展示用。 */ + @Schema(description = "餐段") + private String hourType; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java index a66b061a..faecc1da 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java @@ -80,6 +80,35 @@ public class HardAutoRefundTaskController { return R.ok(taskService.pageTasks(page, dto)); } + /** + * 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。 + */ + @GetMapping("/expired-candidates") + @Operation(summary = "查询待过期预约退款订单") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')") + public R> expiredCandidates(@ParameterObject Page page, + @ParameterObject HardAutoRefundTaskPageDto dto) { + List scopedMchIds = listScopedMerchantIds(); + if (scopedMchIds.isEmpty()) { + Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L); + empty.setRecords(List.of()); + return R.ok(empty); + } + dto.setMchIds(scopedMchIds); + return R.ok(taskService.pageExpiredCandidates(page, dto)); + } + + /** 管理端确认后逐单执行过期预约整单退款。 */ + @PostMapping("/{id}/expired-refund") + @Operation(summary = "过期预约订单退款") + @Idempotent(expireTime = 5, info = "正在退款,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')") + public R expiredRefund(@PathVariable Long id) { + validateTaskScope(id); + taskService.manualExpiredRefund(id, SecurityUtils.getUser().getId()); + return R.ok(true); + } + /** * 查询指定任务全部只读尝试日志。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java index 097bb40a..26048912 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java @@ -16,8 +16,12 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; +import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; +import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -51,11 +55,19 @@ import java.util.List; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class HardCanteenApptTotalController { + /** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */ + private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; + /** * 食堂预约统计服务,负责菜品和用户维度的预约聚合。 */ private final HardCanteenApptTotalService canteenApptTotalService; + /** + * 食堂预约导出服务,负责异步任务、分批写入和文件上传,不让统计服务承担长耗时导出职责。 + */ + private final HardCanteenApptTotalExportService canteenApptTotalExportService; + /** * 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。 */ @@ -170,6 +182,21 @@ public class HardCanteenApptTotalController { return R.ok(canteenApptTotalService.userReservationDetail(userId, apptUseDate)); } + /** + * 查询当前用户预约筛选条件对应的每日和范围总计。 + * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 预约统计结果 + */ + @Operation(summary = "用户预约统计", description = "按日统计用户预约人数、餐段及菜品份数") + @ApiCryptoAes + @PostMapping("/user-reserv-statistics") + @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_page')") + public R userReservationStatistics( + @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return R.ok(canteenApptTotalService.userReservationStatistics(query)); + } + /** * 提交用户每日预约异步导出任务。 * @@ -182,7 +209,7 @@ public class HardCanteenApptTotalController { @PostMapping("/user-reserv-export-task") public R submitUserReservationExport( @Valid @RequestBody HardCanteenUserApptQueryDto query) { - return R.ok(canteenApptTotalService.submitUserReservationExport(query), "用户预约导出任务已提交"); + return R.ok(canteenApptTotalExportService.submitUserReservationExport(query), "用户预约导出任务已提交"); } /** @@ -198,7 +225,70 @@ public class HardCanteenApptTotalController { @PostMapping("/user-reserv-export") public List userReservationExport( @Valid @RequestBody HardCanteenUserApptQueryDto query) { - return canteenApptTotalService.userReservationExport(query); + return canteenApptTotalExportService.userReservationExport(query); + } + + /** + * 提交菜品预约统计异步导出任务。 + * + * @param query 菜品预约日期、餐段、食堂和部门筛选条件 + * @return 供前端轮询的初始任务进度 + */ + @Operation(summary = "菜品预约统计异步导出", description = "按日期分批生成菜品预约三级统计 Excel") + @SysLog("菜品预约统计异步导出") + @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')") + @PostMapping("/product-reserv-export-task") + public R submitProductReservationExport(@RequestBody HardOrderDto query) { + applyCanteenMerchantScope(query); + return R.ok(canteenApptTotalExportService.submitProductReservationExport(query), "菜品预约导出任务已提交"); + } + + /** + * 同步菜品预约统计导出接口,保留给已有调用方;管理端页面使用异步任务接口。 + * + * @param query 菜品预约统计筛选条件 + * @return 按日期餐段、商户部门和商品三级组织的自定义 Excel 行 + */ + @Operation(summary = "菜品预约统计导出", description = "导出日期餐段、商户部门和商品三级菜品预约统计") + @SysLog("菜品预约统计导出") + @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')") + @ResponseExcel(name = "菜品预约统计", inMemory = true, writeHandler = HardCanteenProductApptExportMergeHandler.class) + @PostMapping("/product-reserv-export") + public List productReservationExport(@RequestBody HardOrderDto query) { + applyCanteenMerchantScope(query); + return canteenApptTotalExportService.productReservationExport(query); + } + + /** + * 为后台菜品预约导出补充当前操作者可见的食堂范围。 + * + *

分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时 + * 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。

+ * + * @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验 + */ + private void applyCanteenMerchantScope(HardOrderDto query) { + if (query == null) { + return; + } + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + R> scopeResponse = remoteMchService.scopeList(mchManageDto); + List mchList = scopeResponse == null ? null : scopeResponse.getData(); + List visibleMerchantIds = ObjectUtil.isEmpty(mchList) ? List.of() + : mchList.stream().map(MchManageVo::getId).filter(ObjectUtil::isNotEmpty).distinct().toList(); + if (ObjectUtil.isEmpty(visibleMerchantIds)) { + query.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID)); + return; + } + if (ObjectUtil.isEmpty(query.getMchIds())) { + query.setMchIds(visibleMerchantIds); + return; + } + // 即使调用方手工携带 mchIds,也只能导出当前操作者实际可见的食堂。 + List allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList(); + query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds) + ? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java index 41ea25c2..911c3d71 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java @@ -6,6 +6,9 @@ import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; /** * 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。 @@ -39,6 +42,7 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageExpiredCandidates(Page page, + @Param("dto") HardAutoRefundTaskPageDto dto, + @Param("now") LocalDateTime now); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java index 40f4ac68..b7ec5a8b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java @@ -12,6 +12,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCarTotalVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; @@ -37,6 +38,15 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + /** 按预约日期和餐段查询导出所需的日期餐段总计。 */ + List listApptExportSummary(@Param("dto") HardOrderDto dto); + + /** 按预约日期、餐段、商户和部门查询导出商户部门统计。 */ + List listApptExportMerchant(@Param("dto") HardOrderDto dto); + + /** 按预约日期、餐段、商户、部门和商品查询导出商品明细。 */ + List listApptExportProduct(@Param("dto") HardOrderDto dto); + Page proudctQuery(Page page, @Param("dto") HardCarDto dto); /** @@ -48,6 +58,22 @@ public interface HardCarMapper extends PjcloudBaseMapper { List listByHourType(@Param("dto") HardOrderDto dto); + /** + * 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。 + * + * @param parentCarIds 预约订单关联的父购物车主键集合 + * @return 未删除的购物车商品快照;传入空集合时返回空列表 + */ + default List listByParentCarIds(List parentCarIds) { + if (parentCarIds == null || parentCarIds.isEmpty()) { + return List.of(); + } + return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .in(HardCarEntity::getParentCarId, parentCarIds) + .orderByAsc(HardCarEntity::getParentCarId) + .orderByAsc(HardCarEntity::getId)); + } + /** * 菜品销量按照分类查询 */ @@ -62,4 +88,4 @@ public interface HardCarMapper extends PjcloudBaseMapper { * 个人菜品喜好排行 */ List productLikeTop(@Param("dto") HardAppProductLikeTopDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 50431a38..90a46764 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -72,6 +72,33 @@ public interface HardAfterSaleService extends IService { */ void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId); + /** + * 按过期预约任务创建并完成每日菜谱整单系统退款;任务快照决定是否恢复库存。 + * + * @param task 过期预约任务,afterSaleId 为空时由本方法创建系统售后 + */ + Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task); + + /** + * 完成过期预约整单退款,并允许调用方声明是否需要校验过期自动退款开关。 + * 管理端逐单业务退款即使商户关闭自动开关也必须可用,因此该路径不执行自动开关校验; + * 调度器和自动重试路径必须传入 {@code true},继续由 Wallet 做最终开关校验。 + * + * @param task 过期预约退款任务,afterSaleId 为空时由本方法创建系统售后 + * @param enforceAutoSwitch 是否按自动任务场景校验当前过期退款开关 + * @return 完成或复用的系统售后单 ID + */ + Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task, + boolean enforceAutoSwitch); + + /** + * 在资金退款前独立提交过期预约系统售后单及商品快照,确保跨服务退款成功后本地回滚仍可恢复。 + * + * @param task 过期预约任务,必须尚未关联售后单 + * @return 新建或已存在的系统售后单 ID + */ + Long prepareExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task); + /** * 同意售后 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index 4d5f3bbc..fa8a34ee 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -39,8 +39,9 @@ public interface HardApptReservationService { * @param apptDate 实际用餐日期 * @param apptRuleId 周期预约规则 ID;动态模式为空 * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径 + * @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款 */ record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId, - LocalDateTime eligibilityDeadline) { + LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) { } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java index c88ac2c1..212fb749 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -27,6 +27,14 @@ public interface HardAutoRefundTaskService extends IService pageTasks(Page page, HardAutoRefundTaskPageDto dto); + /** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */ + Page pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto); + /** * 查询任务的只读尝试日志,按尝试序号升序返回。 * @@ -68,6 +79,14 @@ public interface HardAutoRefundTaskService extends IService userReservationDetail(Long userId, LocalDate apptUseDate); /** - * 提交用户每日预约异步导出任务,后台按权限用户分批查询并流式写入 Excel。 + * 按当前用户预约查询条件统计每日和查询范围内的预约人数、餐段及菜品。 * - * @param query 用户、日期范围和预约状态筛选条件 - * @return 供前端轮询的初始任务进度 + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 按日和总计组织的预约统计结果 */ - AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query); + HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query); - /** - * 查询并转换用户预约导出数据,包含没有预约订单的权限范围用户。 - * - * @param query 用户、日期范围和预约状态筛选条件 - * @return 用户和日期维度的 Excel 导出行,最后一行为餐段及当日金额合计 - */ - List userReservationExport(HardCanteenUserApptQueryDto query); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index 473680f2..19026a2b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -268,6 +268,16 @@ public interface HardOrderService extends IService { void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce); + /** + * 按售后单隔离库存幂等业务号执行扣减或恢复;每日菜谱退款必须传售后单 ID,避免多次部分退款互相覆盖。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示整单商品 + * @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存 + * @param afterSaleId 本次售后单 ID,非退款调用可为空 + */ + void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, Long afterSaleId); + /** * 核销硬件二维码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 3c1e3be6..43a8c4ba 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -357,7 +357,8 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic if (!availability.available()) { throw new RuntimeException(availability.unavailableReason()); } - return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime()); + return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(), + availability.endDateTime()); } private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, @@ -377,7 +378,34 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic if (!match.availability().available()) { throw new RuntimeException(match.availability().unavailableReason()); } - return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime()); + return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(), + resolveCycleUsageEndDateTime(mchId, hourType, apptDate)); + } + + /** + * 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析, + * 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。 + * + * @param mchId 订单所属商户 ID + * @param hourType 订单选中的标准餐段编码 + * @param apptDate 实际用餐日期 + * @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null} + */ + private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) { + List candidates = hardApptService.list(new LambdaQueryWrapper() + .eq(HardApptEntity::getMchId, mchId) + .eq(HardApptEntity::getHourType, hourType) + .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) + .orderByAsc(HardApptEntity::getId)); + if (candidates.size() != 1) { + return null; + } + HardApptEntity appt = candidates.get(0); + HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType()); + if (endType == null || appt.getEndTime() == null) { + return null; + } + return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime()); } private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java index 1b9958b1..ed069be6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java @@ -164,9 +164,8 @@ public class HardThirdSelfZyServiceImpl implements HardThirdSelfZyService { .content(HardSaleStatusEnum.COMPLETE.getContent()) .build(); hardAfterSaleLogMapper.insert(saveLogObj); - // 执行退款 + // 正元订单不属于每日菜谱,普通库存接口没有跨服务补偿能力,保持先退款后恢复库存。 hardAfterSaleService.refund(saveObj.getId(), "zy"); - // 退库存 hardOrderService.fastenerInventory(order, YesNoEnum.NO); HardCallbackRefundVo result = new HardCallbackRefundVo(); result.setCode(HardCallbackRefundStatus.SUCCESS); 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 bf69eb2a..3d8c2bd7 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 @@ -31,6 +31,7 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleOperateTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardSaleTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleWayEnum; import com.bsd.pjcloud.hard.api.dto.order.*; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto; @@ -519,6 +520,20 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletRefundResult = walletRechargeService.refund(hardRefundDto); if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) { throw new RuntimeException("钱包退款错误"); @@ -639,7 +656,14 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); - if (ObjectUtil.isEmpty(refundCars)) { - hardOrderService.fastenerInventory(order, YesNoEnum.NO); - } else { - hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); - } recomputeOrderRefundState(order.getId()); } + /** + * 过期预约任务的系统整单退款。订单和任务已由任务状态机串行保护,这里仍锁订单行并重新核对可退条件, + * 防止核销、人工退款或重复调度在任务到期前改变订单状态。退库存只读取任务快照,不读取当前商户配置。 + * + * @param task 过期预约任务,首次执行时售后单 ID 为空 + * @return 创建或复用的系统售后单 ID + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task) { + return completeExpiredReserveRefund(task, true); + } + + /** + * 过期预约任务的系统整单退款实现。自动调度需要校验当前开关,管理端逐单业务退款不受商户关闭开关影响。 + * 订单、售后和库存仍统一由订单锁与本地事务保护,避免人工操作绕过支付、核销和重复退款校验。 + * + * @param task 过期预约任务,首次执行时售后单 ID 为空 + * @param enforceAutoSwitch 自动调度/重试为 {@code true},管理端逐单退款为 {@code false} + * @return 创建或复用的系统售后单 ID + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task, boolean enforceAutoSwitch) { + if (task == null || task.getOrderId() == null) { + throw new RuntimeException("过期预约任务数据异常"); + } + if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) { + throw new RuntimeException("过期预约退款尚未到达触发时间"); + } + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId()); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) { + throw new RuntimeException("订单不满足过期预约退款条件"); + } + List afterSales = listOrderAfterSales(order.getId()); + if (afterSales.stream().anyMatch(item -> !Objects.equals(item.getId(), task.getAfterSaleId()) + && Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("订单已有处理中售后"); + } + int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + if (completedRefundPrice > 0 || safePrice(order.getPayPrice()) <= 0) { + throw new RuntimeException("订单已退款或金额异常"); + } + if (task.getAfterSaleId() == null) { + throw new RuntimeException("过期预约退款售后单尚未准备完成"); + } + HardAfterSaleEntity afterSale = baseMapper.selectById(task.getAfterSaleId()); + if (afterSale == null || !Objects.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) { + throw new RuntimeException("过期预约退款售后单状态异常"); + } + if (Objects.equals(task.getRestoreInventory(), YesNoEnum.YES.getCode())) { + restoreRefundInventory(order, afterSale.getId()); + } + refundInCurrentTransaction(afterSale.getId(), "hard_expired", + enforceAutoSwitch ? task.getConfigId() : null, + enforceAutoSwitch ? WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE : null); + HardAfterSaleEntity completed = HardAfterSaleEntity.builder().id(afterSale.getId()) + .status(HardSaleStatusEnum.COMPLETE.getStatus()).auditTime(LocalDateTime.now()).refundTime(LocalDateTime.now()).build(); + if (baseMapper.updateById(completed) != 1) { + throw new RuntimeException("更新过期退款售后状态失败"); + } + insertAfterSaleLog(afterSale.getId(), afterSale.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(), + HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus(), + HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent()); + recomputeOrderRefundState(order.getId()); + return afterSale.getId(); + } + + /** + * 在独立事务中创建过期预约系统售后单和商品快照;不调用钱包,提交后由任务状态机执行资金退款。 + * + * @param task 过期预约任务,订单已由任务状态机按订单锁串行 + * @return 新建系统售后单 ID + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long prepareExpiredReserveRefund(HardAutoRefundTaskEntity task) { + if (task == null || task.getOrderId() == null || task.getAfterSaleId() != null) { + throw new RuntimeException("过期预约退款任务数据异常"); + } + if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) { + throw new RuntimeException("过期预约退款尚未到达触发时间"); + } + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId()); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) { + throw new RuntimeException("订单不满足过期预约退款条件"); + } + List afterSales = listOrderAfterSales(order.getId()); + if (afterSales.stream().anyMatch(item -> Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + throw new RuntimeException("订单已有处理中售后"); + } + if (sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()) > 0 || safePrice(order.getPayPrice()) <= 0) { + throw new RuntimeException("订单已退款或金额异常"); + } + HardAfterSaleEntity afterSale = HardAfterSaleEntity.builder() + .status(HardSaleStatusEnum.APPLY.getStatus()) + .no("hard_expired_" + order.getId()) + .orderId(order.getId()) + .orderNo(String.valueOf(order.getId())) + .userId(order.getUserId()) + .way(HardAfterSaleWayEnum.REFUND.getType()) + .type(HardSaleTypeEnum.IN_SALE.getType()) + .refundPrice(safePrice(order.getPayPrice())) + .applyReason("预约订单过期自动退款") + .applyDescription("系统按预约餐段结束时间自动退款") + .build(); + if (baseMapper.insert(afterSale) != 1) { + throw new RuntimeException("创建过期预约退款售后单失败"); + } + hardAfterSaleCarService.create(buildRemainingRefundCars(order, getOriginalCars(order)), order, afterSale); + insertAfterSaleLog(afterSale.getId(), order.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(), + order.getOrderStatus(), HardSaleStatusEnum.APPLY.getStatus(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent()); + return afterSale.getId(); + } + @Override public Boolean agreeAfterSale(Long userId, Long id) { HardAfterSaleEntity current = baseMapper.selectById(id); @@ -693,13 +836,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(id); - if (ObjectUtil.isEmpty(refundCars)) { - hardOrderService.fastenerInventory(order, YesNoEnum.NO); - } else { - hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); + if (!isDailyMenuOrder(order)) { + restoreOrderInventory(order, id); } recomputeOrderRefundState(order.getId()); return true; @@ -779,33 +922,90 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundSuccessCarList = hardAfterSaleCarService.getOccupiedByCarParentId(order.getParentCarId()); + Map requestedCountByProductId = new LinkedHashMap<>(); for (HardAfterSaleCarEntity refundCar : refundCarList) { - HardCarEntity orderCar = orderCarList.stream() - .filter(v -> ObjectUtil.equal(v.getProductId(), refundCar.getProductId()) - || ObjectUtil.equal(v.getId(), refundCar.getId())) - .findFirst().orElse(null); + if (refundCar == null || (refundCar.getProductId() == null && refundCar.getId() == null)) { + throw new RuntimeException("退款商品不能为空"); + } + HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar); if (orderCar == null) throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">不存在"); - int refundCount = 0; - if (ObjectUtil.isNotEmpty(refundCar.getProductCount())) { - refundCount = refundCar.getProductCount(); + // 后续金额、售后快照和库存恢复都以服务端商品 ID 为准,兼容后台历史提交的购物车行 ID。 + refundCar.setProductId(orderCar.getProductId()); + // 按数量商品必须提交有效份数;称重商品历史请求可能只提交重量,数量按零参与数量校验。 + if (HardProductNatureEnum.DEFAULT.getType().equals(orderCar.getProductNature()) + && refundCar.getProductCount() == null) { + throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为空"); } - // 已经退货成功的商品列表 - if (ObjectUtil.isNotEmpty(refundSuccessCarList)) { - // 售后明细 ID 是退款记录自身 ID,不能与原购物车 ID 比较;使用服务端订单商品 ID 防止重复超额退款。 - HardAfterSaleCarEntity refundSuccessCar = refundSuccessCarList.stream() - .filter(v -> ObjectUtil.equal(v.getProductId(), orderCar.getProductId())) - .findFirst() - .orElse(null); - if (ObjectUtil.isNotEmpty(refundSuccessCar)) { - refundCount += refundSuccessCar.getProductCount(); - } + if (refundCar.getProductCount() != null && refundCar.getProductCount() < 0) { + throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为负数"); } - int originalCount = orderCarList.stream().filter(item -> ObjectUtil.equal(item.getProductId(), orderCar.getProductId())) - .map(HardCarEntity::getProductCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); - if (originalCount < refundCount) { - throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">数量超出"); + int requestCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount(); + try { + requestedCountByProductId.merge(orderCar.getProductId(), requestCount, Math::addExact); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量异常"); } } + Map occupiedCountByProductId = refundSuccessCarList.stream() + .filter(item -> item != null && item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, + item -> item.getProductCount() == null ? 0 : item.getProductCount(), + (left, right) -> { + try { + return Math.addExact(left, right); + } catch (ArithmeticException exception) { + throw new RuntimeException("历史退款商品数量异常"); + } + }, LinkedHashMap::new)); + Map originalCountByProductId = orderCarList.stream() + .filter(item -> item.getProductId() != null) + .collect(Collectors.toMap(HardCarEntity::getProductId, + item -> item.getProductCount() == null ? 0 : item.getProductCount(), + (left, right) -> { + try { + return Math.addExact(left, right); + } catch (ArithmeticException exception) { + throw new RuntimeException("订单商品数量异常"); + } + }, LinkedHashMap::new)); + requestedCountByProductId.forEach((productId, requestedCount) -> { + int occupiedCount = occupiedCountByProductId.getOrDefault(productId, 0); + int originalCount = originalCountByProductId.getOrDefault(productId, 0); + final int totalRefundCount; + try { + totalRefundCount = Math.addExact(occupiedCount, requestedCount); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款商品数量异常"); + } + if (totalRefundCount > originalCount) { + HardCarEntity orderCar = orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getProductId(), productId)) + .findFirst().orElse(null); + throw new RuntimeException("退款商品<" + (orderCar == null ? productId : orderCar.getProductName()) + ">数量超出"); + } + }); + } + + /** + * 将退款明细解析到原订单购物车行,优先使用商品 ID,兼容后台历史提交的购物车行 ID。 + * + * @param orderCarList 原订单购物车快照 + * @param refundCar 客户端提交或服务端组装的退款明细 + * @return 匹配的原订单购物车行;无法匹配时返回 {@code null} + */ + private HardCarEntity resolveRefundOrderCar(List orderCarList, HardAfterSaleCarEntity refundCar) { + if (refundCar == null) { + return null; + } + HardCarEntity byProductId = orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getProductId(), refundCar.getProductId())) + .findFirst().orElse(null); + if (byProductId != null) { + return byProductId; + } + return orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId())) + .findFirst().orElse(null); } /** @@ -1445,13 +1645,22 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = isDailyMenuOrder(order) + ? buildDailyMenuAdminRefundCars(order, dto.getCarList()) : dto.getCarList(); + hardAfterSaleCarService.createAdmin(refundCars, order, saveObj); } // 添加售后进度 HardAfterSaleLogEntity saveLogObj = HardAfterSaleLogEntity.builder() @@ -1551,8 +1762,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl carList = dto.getCarList().stream().map((item) -> HardCarEntity.builder() .productId(item.getProductId()) @@ -1561,10 +1777,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品, + * 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离, + * 使重复回调不会重复增加库存。

+ * + * @param order 原每日菜谱预约订单 + * @param afterSaleId 本次售后单 ID,不能为空 + */ + private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) { + if (afterSaleId == null) { + throw new RuntimeException("每日菜谱退款库存缺少售后单 ID"); + } + List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + if (ObjectUtil.isEmpty(refundCars)) { + hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId); + return; + } + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId); + } + + /** + * 恢复普通商品库存,保持旧库存服务的调用协议和退款后执行顺序。 + * + * @param order 原订单 + * @param afterSaleId 售后单 ID,仅用于读取部分退款商品快照 + */ + private void restoreOrderInventory(HardOrderEntity order, Long afterSaleId) { + List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + if (ObjectUtil.isEmpty(refundCars)) { + hardOrderService.fastenerInventory(order, YesNoEnum.NO); + } else { + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO); + } + } + /** * 使用下单时保存的购物车成交价快照计算每日菜谱退款金额,退款时不再依赖可能已下架或改价的商品主数据。 * 请求仅提供退款数量,商品和价格均以服务端订单购物车为准。 @@ -1599,26 +1854,116 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCountByProductId = new LinkedHashMap<>(); try { for (HardAfterSaleCarEntity refundCar : refundCarList) { + HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar); + if (orderCar == null || orderCar.getProductId() == null) { + throw new RuntimeException("退款商品不存在"); + } int refundCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount(); - if (refundCount <= 0) { + if (refundCount < 0) { + throw new RuntimeException("退款商品数量不能为负数"); + } + if (refundCount == 0) { continue; } - HardCarEntity orderCar = orderCarList.stream() - .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId())) - .findFirst() - .orElseThrow(() -> new RuntimeException("退款商品不存在")); - if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) { - throw new RuntimeException("退款商品价格异常"); - } - refundPrice = Math.addExact(refundPrice, Math.multiplyExact(orderCar.getProductPrice(), refundCount)); + refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact); } + + int refundPrice = 0; + for (Map.Entry entry : refundCountByProductId.entrySet()) { + int remainingCount = entry.getValue(); + List productCars = orderCarList.stream() + .filter(item -> ObjectUtil.equal(item.getProductId(), entry.getKey())) + .toList(); + for (HardCarEntity orderCar : productCars) { + if (remainingCount <= 0) { + break; + } + int originalCount = orderCar.getProductCount() == null ? 0 : orderCar.getProductCount(); + int currentCount = Math.min(remainingCount, Math.max(originalCount, 0)); + if (currentCount <= 0) { + continue; + } + if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) { + throw new RuntimeException("退款商品价格异常"); + } + refundPrice = Math.addExact(refundPrice, + Math.multiplyExact(orderCar.getProductPrice(), currentCount)); + remainingCount -= currentCount; + } + if (remainingCount > 0) { + throw new RuntimeException("退款商品数量超出订单数量"); + } + } + return refundPrice; } catch (ArithmeticException exception) { throw new RuntimeException("退款金额超出计算范围", exception); } - return refundPrice; + } + + /** + * 按原订单购物车生成后台每日菜谱退款快照,丢弃客户端携带的名称、价格和总价,避免后续整单退款读取不可信金额。 + * 同一商品多行时先聚合数量,库存恢复和历史退款占用保持同一商品维度。 + * + * @param order 每日菜谱预约订单 + * @param refundCarList 后台提交的退款商品及数量 + * @return 使用原订单商品资料和成交价计算的可信售后明细 + */ + private List buildDailyMenuAdminRefundCars(HardOrderEntity order, + List refundCarList) { + List orderCarList = hardCarService.getByParentCarId(order.getParentCarId()); + if (ObjectUtil.isEmpty(orderCarList)) { + throw new RuntimeException("订单购物车不存在"); + } + Map orderCarMap = orderCarList.stream() + .filter(item -> item.getProductId() != null) + .collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left, + LinkedHashMap::new)); + Map refundCountByProductId = new LinkedHashMap<>(); + for (HardAfterSaleCarEntity refundCar : refundCarList) { + HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar); + int refundCount = refundCar == null || refundCar.getProductCount() == null ? 0 : refundCar.getProductCount(); + if (orderCar == null || orderCar.getProductId() == null) { + throw new RuntimeException("退款商品不存在"); + } + if (refundCount < 0) { + throw new RuntimeException("退款商品数量不能为负数"); + } + if (refundCount == 0) { + continue; + } + try { + refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款商品数量异常", exception); + } + } + List result = new ArrayList<>(refundCountByProductId.size()); + for (Map.Entry entry : refundCountByProductId.entrySet()) { + HardCarEntity orderCar = orderCarMap.get(entry.getKey()); + if (orderCar == null || orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) { + throw new RuntimeException("退款商品价格异常"); + } + long totalPrice; + try { + totalPrice = Math.multiplyExact((long) orderCar.getProductPrice(), entry.getValue()); + } catch (ArithmeticException exception) { + throw new RuntimeException("退款金额超出计算范围", exception); + } + HardAfterSaleCarEntity trustedCar = new HardAfterSaleCarEntity(); + trustedCar.setProductId(orderCar.getProductId()); + trustedCar.setProductName(orderCar.getProductName()); + trustedCar.setProductPic(orderCar.getProductPic()); + trustedCar.setProductPrice(orderCar.getProductPrice().longValue()); + trustedCar.setProductNature(orderCar.getProductNature()); + trustedCar.setProductCount(entry.getValue()); + trustedCar.setProductWeight(orderCar.getProductWeight()); + trustedCar.setTotalPrice(totalPrice); + result.add(trustedCar); + } + return result; } /** @@ -1719,10 +2064,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl() + .eq(HardAutoRefundTaskEntity::getOrderId, order.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())); + if (existing != null) { + return existing; + } + HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder() + .taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .orderId(order.getId()).mchId(order.getMerchantId()) + .configId(order.getExpiredRefundConfigId()) + .triggerTime(order.getExpiredRefundDeadline()) + .restoreInventory(order.getExpiredRefundRestoreInventory()) + .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .autoAttemptCount(0).totalAttemptCount(0) + .nextRetryTime(order.getExpiredRefundDeadline()) + .build(); + if (baseMapper.insert(task) != 1) { + throw new RuntimeException("创建过期预约退款任务失败"); + } + return task; + } + /** * 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。 * @@ -240,6 +284,17 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl= MAX_AUTO_ATTEMPTS) { + if (List.of(HardAutoRefundTriggerTypeEnum.MANUAL, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL).contains(triggerType) + || autoAttempts >= MAX_AUTO_ATTEMPTS) { task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus()); task.setNextRetryTime(null); } else { @@ -595,12 +729,22 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageExpiredCandidates(Page page, + HardAutoRefundTaskPageDto dto) { + LocalDateTime now = LocalDateTime.now(); + Page entityPage = baseMapper.pageExpiredCandidates(page, dto, now); + List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class); + fillAfterSaleData(records); + fillOrderData(records); + fillUserData(records); + fillMerchantData(records); + Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + result.setRecords(records); + return result; + } + + /** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */ + private void fillOrderData(List records) { + List orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList(); + if (orderIds.isEmpty()) return; + Map orders = hardOrderMapper.selectBatchIds(orderIds).stream() + .collect(Collectors.toMap(HardOrderEntity::getId, Function.identity(), (first, second) -> first)); + records.forEach(item -> { + HardOrderEntity order = orders.get(item.getOrderId()); + if (order != null) { + item.setUserId(order.getUserId()); + item.setRefundPrice(order.getPayPrice()); + item.setApptUseDate(order.getApptUseDate()); + item.setHourType(order.getHourType()); + } + }); + } + /** * 按售后唯一键查询任务。 * @@ -870,9 +1081,19 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl() .and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus, HardAutoRefundTaskStatusEnum.PENDING.getStatus()) + .and(nested -> nested.ne(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .or(expired -> expired.eq(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .le(HardAutoRefundTaskEntity::getTriggerTime, now))) .or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus, HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()) - .le(HardAutoRefundTaskEntity::getNextRetryTime, now))) + .le(HardAutoRefundTaskEntity::getNextRetryTime, now) + .and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType, + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .le(HardAutoRefundTaskEntity::getTriggerTime, now))))) .orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime) .orderByAsc(HardAutoRefundTaskEntity::getId) .last("limit " + limit))); @@ -899,6 +1120,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过 + * Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。

+ */ +@Slf4j +@Validated +@Service +@RequiredArgsConstructor +public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTotalExportService { + + /** 用户预约导出任务的 Redis Key 命名空间。 */ + private static final String USER_RESERVATION_EXPORT_REDIS_KEY = "hard:canteen-user-appt:export"; + + /** 用户预约导出任务名称。 */ + private static final String USER_RESERVATION_EXPORT_TASK_NAME = "用户预约查询导出"; + + /** 用户预约导出创建时的占位总数。 */ + private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1; + + /** 用户预约导出每批读取的权限用户数。 */ + private static final int USER_RESERVATION_EXPORT_BATCH_SIZE = 200; + + /** 文件上传和任务收尾占用的额外进度步数。 */ + private static final int USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS = 2; + + /** 用户预约导出表头及合计行占用的固定行数。 */ + private static final int USER_RESERVATION_EXPORT_HEADER_ROW_COUNT = 2; + private static final int USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT = 1; + + /** 用户预约导出工作表允许写入的最大明细行数。 */ + private static final int MAX_USER_RESERVATION_EXPORT_ROWS = + BatchExcelExportTemplate.calculateMaxDetailRowCount(USER_RESERVATION_EXPORT_HEADER_ROW_COUNT, + USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT); + + /** 用户预约导出工作表名称。 */ + private static final String USER_RESERVATION_EXPORT_SHEET_NAME = "用户预约查询"; + + /** 用户预约导出文件目录。 */ + private static final String USER_RESERVATION_EXPORT_FILE_DIR = "hard-export"; + + /** 用户预约导出文件类型编码。 */ + private static final String USER_RESERVATION_EXPORT_FILE_TYPE = "HARD_CANTEEN_USER_APPT_EXPORT"; + + /** 用户预约导出文件 MIME 类型。 */ + private static final String USER_EXPORT_EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 用户预约导出文件名时间格式。 */ + private static final DateTimeFormatter USER_EXPORT_FILE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** 用户预约明细中核销时间的 Excel 展示格式。 */ + private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 菜品预约导出进度的 Redis Key 命名空间。 */ + private static final String PRODUCT_EXPORT_REDIS_KEY = "hard:canteen-product-appt:export"; + + /** 菜品预约导出任务名称。 */ + private static final String PRODUCT_EXPORT_TASK_NAME = "菜品预约统计导出"; + + /** 异步任务创建时的占位总数,真实日期数量在后台查询后更新。 */ + private static final int INITIAL_PROGRESS_TOTAL = 1; + + /** 菜品预约导出工作表名称。 */ + private static final String PRODUCT_EXPORT_SHEET_NAME = "菜品预约统计"; + + /** 文件中心存储目录。 */ + private static final String EXPORT_FILE_DIR = "hard-export"; + + /** 文件中心业务类型编码。 */ + private static final String PRODUCT_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_APPT_EXPORT"; + + /** Excel 文件 MIME 类型。 */ + private static final String EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 用餐时间段字典类型。 */ + private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu"; + + /** Excel 文件名时间格式。 */ + private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */ + private static final long MAX_QUERY_MONTHS = 2L; + + /** 页面协议中的已订餐状态。 */ + private static final String RESERVATION_STATUS_RESERVED = "RESERVED"; + + /** 页面协议中的未订餐状态。 */ + private static final String RESERVATION_STATUS_NOT_RESERVED = "NOT_RESERVED"; + + /** 页面协议中的无需核销状态。 */ + private static final String VERIFICATION_STATUS_NONE = "NONE"; + + /** 页面协议中的全部待核销状态。 */ + private static final String VERIFICATION_STATUS_UNVERIFIED = "UNVERIFIED"; + + /** 页面协议中的部分预约已核销状态。 */ + private static final String VERIFICATION_STATUS_PARTIAL = "PARTIAL"; + + /** 页面协议中的全部已核销状态。 */ + private static final String VERIFICATION_STATUS_VERIFIED = "VERIFIED"; + + /** 允许的订餐状态筛选值。 */ + private static final Set RESERVATION_STATUS_VALUES = Set.of( + RESERVATION_STATUS_RESERVED, RESERVATION_STATUS_NOT_RESERVED); + + /** 允许的核销状态筛选值。 */ + private static final Set VERIFICATION_STATUS_VALUES = Set.of( + VERIFICATION_STATUS_NONE, VERIFICATION_STATUS_UNVERIFIED, + VERIFICATION_STATUS_PARTIAL, VERIFICATION_STATUS_VERIFIED); + + /** 有效预约订单状态,只统计已支付和已完成核销的订单。 */ + private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes(); + + /** 用户预约导出和订单关联的单批用户数量。 */ + private static final long USER_BATCH_SIZE = 500L; + + /** 单次订单查询允许的用户主键数量。 */ + private static final int ORDER_USER_BATCH_SIZE = 500; + + /** 用户远程服务,负责导出权限范围内的完整用户资料。 */ + private final RemoteUserService remoteUserService; + + /** 预约订单查询 Mapper,供用户预约导出直接读取预约订单。 */ + private final HardOrderMapper hardOrderMapper; + + /** 菜品预约聚合 Mapper。 */ + private final HardCarMapper hardCarMapper; + + /** 每日菜谱远程服务,用于展开套餐组成。 */ + private final RemoteDailyMenuService remoteDailyMenuService; + + /** 部门远程服务,用于补齐预约人所属部门名称。 */ + private final RemoteDeptService remoteDeptService; + + /** 餐段字典远程服务,用于输出餐段名称。 */ + private final RemoteDictService remoteDictService; + + /** 商户远程服务,用于补齐食堂名称。 */ + private final RemoteMchService remoteMchService; + + /** 文件中心远程服务。 */ + private final RemoteFileService remoteFileService; + + /** 通用异步任务执行器。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** + * 提交用户每日预约异步导出任务。 + * + *

请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并 + * 分批写入 Excel,避免长时间占用 HTTP 连接和一次性持有全部导出行。

+ * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 已创建任务的初始进度 + */ + @Override + public AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query) { + validateQuery(query); + HardCanteenUserApptQueryDto querySnapshot = copyUserReservationQuery(query); + return asyncTaskExecutorService.submitWithResult(USER_RESERVATION_EXPORT_REDIS_KEY, + USER_RESERVATION_EXPORT_TASK_NAME, USER_RESERVATION_EXPORT_INITIAL_TOTAL, + reporter -> exportUserReservations(querySnapshot, reporter)); + } + + /** + * 查询用户预约 Excel 数据。 + * + * @param query 用户、日期范围和预约状态筛选条件 + * @return 用户和日期维度的导出行,最后一行为餐段及当日金额合计 + */ + @Override + public List userReservationExport(HardCanteenUserApptQueryDto query) { + validateQuery(query); + HardCanteenUserApptQueryDto exportQuery = copyUserReservationQuery(query); + if (!narrowQueryToReservationCandidates(exportQuery)) { + return List.of(); + } + Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); + List reservationRows = + listAllFilteredExportRows(exportQuery, getQueryDates(exportQuery)); + if (reservationRows.isEmpty()) { + return List.of(); + } + UserReservationExportSummary summary = + new UserReservationExportSummary(exportQuery, mealSegmentLabelMap); + summary.addRows(reservationRows); + List excelRows = new ArrayList<>(reservationRows.size() + 1); + reservationRows.stream().map(row -> toExcelRow(row, exportQuery, mealSegmentLabelMap)) + .forEach(excelRows::add); + excelRows.add(toExcelSummaryRow(summary, mealSegmentLabelMap)); + return excelRows; + } + + /** + * 在异步线程中分批查询用户预约、生成 Excel 并上传文件中心。 + * + * @param query 后台线程独占的用户预约筛选条件 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportUserReservations(HardCanteenUserApptQueryDto query, + AsyncTaskProgressReporter reporter) { + if (!narrowQueryToReservationCandidates(query)) { + throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); + } + Page firstUserPage = fetchExportUserPage(1L, USER_RESERVATION_EXPORT_BATCH_SIZE, query); + long userTotal = firstUserPage.getTotal(); + if (userTotal <= 0L) { + throw new IllegalStateException("没有符合当前条件的用户预约数据"); + } + validateUserReservationExportRowCapacity(userTotal, query); + int progressTotal = calculateUserReservationExportProgressTotal(userTotal); + reporter.updateTotal(progressTotal, "已确定 " + userTotal + " 位符合条件用户,将分批生成预约文件"); + + String fileName = "用户预约查询_" + LocalDateTime.now().format(USER_EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + Path outputPath = null; + try { + outputPath = Files.createTempFile("canteen-user-appt-export-", ".xlsx"); + long exportedRowTotal = writeUserReservationWorkbook(outputPath, query, firstUserPage, userTotal, + reporter); + String fileUrl = uploadUserReservationWorkbook(outputPath, fileName); + reporter.advance("用户预约导出文件已生成,共 " + exportedRowTotal + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|用户预约查询导出|生成或保存文件失败,fileName:{}", fileName, exception); + throw new IllegalStateException("生成用户预约导出文件失败", exception); + } finally { + deleteTemporaryExportFile(outputPath); + } + } + + /** + * 按权限用户分页读取并把当前批次结果写入 EasyExcel 流式工作簿。 + * + *

订餐和核销状态依赖订单关联结果,因此在每批用户展开日期后执行;批次写入完成即可释放, + * 不会把整个日期范围的全部用户行同时留在 JVM 堆中。

+ * + * @param outputPath 最终上传前使用的临时 XLSX 文件 + * @param query 已冻结的筛选条件 + * @param firstUserPage 已用于取得用户总数的第一页,避免重复远程调用 + * @param userTotal 任务开始时取得的权限用户总数 + * @param reporter 当前异步任务进度报告器 + * @return 实际写入 Excel 的用户日期行数 + */ + private long writeUserReservationWorkbook(Path outputPath, HardCanteenUserApptQueryDto query, + Page firstUserPage, long userTotal, + AsyncTaskProgressReporter reporter) { + List queryDates = getQueryDates(query); + Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); + long processedUserTotal = 0L; + long exportedRowTotal = 0L; + long current = 1L; + UserReservationExportSummary summary = new UserReservationExportSummary(query, mealSegmentLabelMap); + + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenUserApptExcelVo.class).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(USER_RESERVATION_EXPORT_SHEET_NAME).build(); + Page userPage = firstUserPage; + while (processedUserTotal < userTotal) { + List pageRecords = userPage.getRecords() == null ? List.of() : userPage.getRecords(); + if (pageRecords.isEmpty()) { + break; + } + int remainingUserCount = Math.toIntExact(Math.min(pageRecords.size(), userTotal - processedUserTotal)); + List batchUsers = pageRecords.subList(0, remainingUserCount); + List batchReservationRows = + filterRows(buildRows(batchUsers, query, queryDates), query); + long nextExportedRowTotal = exportedRowTotal + batchReservationRows.size(); + if (nextExportedRowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); + } + List batchRows = batchReservationRows.stream() + .map(row -> toExcelRow(row, query, mealSegmentLabelMap)).toList(); + if (!batchRows.isEmpty()) { + excelWriter.write(batchRows, writeSheet); + summary.addRows(batchReservationRows); + } + exportedRowTotal = nextExportedRowTotal; + processedUserTotal += batchUsers.size(); + reporter.advance("已处理 " + processedUserTotal + " / " + userTotal + " 位用户,已写入 " + + exportedRowTotal + " 条预约记录"); + if (processedUserTotal >= userTotal) { + break; + } + userPage = fetchExportUserPage(++current, USER_RESERVATION_EXPORT_BATCH_SIZE, query); + } + if (processedUserTotal != userTotal) { + throw new IllegalStateException("导出用户数据在执行期间发生变化,预期 " + userTotal + + " 位,实际处理 " + processedUserTotal + " 位"); + } + if (exportedRowTotal == 0L) { + throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); + } + excelWriter.write(List.of(toExcelSummaryRow(summary, mealSegmentLabelMap)), writeSheet); + } + return exportedRowTotal; + } + + /** + * 根据权限用户总数计算“用户查询批次数 + 文件上传和任务收尾”的进度总步数。 + * + * @param userTotal 当前筛选条件下的权限用户总数 + * @return 统一进度组件使用的总步数 + */ + private int calculateUserReservationExportProgressTotal(long userTotal) { + return BatchExcelExportTemplate.calculateProgressTotal(userTotal, USER_RESERVATION_EXPORT_BATCH_SIZE, + USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS); + } + + /** + * 在没有订餐或核销状态筛选时,提前校验确定会生成的“用户 × 日期”明细行数。 + * + *

存在关联状态筛选时最终行数只能在分批关联订单后确定,写入过程仍会执行单工作表上限保护。

+ * + * @param userTotal 当前用户筛选和数据权限范围内的用户总数 + * @param query 已冻结的日期和预约状态筛选条件 + */ + private void validateUserReservationExportRowCapacity(long userTotal, HardCanteenUserApptQueryDto query) { + if (hasJoinedStatusFilter(query)) { + return; + } + long rowTotal; + try { + rowTotal = Math.multiplyExact(userTotal, (long) getQueryDates(query).size()); + } catch (ArithmeticException exception) { + throw new IllegalStateException("用户预约导出数据量过大,请缩小查询范围后分批导出", exception); + } + if (rowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); + } + } + + /** + * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 + * + * @param outputPath 已完成写入的临时文件 + * @param fileName 用户下载时使用的业务文件名 + * @return 文件中心提供的下载地址 + * @throws Exception 临时文件读取或远程上传失败时抛出 + */ + private String uploadUserReservationWorkbook(Path outputPath, String fileName) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), + USER_EXPORT_EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath)); + R uploadResult = remoteFileService.innerUpload(exportFile, USER_RESERVATION_EXPORT_FILE_DIR, null, + USER_RESERVATION_EXPORT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的用户预约导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回用户预约导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 复制用户预约查询条件,避免异步任务与控制器请求对象共享可变数组或列表。 + * + * @param source 已通过基础校验的请求筛选条件 + * @return 后台线程独占的完整筛选条件副本 + */ + private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) { + HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto(); + target.setApptUseDateList(source.getApptUseDateList() == null + ? null : source.getApptUseDateList().clone()); + target.setNameOrPhone(source.getNameOrPhone()); + target.setDeptIds(copyList(source.getDeptIds())); + target.setIsInclude(source.getIsInclude()); + target.setMerchantId(source.getMerchantId()); + target.setHourType(source.getHourType()); + target.setReservationStatus(source.getReservationStatus()); + target.setVerificationStatus(source.getVerificationStatus()); + target.setUserIds(copyList(source.getUserIds())); + return target; + } + + /** + * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。 + * + * @param source 请求对象中的可变列表 + * @param 列表元素类型 + * @return 空值保持为空,非空列表返回独立副本 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** + * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeUploadOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 清理异步导出创建的最终临时文件;清理失败只记录告警,不覆盖原始任务结果。 + * + * @param outputPath 待删除的临时文件路径,允许为空 + */ + private void deleteTemporaryExportFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception); + } + } + + /** + * 校验必填条件和前后端状态协议值,防止未知值被静默当成“全部”。 + * + * @param query 待校验的查询条件 + */ + private void validateQuery(HardCanteenUserApptQueryDto query) { + if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2 + || query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) { + throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期"); + } + LocalDate startDate = query.getApptUseDateList()[0]; + LocalDate endDate = query.getApptUseDateList()[1]; + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("用餐结束日期不能早于开始日期"); + } + if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) { + throw new IllegalArgumentException("用餐日期范围最多查询两个月"); + } + if (StrUtil.isNotBlank(query.getReservationStatus()) + && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) { + throw new IllegalArgumentException("订餐状态不正确"); + } + if (StrUtil.isNotBlank(query.getVerificationStatus()) + && !VERIFICATION_STATUS_VALUES.contains(query.getVerificationStatus())) { + throw new IllegalArgumentException("核销状态不正确"); + } + } + + /** + * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。 + * + * @param query 已通过日期范围校验的查询条件 + * @return 按日期升序排列的查询日期 + */ + private List getQueryDates(HardCanteenUserApptQueryDto query) { + LocalDate endDate = query.getApptUseDateList()[1]; + List dates = new ArrayList<>(); + LocalDate currentDate = query.getApptUseDateList()[0]; + while (!currentDate.isAfter(endDate)) { + dates.add(currentDate); + if (currentDate.equals(endDate)) { + break; + } + currentDate = currentDate.plusDays(1L); + } + return dates; + } + + /** + * 判断查询是否需要在用户与预约订单关联后再进行筛选。 + * + * @param query 查询条件 + * @return 存在订餐或核销状态条件时返回 true + */ + private boolean hasJoinedStatusFilter(HardCanteenUserApptQueryDto query) { + return StrUtil.isNotBlank(query.getReservationStatus()) || StrUtil.isNotBlank(query.getVerificationStatus()); + } + + /** + * 判断当前筛选结果是否必然存在有效预约订单。 + * + *

已订餐以及待核销、部分核销、已核销都可以先从订单表反查候选用户;未订餐和无需核销属于 + * “订单不存在”条件,仍需以全部权限用户为主表执行左关联。

+ * + * @param query 预约状态和核销状态筛选条件 + * @return 可以先按预约订单收窄用户范围时返回 true + */ + private boolean requiresExistingReservation(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getReservationStatus(), RESERVATION_STATUS_RESERVED) + || (StrUtil.isNotBlank(query.getVerificationStatus()) + && !Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_NONE)); + } + + /** + * 对必然存在预约的列表或导出条件先查询候选用户,并写回独立查询快照。 + * + *

候选 ID 只负责缩小 UPMS 查询范围,姓名、部门和当前操作者的数据权限仍由 UPMS 最终过滤; + * 原查询已指定用户时,订单查询也会先应用该范围,因此不会扩大导出对象。

+ * + * @param query 当前列表或导出流程独占、允许写入候选用户主键的查询快照 + * @return 无需预筛或存在候选用户时返回 true,确定没有匹配预约时返回 false + */ + private boolean narrowQueryToReservationCandidates(HardCanteenUserApptQueryDto query) { + if (!requiresExistingReservation(query)) { + return true; + } + List candidateUserIds = hardOrderMapper.listReservationUserIds( + buildReservationOrderQuery(query, copyList(query.getUserIds()))); + if (candidateUserIds == null || candidateUserIds.isEmpty()) { + return false; + } + query.setUserIds(candidateUserIds.stream().filter(Objects::nonNull).distinct().toList()); + return !query.getUserIds().isEmpty(); + } + + /** + * 获取完整用户导出资料并在关联预约订单后执行状态筛选。 + * + *

与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。

+ * + * @param query 查询条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 携带完整用户主资料和身份资料的预约导出行 + */ + private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query, + List queryDates) { + List users = fetchAllExportUsers(query); + return filterRows(buildRows(users, query, queryDates), query); + } + + /** + * 调用 UPMS 导出分页查询完整用户主资料、角色岗位和身份扩展。 + * + * @param current 当前页码 + * @param size 每页数量 + * @param query 用户筛选条件 + * @return 含准确总数和完整导出资料的用户分页 + */ + private Page fetchExportUserPage(long current, long size, HardCanteenUserApptQueryDto query) { + R> response = remoteUserService.getScopeExportUserPage(current, size, + buildUserQuery(query)); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.isBlank(message) ? "查询用户导出资料失败" : message); + } + return response.getData(); + } + + /** + * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。 + * + * @param query 用户预约查询条件 + * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象 + */ + private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) { + UserDTO userQuery = new UserDTO(); + userQuery.setNameOrPhone(query.getNameOrPhone()); + userQuery.setDeptIds(copyList(query.getDeptIds())); + userQuery.setIsInclude(query.getIsInclude()); + userQuery.setUserIds(copyList(query.getUserIds())); + return userQuery; + } + + /** + * 分页遍历完整用户导出资料,供兼容同步导出使用。 + * + *

管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。

+ * + * @param query 用户筛选条件 + * @return 权限范围内携带角色岗位和身份资料的全部用户 + */ + private List fetchAllExportUsers(HardCanteenUserApptQueryDto query) { + List users = new ArrayList<>(); + long current = 1L; + long total; + do { + Page page = fetchExportUserPage(current, USER_BATCH_SIZE, query); + List records = page.getRecords() == null ? List.of() : page.getRecords(); + users.addAll(records); + total = page.getTotal(); + if (records.isEmpty()) { + break; + } + current++; + } while (users.size() < total); + return users; + } + + /** + * 将用户列表与日期范围内的预约订单按用户、日期左关联,每个用户每天至少生成一行。 + * + * @param users 权限用户列表 + * @param query 日期范围、食堂和餐段筛选条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 按用户顺序、日期升序排列的预约行 + */ + private List buildRows(List users, HardCanteenUserApptQueryDto query, + List queryDates) { + if (users == null || users.isEmpty()) { + return List.of(); + } + List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + List orders = fetchReservationOrders(userIds, query); + return buildRows(users, query, queryDates, orders); + } + + /** + * 使用已加载的预约订单构造用户-日期行,供列表查询和统计查询共用同一行生成逻辑。 + * + * @param users 权限范围内的用户 + * @param query 日期、食堂和餐段筛选条件 + * @param queryDates 查询范围内的自然日 + * @param orders 已按相同筛选条件加载的有效预约订单 + * @return 按用户顺序和日期顺序排列的预约行 + */ + private List buildRows(List users, HardCanteenUserApptQueryDto query, + List queryDates, List orders) { + if (users == null || users.isEmpty()) { + return List.of(); + } + Map merchantNameMap = fetchMerchantNameMap(orders); + Map>> orderMap = orders.stream() + .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) + .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new, + Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new, + Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList())))); + + return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> { + Map> userOrderMap = + orderMap.getOrDefault(user.getUserId(), Map.of()); + List userOrders = userOrderMap.getOrDefault(date, List.of()); + HardCanteenUserApptVo row = new HardCanteenUserApptVo(); + row.setUserId(user.getUserId()); + row.setUserName(resolveUserName(user)); + row.setUsername(user.getUsername()); + row.setPhone(user.getPhone()); + row.setAvatar(user.getAvatar()); + row.setDeptId(user.getDeptId()); + row.setDeptName(user.getDeptName()); + row.setNickname(user.getNickname()); + row.setGender(user.getGender()); + row.setEmail(user.getEmail()); + row.setCreateTime(user.getCreateTime()); + row.setPostList(user.getPostList()); + row.setRoleList(user.getRoleList()); + row.setStaffingType(user.getStaffingType()); + row.setLockFlag(user.getLockFlag()); + // UPMS 的权限用户分页已批量补齐身份资料,这里直接复制,避免按用户再次远程查询。 + row.setIdentityTypes(user.getIdentityTypes()); + row.setStudentInfo(user.getStudentInfo()); + row.setTeacherInfo(user.getTeacherInfo()); + row.setApptUseDate(date); + row.setReservationStatus(userOrders.isEmpty() + ? RESERVATION_STATUS_NOT_RESERVED : RESERVATION_STATUS_RESERVED); + row.setVerificationStatus(resolveVerificationStatus(userOrders)); + row.setTotalPrice(calculateTotalPrice(userOrders)); + row.setOrders(userOrders); + return row; + })).toList(); + } + + /** + * 按用户主键分批查询指定日期范围内的有效食堂预约订单。 + * + * @param userIds 权限范围内的用户主键 + * @param query 日期范围、食堂和餐段筛选条件 + * @return 当前租户内符合条件的有效预约订单 + */ + private List fetchReservationOrders(List userIds, HardCanteenUserApptQueryDto query) { + if (userIds.isEmpty()) { + return List.of(); + } + List orders = new ArrayList<>(); + for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size()); + HardOrderDto orderQuery = buildReservationOrderQuery(query, + new ArrayList<>(userIds.subList(start, end))); + orders.addAll(hardOrderMapper.scopeList(orderQuery, null)); + } + return orders; + } + + /** + * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * + * @param orders 当前筛选口径下的预约订单 + * @return 父购物车主键到商品快照的映射 + */ + private Map> fetchReservationCars(List orders) { + List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + if (parentCarIds.isEmpty()) { + return Map.of(); + } + Map> result = new LinkedHashMap<>(); + for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size()); + hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end)) + .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); + } + return result; + } + + /** + * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 + * + *

统一复用可保证预查询命中的用户与最终写入 Excel 的有效预约口径一致,避免日期、食堂、餐段、 + * 订单状态或预约关联条件在两次查询间发生偏差。

+ * + * @param query 用户预约页面提交的日期、食堂和餐段筛选条件 + * @param userIds 需要限制的用户主键;为空时查询当前租户内全部候选预约用户 + * @return 可直接交给订单 Mapper 的完整预约条件 + */ + private HardOrderDto buildReservationOrderQuery(HardCanteenUserApptQueryDto query, List userIds) { + HardOrderDto orderQuery = new HardOrderDto(); + orderQuery.setUserIds(userIds); + orderQuery.setMerchantType(MerchantTypeEnum.CANTEEN.getType()); + orderQuery.setMerchantId(query.getMerchantId()); + orderQuery.setHourType(query.getHourType()); + orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType()); + orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES); + orderQuery.setApptIdOrApptRuleIdNotNull(true); + orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), + query.getApptUseDateList()[1].atTime(LocalTime.MAX) + }); + return orderQuery; + } + + /** + * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。 + * + * @param orders 有效预约订单 + * @return 商户主键到食堂名称的映射 + */ + private Map fetchMerchantNameMap(List orders) { + List merchantIds = orders.stream().map(HardOrderVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + if (merchantIds.isEmpty()) { + return Map.of(); + } + try { + R> response = remoteMchService.getByIds(merchantIds); + if (response == null || !response.isOk() || response.getData() == null) { + log.warn("查询用户预约食堂名称失败,保留预约订单并使用空名称"); + return Map.of(); + } + return response.getData().stream().filter(merchant -> + merchant.getId() != null && StrUtil.isNotBlank(merchant.getName())) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, + (left, right) -> left, LinkedHashMap::new)); + } catch (Exception exception) { + log.warn("查询用户预约食堂名称异常,保留预约订单并使用空名称,原因:{}", exception.getMessage()); + return Map.of(); + } + } + + /** + * 将订单查询对象转换为页面所需的最小预约明细。 + * + * @param order 有效预约订单 + * @param merchantNameMap 食堂名称映射 + * @return 预约明细 + */ + private HardCanteenUserApptOrderVo toOrderVo(HardOrderVo order, Map merchantNameMap) { + HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo(); + detail.setId(order.getId()); + detail.setHourType(order.getHourType()); + detail.setProductDesc(order.getProductDesc()); + detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); + detail.setTotalPrice(order.getTotalPrice()); + detail.setOrderStatus(order.getOrderStatus()); + detail.setUpdateTime(order.getUpdateTime()); + detail.setUpdateBy(order.getUpdateBy()); + return detail; + } + + /** + * 汇总预约明细的商品总价,历史空金额按零分计算。 + * + * @param orders 同一用户、同一用餐日期和当前查询口径下的有效预约明细 + * @return 商品总价,单位为分;没有预约时返回零 + */ + private long calculateTotalPrice(List orders) { + return orders.stream().map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + } + + /** + * 依次按姓名、昵称和登录账号确定页面展示名。 + * + * @param user 用户信息 + * @return 非空展示名;用户资料均未维护时返回“未命名用户” + */ + private String resolveUserName(UserVO user) { + if (StrUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return StrUtil.isBlank(user.getUsername()) ? "未命名用户" : user.getUsername(); + } + + /** + * 根据有效预约明细计算用户整体核销状态。 + * + * @param orders 用户在当前筛选口径下的有效预约明细 + * @return NONE、UNVERIFIED、PARTIAL 或 VERIFIED + */ + private String resolveVerificationStatus(List orders) { + if (orders.isEmpty()) { + return VERIFICATION_STATUS_NONE; + } + long verifiedCount = orders.stream().filter(order -> + Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).count(); + if (verifiedCount == 0L) { + return VERIFICATION_STATUS_UNVERIFIED; + } + return verifiedCount == orders.size() ? VERIFICATION_STATUS_VERIFIED : VERIFICATION_STATUS_PARTIAL; + } + + /** + * 按订餐和核销状态筛选已完成左关联的用户行。 + * + * @param rows 用户预约行 + * @param query 状态筛选条件 + * @return 符合状态条件的行 + */ + private List filterRows(List rows, + HardCanteenUserApptQueryDto query) { + return rows.stream().filter(row -> + (StrUtil.isBlank(query.getReservationStatus()) + || Objects.equals(query.getReservationStatus(), row.getReservationStatus())) + && (StrUtil.isBlank(query.getVerificationStatus()) + || Objects.equals(query.getVerificationStatus(), row.getVerificationStatus()))) + .toList(); + } + + /** + * 对关联状态筛选后的用户行执行内存分页。 + * + * @param current 当前页码 + * @param size 每页数量 + * @param rows 全部筛选结果 + * @return 标准 MyBatis-Plus 分页结果 + */ + private Page paginateRows(long current, long size, + List rows) { + long offset = (current - 1L) * size; + int fromIndex = offset >= rows.size() ? rows.size() : Math.toIntExact(offset); + long endOffset = Math.min(offset + size, rows.size()); + int toIndex = Math.max(fromIndex, Math.toIntExact(endOffset)); + Page result = new Page<>(current, size, rows.size()); + result.setRecords(rows.subList(fromIndex, toIndex)); + return result; + } + + /** + * 将用户预约汇总转换为单行 Excel 数据,保持“一个用户 + 一个日期”导出口径。 + * + * @param row 用户预约汇总 + * @param query 原始查询条件,用于无订单时保留所选餐段 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return Excel 导出行 + */ + private HardCanteenUserApptExcelVo toExcelRow(HardCanteenUserApptVo row, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + List orders = row.getOrders(); + List verifiedOrders = orders.stream().filter(order -> + Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).toList(); + HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); + excel.setUserId(row.getUserId()); + excel.setUserName(row.getUserName()); + excel.setEmail(row.getEmail()); + excel.setPhone(row.getPhone()); + excel.setDeptName(row.getDeptName()); + excel.setPostNames(joinPostNames(row.getPostList())); + excel.setIdentityTypeNames(joinIdentityNames(row.getIdentityTypes())); + if (row.getStudentInfo() != null) { + excel.setStudentNo(row.getStudentInfo().getStudentNo()); + excel.setSchoolName(row.getStudentInfo().getSchoolName()); + excel.setClassName(row.getStudentInfo().getClassName()); + excel.setGuardianPhones(row.getStudentInfo().getGuardianPhones()); + } + if (row.getTeacherInfo() != null) { + excel.setTeacherNo(row.getTeacherInfo().getTeacherNo()); + } + excel.setApptUseDate(row.getApptUseDate()); + excel.setReservationStatus(Objects.equals(row.getReservationStatus(), RESERVATION_STATUS_RESERVED) + ? "已订餐" : "未订餐"); + excel.setHourTypes(orders.isEmpty() + ? (StrUtil.isBlank(query.getHourType()) ? "全部餐段" + : resolveMealSegmentLabel(query.getHourType(), mealSegmentLabelMap)) + : joinDistinct(orders, order -> resolveMealSegmentLabel(order.getHourType(), + mealSegmentLabelMap))); + excel.setBreakfastReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.MORNING)); + excel.setLunchReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NOONING)); + excel.setDinnerReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NIGHT)); + excel.setLateNightReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.LATE_NIGHT)); + excel.setMealSegmentTotalPrices(buildMealSegmentTotalPrices(orders, query, mealSegmentLabelMap)); + excel.setTotalPrice(centsToYuan(row.getTotalPrice())); + excel.setProductDescriptions(joinDistinct(orders, order -> + StrUtil.isBlank(order.getProductDesc()) ? "订餐内容未记录" : order.getProductDesc())); + excel.setMerchantNames(joinDistinct(orders, HardCanteenUserApptOrderVo::getMerchantName)); + excel.setVerificationStatus(verificationStatusText(row.getVerificationStatus())); + excel.setVerificationTimes(joinDistinct(verifiedOrders, order -> order.getUpdateTime() == null + ? "核销时间未记录" : EXCEL_DATE_TIME_FORMATTER.format(order.getUpdateTime()))); + return excel; + } + + /** + * 生成用户预约导出文件末尾的金额合计行。 + * + *

合计仍使用与明细相同的导出模型,姓名列标记“合计”,餐段金额和当日金额分别写入对应统计列, + * 其余人员及预约字段保持为空。

+ * + * @param summary 已累计全部导出分片的金额汇总 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 可直接追加到 EasyExcel 工作表末尾的合计行 + */ + private HardCanteenUserApptExcelVo toExcelSummaryRow(UserReservationExportSummary summary, + Map mealSegmentLabelMap) { + HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); + excel.setUserName("合计"); + excel.setMealSegmentTotalPrices(buildExportSummaryMealSegmentTotalPrices(summary, + mealSegmentLabelMap)); + excel.setTotalPrice(centsToYuan(summary.totalPrice)); + return excel; + } + + /** + * 查询用餐时间段字典并保留字典排序;远程调用失败时由实际订单编码继续完成导出。 + * + * @return 餐段编码到展示标签的有序映射 + */ + private Map fetchMealSegmentLabelMap() { + try { + R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); + if (response == null || !response.isOk() || response.getData() == null) { + log.warn("查询用餐时间段字典失败,导出将使用订单餐段编码"); + return Map.of(); + } + return response.getData().stream().filter(item -> + StrUtil.isNotBlank(item.getItemValue()) && StrUtil.isNotBlank(item.getLabel())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, + (left, right) -> left, LinkedHashMap::new)); + } catch (Exception exception) { + log.warn("查询用餐时间段字典异常,导出将使用订单餐段编码,原因:{}", exception.getMessage()); + return Map.of(); + } + } + + /** + * 将餐段协议编码转换为导出文案,并兼容历史空编码。 + * + * @param hourType 订单或查询条件中的餐段协议编码 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 字典标签;未知编码保留原值,空编码返回“未标记餐段” + */ + private String resolveMealSegmentLabel(String hourType, Map mealSegmentLabelMap) { + if (StrUtil.isBlank(hourType)) { + return "未标记餐段"; + } + return mealSegmentLabelMap.getOrDefault(hourType, hourType); + } + + /** + * 生成一个固定餐段的预约状态。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件,用于区分未订餐和未参与查询的餐段 + * @param hourType 固定餐段协议枚举 + * @return “已订N单”“未订餐”或“未查询” + */ + private String buildMealSegmentReservation(List orders, + HardCanteenUserApptQueryDto query, + HardHourTypeEnum hourType) { + if (StrUtil.isNotBlank(query.getHourType()) && !Objects.equals(query.getHourType(), hourType.getType())) { + return "未查询"; + } + long orderCount = orders.stream().filter(order -> + Objects.equals(hourType.getType(), order.getHourType())).count(); + return orderCount == 0L ? "未订餐" : "已订" + orderCount + "单"; + } + + /** + * 生成各餐段商品总价摘要,未订餐段明确输出零元。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件,选择餐段时只生成该餐段金额 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 例如“早餐:0.00元;午餐:18.50元”的摘要 + */ + private String buildMealSegmentTotalPrices(List orders, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + Map segments = resolveMealSegments(orders, query, mealSegmentLabelMap); + if (segments.isEmpty()) { + return "全部餐段:0.00元"; + } + return segments.entrySet().stream().map(entry -> { + long totalPrice = orders.stream().filter(order -> + Objects.equals(entry.getKey(), order.getHourType())) + .map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + return entry.getValue() + ":" + formatYuan(totalPrice); + }).collect(Collectors.joining(";")); + } + + /** + * 格式化全部导出明细累计后的各餐段金额。 + * + * @param summary 跨分片累计的金额汇总 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 例如“早餐:60.80元;午餐:119.00元”的餐段合计 + */ + private String buildExportSummaryMealSegmentTotalPrices(UserReservationExportSummary summary, + Map mealSegmentLabelMap) { + if (summary.mealSegmentTotalPrices.isEmpty()) { + return "全部餐段:0.00元"; + } + return summary.mealSegmentTotalPrices.entrySet().stream() + .map(entry -> resolveMealSegmentLabel(entry.getKey(), mealSegmentLabelMap) + + ":" + formatYuan(entry.getValue())) + .collect(Collectors.joining(";")); + } + + /** + * 合并当前字典餐段和订单中的历史餐段编码,供预约数量及金额摘要复用。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 保持字典顺序并补齐历史编码的餐段映射 + */ + private Map resolveMealSegments(List orders, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + Map segments = new LinkedHashMap<>(); + if (StrUtil.isNotBlank(query.getHourType())) { + segments.put(query.getHourType(), + mealSegmentLabelMap.getOrDefault(query.getHourType(), query.getHourType())); + } else { + segments.putAll(mealSegmentLabelMap); + orders.stream().map(HardCanteenUserApptOrderVo::getHourType).filter(StrUtil::isNotBlank) + .forEach(hourType -> segments.putIfAbsent(hourType, hourType)); + } + return segments; + } + + /** + * 提取、去空、去重并连接预约明细字段。 + * + * @param orders 预约明细列表 + * @param mapper 字段提取函数 + * @return 使用中文分号连接的字段值;没有有效值时返回空字符串 + */ + private String joinDistinct(List orders, + Function mapper) { + return orders.stream().map(mapper).filter(StrUtil::isNotBlank).distinct() + .collect(Collectors.joining(";")); + } + + /** + * 将整数分转换为精确的元金额。 + * + * @param price 商品总价,单位为分;为空时按零计算 + * @return 以元为单位且固定两位小数精度的金额 + */ + private BigDecimal centsToYuan(Long price) { + return BigDecimal.valueOf(price == null ? 0L : price, 2); + } + + /** + * 将整数分格式化为带元单位的两位小数文案。 + * + * @param price 商品总价,单位为分 + * @return 例如“18.50元”的金额文案 + */ + private String formatYuan(long price) { + return centsToYuan(price).toPlainString() + "元"; + } + + /** + * 拼接用户岗位名称,空岗位列表输出空字符串。 + * + * @param posts 完整用户导出分页返回的岗位列表 + * @return 以中文逗号分隔的岗位名称 + */ + private String joinPostNames(List posts) { + if (posts == null || posts.isEmpty()) { + return ""; + } + return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) + .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 按用户身份协议顺序拼接身份中文名称。 + * + * @param identityTypes UPMS 返回的用户身份协议编码 + * @return 以中文逗号分隔的身份名称;未知历史编码保留原值 + */ + private String joinIdentityNames(List identityTypes) { + if (identityTypes == null || identityTypes.isEmpty()) { + return ""; + } + return identityTypes.stream().map(identityType -> { + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + return identity == null ? identityType : identity.getDescription(); + }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 将核销状态协议值转换为导出文案。 + * + * @param status 核销状态协议值 + * @return 中文核销状态 + */ + private String verificationStatusText(String status) { + if (Objects.equals(status, VERIFICATION_STATUS_VERIFIED)) { + return "已核销"; + } + if (Objects.equals(status, VERIFICATION_STATUS_PARTIAL)) { + return "部分核销"; + } + if (Objects.equals(status, VERIFICATION_STATUS_UNVERIFIED)) { + return "待核销"; + } + return "无需核销"; + } + + /** + * 用户预约导出跨分片金额汇总器。 + * + *

所有金额均以分为单位累计,生成最终合计行时才统一转换为元,避免分片导出过程中出现精度损失。

+ */ + private static final class UserReservationExportSummary { + + /** 各餐段累计金额,键为订单餐段协议编码,值为商品总价,单位为分。 */ + private final Map mealSegmentTotalPrices = new LinkedHashMap<>(); + + /** 全部导出明细的当日金额累计值,单位为分。 */ + private long totalPrice; + + /** + * 创建跨分片汇总器,并按查询餐段或餐段字典顺序预置零金额。 + * + * @param query 本次导出的餐段筛选条件 + * @param mealSegmentLabelMap 餐段编码到字典标签的有序映射 + */ + private UserReservationExportSummary(HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + if (StrUtil.isNotBlank(query.getHourType())) { + mealSegmentTotalPrices.put(query.getHourType(), 0L); + } else { + mealSegmentLabelMap.keySet().forEach(hourType -> + mealSegmentTotalPrices.put(hourType, 0L)); + } + } + + /** + * 累计一批已经通过全部查询条件筛选的用户日期明细。 + * + * @param rows 当前分片即将写入 Excel 的用户日期明细 + */ + private void addRows(List rows) { + rows.forEach(this::addRow); + } + + /** + * 累计单个用户日期明细的当日总金额和各餐段金额。 + * + * @param row 当前导出的一条用户日期明细 + */ + private void addRow(HardCanteenUserApptVo row) { + totalPrice = Math.addExact(totalPrice, row.getTotalPrice() == null ? 0L : row.getTotalPrice()); + if (row.getOrders() == null || row.getOrders().isEmpty()) { + return; + } + row.getOrders().stream().filter(Objects::nonNull) + .filter(order -> StrUtil.isNotBlank(order.getHourType())) + .forEach(order -> mealSegmentTotalPrices.merge(order.getHourType(), + order.getTotalPrice() == null ? 0L : order.getTotalPrice().longValue(), + Math::addExact)); + } + } + + /** + * 提交菜品预约异步导出任务。 + * + * @param query 菜品预约统计筛选条件 + * @return 初始异步任务进度 + */ + @Override + public AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query) { + HardOrderDto querySnapshot = copyProductQuery(query); + return asyncTaskExecutorService.submitWithResult(PRODUCT_EXPORT_REDIS_KEY, PRODUCT_EXPORT_TASK_NAME, + INITIAL_PROGRESS_TOTAL, reporter -> exportProducts(querySnapshot, reporter)); + } + + /** + * 同步查询菜品预约自定义 Excel 行。 + * + * @param query 菜品预约统计筛选条件 + * @return 三级层级导出行 + */ + @Override + public List productReservationExport(HardOrderDto query) { + HardOrderDto querySnapshot = copyProductQuery(query); + List rows = new ArrayList<>(); + for (LocalDate date : getQueryDates(querySnapshot)) { + HardOrderDto dateQuery = copyProductQuery(querySnapshot); + dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)}); + rows.addAll(buildProductRows(dateQuery, date)); + } + return rows; + } + + /** + * 后台按日期批次生成菜品预约工作簿并上传文件中心。 + * + * @param query 后台线程独占的查询快照 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心结果 + */ + private AsyncTaskResult exportProducts(HardOrderDto query, AsyncTaskProgressReporter reporter) { + List dates = getQueryDates(query); + reporter.updateTotal(Math.max(1, dates.size() + 1), "已确定 " + dates.size() + " 个用餐日期,开始分批导出"); + Path outputPath = null; + String fileName = "菜品预约统计_" + LocalDateTime.now().format(FILE_TIME_FORMATTER) + ".xlsx"; + try { + outputPath = Files.createTempFile("canteen-product-appt-export-", ".xlsx"); + long rowCount; + try (ExcelWriter writer = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class) + .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) { + WriteSheet sheet = EasyExcel.writerSheet(PRODUCT_EXPORT_SHEET_NAME).build(); + rowCount = 0L; + for (LocalDate date : dates) { + HardOrderDto dateQuery = copyProductQuery(query); + dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)}); + List batchRows = buildProductRows(dateQuery, date); + if (!batchRows.isEmpty()) { + writer.write(batchRows, sheet); + rowCount += batchRows.size(); + } + reporter.advance("已处理 " + date + ",写入 " + rowCount + " 行"); + } + if (rowCount == 0L) { + throw new IllegalStateException("没有符合当前条件的菜品预约数据"); + } + } + String fileUrl = upload(outputPath, fileName); + reporter.advance("菜品预约导出文件已生成"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("菜品预约统计导出失败,fileName:{}", fileName, exception); + throw new IllegalStateException("生成菜品预约导出文件失败", exception); + } finally { + if (outputPath != null) { + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("删除菜品预约导出临时文件失败,path:{}", outputPath, exception); + } + } + } + } + + /** + * 查询一个日期批次的三层数据并转换为 Excel 行。 + * + * @param query 日期批次查询条件 + * @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取 + * @return 当前批次的汇总、商户部门和商品明细行 + */ + private List buildProductRows(HardOrderDto query, LocalDate fixedDate) { + List summaries = hardCarMapper.listApptExportSummary(query); + List merchants = hardCarMapper.listApptExportMerchant(query); + List products = hardCarMapper.listApptExportProduct(query); + if (summaries == null || summaries.isEmpty()) { + return List.of(); + } + Map merchantNames = fetchMerchantNames(merchants); + Map departmentNames = fetchDepartmentNames(merchants); + Map hourNames = fetchHourNames(); + Map menuMap = fetchDailyMenus(products, fixedDate); + Map> merchantGroups = merchants.stream() + .collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList())); + Map> productGroups = products.stream() + .collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList())); + List rows = new ArrayList<>(); + for (HardCanteenProductTotalVo summary : summaries) { + LocalDate date = fixedDate != null ? fixedDate + : summary.getCreateTime() == null ? null : summary.getCreateTime().toLocalDate(); + if (date == null) { + log.warn("菜品预约导出汇总记录缺少预约日期,hourType:{},跳过该记录", summary.getHourType()); + continue; + } + String hourType = summary.getHourType(); + HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo(); + summaryRow.setSummaryDate(date); + summaryRow.setSummaryHourType(resolveHourName(hourType, hourNames)); + summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount())); + summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount())); + summaryRow.setSummaryPayCount(valueOrZero(summary.getPayCount())); + summaryRow.setSummaryTotalPrice(centsToYuan(summary.getTotalPrice())); + rows.add(summaryRow); + String summaryKey = date + "|" + hourType; + List merchantRows = merchantGroups.getOrDefault(summaryKey, List.of()); + for (HardCanteenProductApptExportRecord merchant : merchantRows) { + HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo(); + merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录")); + merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录")); + merchantRow.setMerchantDate(date); + merchantRow.setMerchantHourType(resolveHourName(hourType, hourNames)); + merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount())); + merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount())); + merchantRow.setMerchantPayCount(valueOrZero(merchant.getPayCount())); + merchantRow.setMerchantTotalPrice(centsToYuan(merchant.getTotalPrice())); + rows.add(merchantRow); + String merchantKey = groupKey(merchant); + List productRows = productGroups.getOrDefault(merchantKey, List.of()); + for (HardCanteenProductApptExportRecord product : productRows) { + rows.addAll(toProductRows(product, menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date)))); + } + } + } + return rows; + } + + /** + * 将商品聚合行按预约当天每日菜谱中的套餐组成展开。 + * + *

组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊, + * 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。

+ * + * @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径 + * @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行 + * @return 单品原始行,或按套餐组成顺序生成的商品明细行 + */ + private List toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) { + List items = menu != null && menu.getComboItems() != null + && !menu.getComboItems().isEmpty() ? menu.getComboItems() : List.of(); + if (items.isEmpty()) { + return List.of(toProductRow(product.getProductName(), valueOrZero(product.getPayCount()), + product.getTotalPrice())); + } + long packageCount = valueOrZero(product.getPayCount()); + long totalCents = valueOrZero(product.getTotalPrice()); + long sign = totalCents < 0 ? -1L : 1L; + long absoluteTotalCents = Math.abs(totalCents); + long baseAmount = items.stream().mapToLong(item -> Math.max(0, valueOrZero(item.getPrice())) + * Math.max(1, valueOrZero(item.getQuantity()))).sum(); + long[] itemPrices = new long[items.size()]; + long[] itemWeights = new long[items.size()]; + long allocated = 0L; + List remainderIndexes = new ArrayList<>(); + List result = new ArrayList<>(); + for (int index = 0; index < items.size(); index++) { + OnlineSpuComboItemVO item = items.get(index); + long itemWeight = Math.max(0, valueOrZero(item.getPrice())) * Math.max(1, valueOrZero(item.getQuantity())); + itemWeights[index] = itemWeight; + if (baseAmount > 0) { + BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents) + .multiply(BigDecimal.valueOf(itemWeight)) + .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN); + itemPrices[index] = exactPrice.setScale(0, RoundingMode.FLOOR).longValue(); + allocated += itemPrices[index]; + remainderIndexes.add(index); + } + } + if (baseAmount > 0) { + long remainder = absoluteTotalCents - allocated; + // 每项先向下取整,再按小数余数从大到小补分,保证分摊值不为负且合计不丢分。 + remainderIndexes.sort(Comparator.comparing((Integer index) -> { + BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents) + .multiply(BigDecimal.valueOf(itemWeights[index])) + .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN); + return exactPrice.subtract(BigDecimal.valueOf(itemPrices[index])); + }).reversed()); + for (int index = 0; index < remainderIndexes.size() && remainder > 0; index++, remainder--) { + itemPrices[remainderIndexes.get(index)]++; + } + } else if (!items.isEmpty()) { + itemPrices[items.size() - 1] = absoluteTotalCents; + } + for (int index = 0; index < items.size(); index++) { + OnlineSpuComboItemVO item = items.get(index); + long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity())); + long itemPrice = itemPrices[index] * sign; + result.add(toProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), itemCount, itemPrice)); + } + return result; + } + + /** 创建单个商品明细行。 */ + private HardCanteenProductApptExcelVo toProductRow(String name, long count, long totalPrice) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setProductName(name); + row.setProductCount(count); + row.setProductTotalPrice(centsToYuan(totalPrice)); + return row; + } + + /** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */ + private Map fetchDailyMenus(List products, + LocalDate fixedDate) { + Map result = new HashMap<>(); + LocalDate date = fixedDate; + if (date == null) return result; + List merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + for (Long merchantId : merchantIds) { + for (String hourType : products.stream().filter(item -> Objects.equals(item.getMerchantId(), merchantId)) + .map(HardCanteenProductApptExportRecord::getHourType).filter(StrUtil::isNotBlank).distinct().toList()) { + try { + Integer hour = Integer.valueOf(hourType); + R> response = remoteDailyMenuService.getCanteenList(date.toString(), hour, null, merchantId); + if (response != null && response.isOk() && response.getData() != null) { + response.getData().forEach(menu -> { + if (menu.getSpuId() != null) result.put(merchantId + "|" + date + "|" + hourType + "|" + menu.getSpuId(), menu); + }); + } + } catch (Exception exception) { + log.warn("查询每日菜谱套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}", + merchantId, date, hourType, exception.getMessage()); + } + } + } + return result; + } + + /** 批量查询商户名称。 */ + private Map fetchMerchantNames(List rows) { + List ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) return Map.of(); + try { + R> response = remoteMchService.getByIds(ids); + if (response == null || !response.isOk() || response.getData() == null) return Map.of(); + return response.getData().stream().filter(item -> item.getId() != null) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left)); + } catch (Exception exception) { + log.warn("查询导出商户名称失败,使用空名称", exception); + return Map.of(); + } + } + + /** 批量查询部门名称。 */ + private Map fetchDepartmentNames(List rows) { + List ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) return Map.of(); + try { + R> response = remoteDeptService.getDeptByDeptIds(ids); + if (response == null || !response.isOk() || response.getData() == null) return Map.of(); + return response.getData().values().stream().filter(item -> item.getDeptId() != null) + .collect(Collectors.toMap(SysDept::getDeptId, SysDept::getName, (left, right) -> left)); + } catch (Exception exception) { + log.warn("查询导出部门名称失败,使用空名称", exception); + return Map.of(); + } + } + + /** 查询餐段字典。 */ + private Map fetchHourNames() { + try { + R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); + if (response == null || !response.isOk() || response.getData() == null) return Map.of(); + return response.getData().stream().filter(item -> StrUtil.isNotBlank(item.getItemValue())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left)); + } catch (Exception exception) { + log.warn("查询导出餐段字典失败,使用餐段编码", exception); + return Map.of(); + } + } + + /** 复制菜品统计查询条件,避免异步任务与请求对象共享可变数组和集合。 */ + private HardOrderDto copyProductQuery(HardOrderDto source) { + if (source == null) throw new IllegalArgumentException("菜品预约导出查询条件不能为空"); + HardOrderDto target = new HardOrderDto(); + target.setApptUseDateTimeList(source.getApptUseDateTimeList() == null ? null : source.getApptUseDateTimeList().clone()); + target.setHourType(source.getHourType()); + target.setMerchantId(source.getMerchantId()); + target.setUserDeptIds(copyList(source.getUserDeptIds())); + target.setIsInclude(source.getIsInclude()); + target.setMerchantDeptIds(copyList(source.getMerchantDeptIds())); + target.setMchIds(copyList(source.getMchIds())); + target.setOrderType(HardOrderTypeEnum.RESERVE.getType()); + target.setApptIdOrApptRuleIdNotNull(true); + target.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); + return target; + } + + /** 获取查询日期闭区间。 */ + private List getQueryDates(HardOrderDto query) { + if (query.getApptUseDateTimeList() == null || query.getApptUseDateTimeList().length != 2 + || query.getApptUseDateTimeList()[0] == null || query.getApptUseDateTimeList()[1] == null) { + throw new IllegalArgumentException("预约时间不能为空"); + } + LocalDate start = query.getApptUseDateTimeList()[0].toLocalDate(); + LocalDate end = query.getApptUseDateTimeList()[1].toLocalDate(); + if (end.isBefore(start)) throw new IllegalArgumentException("预约结束时间不能早于开始时间"); + List dates = new ArrayList<>(); + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) dates.add(date); + return dates; + } + + /** 构造商户部门分组键。 */ + private String groupKey(HardCanteenProductApptExportRecord record) { + return record.getApptDate() + "|" + record.getHourType() + "|" + record.getMerchantId() + "|" + record.getUserDeptId(); + } + + /** 构造日期餐段分组键,用于从日期餐段汇总行定位其下属商户部门统计。 */ + private String summaryGroupKey(HardCanteenProductApptExportRecord record) { + return record.getApptDate() + "|" + record.getHourType(); + } + + /** 构造商品每日菜谱缓存键。 */ + private String menuKey(HardCanteenProductApptExportRecord record, LocalDate date) { + return record.getMerchantId() + "|" + date + "|" + record.getHourType() + "|" + record.getProductId(); + } + + /** 将空数值按零处理。 */ + private long valueOrZero(Number value) { + return value == null ? 0L : value.longValue(); + } + + /** 将分转换为元,保持两位小数。 */ + private BigDecimal centsToYuan(Number cents) { + return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2).setScale(2, RoundingMode.HALF_UP); + } + + /** 解析餐段名称,字典缺失时保留原始编码。 */ + private String resolveHourName(String hourType, Map hourNames) { + return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段")); + } + + /** 上传已关闭的 Excel 临时文件并提取下载地址。 */ + private String upload(Path path, String fileName) throws Exception { + MultipartFile file = new MockMultipartFile("file", encodeFileName(fileName), EXCEL_CONTENT_TYPE, + Files.readAllBytes(path)); + R response = remoteFileService.innerUpload(file, EXPORT_FILE_DIR, null, PRODUCT_EXPORT_FILE_TYPE); + if (response == null || !response.isOk() || !(response.getData() instanceof Map data) + || data.get("url") == null || StrUtil.isBlank(data.get("url").toString())) { + throw new IllegalStateException("文件中心未返回菜品预约导出下载地址"); + } + return data.get("url").toString(); + } + + /** 按文件中心约定编码中文文件名。 */ + private String encodeFileName(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 228ebb91..f0c90ae6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -2,36 +2,27 @@ package com.bsd.pjcloud.hard.service.impl.order; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; -import com.alibaba.excel.EasyExcel; -import com.alibaba.excel.ExcelWriter; -import com.alibaba.excel.write.metadata.WriteSheet; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; import com.bsd.pjcloud.admin.api.dto.UserDTO; -import com.bsd.pjcloud.admin.api.entity.SysDictItem; -import com.bsd.pjcloud.admin.api.entity.SysPost; -import com.bsd.pjcloud.admin.api.feign.RemoteDictService; -import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.vo.UserVO; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; -import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; -import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; -import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDailyStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDishStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptMealStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsSummaryVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; @@ -39,20 +30,13 @@ import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import org.springframework.web.multipart.MultipartFile; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -78,49 +62,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 单次订单查询的用户主键数量,避免 SQL IN 条件过长。 */ private static final int ORDER_USER_BATCH_SIZE = 500; - /** 用户预约导出任务的 Redis Key 命名空间。 */ - private static final String USER_RESERVATION_EXPORT_REDIS_KEY = "hard:canteen-user-appt:export"; - - /** 用户预约导出任务在统一进度弹窗中的展示名称。 */ - private static final String USER_RESERVATION_EXPORT_TASK_NAME = "用户预约查询导出"; - - /** 查询实际权限用户数量前创建异步任务使用的占位总数。 */ - private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1; - - /** 用户预约导出每批读取的权限用户数,单批最多展开为该人数乘以查询自然日数。 */ - private static final int USER_RESERVATION_EXPORT_BATCH_SIZE = 200; - - /** 文件上传和异步任务成功收尾占用的额外进度步数。 */ - private static final int USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS = 2; - - /** 用户预约导出的二级表头行数,第一行是业务分组,第二行是具体字段。 */ - private static final int USER_RESERVATION_EXPORT_HEADER_ROW_COUNT = 2; - - /** 用户预约导出明细之后固定追加的合计行数量。 */ - private static final int USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT = 1; - - /** 用户预约导出工作表二级表头之后允许写入的最大明细行数。 */ - private static final int MAX_USER_RESERVATION_EXPORT_ROWS = - BatchExcelExportTemplate.calculateMaxDetailRowCount(USER_RESERVATION_EXPORT_HEADER_ROW_COUNT, - USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT); - - /** 用户预约导出工作表名称。 */ - private static final String USER_RESERVATION_EXPORT_SHEET_NAME = "用户预约查询"; - - /** 文件中心保存用户预约导出文件的业务目录。 */ - private static final String USER_RESERVATION_EXPORT_FILE_DIR = "hard-export"; - - /** 文件中心区分用户预约导出文件的类型编码。 */ - private static final String USER_RESERVATION_EXPORT_FILE_TYPE = "HARD_CANTEEN_USER_APPT_EXPORT"; - - /** 用户预约 Excel 文件上传时使用的标准 MIME 类型。 */ - private static final String EXCEL_CONTENT_TYPE = - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - - /** 异步导出文件名使用的时间格式,避免连续任务产生同名文件。 */ - private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER = - DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */ private static final long MAX_QUERY_MONTHS = 2L; @@ -154,12 +95,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 有效预约订单状态,只统计已支付待核销和已完成核销的订单。 */ private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes(); - /** Excel 中核销时间的统一展示格式。 */ - private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - /** 用餐时间段字典类型,属于 PC、App 与后端共同使用的既有协议。 */ - private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu"; - /** 菜品预约聚合 Mapper。 */ private final HardCarMapper hardCarMapper; @@ -169,18 +104,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 用户远程服务,负责返回当前操作者数据权限范围内的用户分页。 */ private final RemoteUserService remoteUserService; - /** 字典远程服务,用于在 Excel 中完整列出已配置和未预约的餐段。 */ - private final RemoteDictService remoteDictService; - - /** 商户远程服务,用于批量补齐预约所属食堂名称。 */ + /** 商户远程服务,用于补齐预约所属食堂名称。 */ private final RemoteMchService remoteMchService; - /** 通用异步任务执行器,负责恢复租户、安全及 Feign 上下文并持久化导出进度。 */ - private final AsyncTaskExecutorService asyncTaskExecutorService; - - /** 文件中心远程服务,用于保存异步生成的用户预约 Excel。 */ - private final RemoteFileService remoteFileService; - /** * 按预约使用日期和用餐时间段汇总已支付的预约菜品。 * @@ -264,223 +190,10 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 提交用户每日预约异步导出任务。 + * 复制用户预约查询条件,避免分页查询在预筛选过程中修改调用方持有的可变集合。 * - *

请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并 - * 分批写入 Excel,避免长时间占用 HTTP 连接和一次性持有全部导出行。

- * - * @param query 用户、日期范围、食堂、餐段和状态筛选条件 - * @return 已创建任务的初始进度 - */ - @Override - public AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query) { - validateQuery(query); - HardCanteenUserApptQueryDto querySnapshot = copyUserReservationQuery(query); - return asyncTaskExecutorService.submitWithResult(USER_RESERVATION_EXPORT_REDIS_KEY, - USER_RESERVATION_EXPORT_TASK_NAME, USER_RESERVATION_EXPORT_INITIAL_TOTAL, - reporter -> exportUserReservations(querySnapshot, reporter)); - } - - /** - * 查询用户预约 Excel 数据。 - * - * @param query 用户、日期范围和预约状态筛选条件 - * @return 用户和日期维度的导出行,最后一行为餐段及当日金额合计 - */ - @Override - public List userReservationExport(HardCanteenUserApptQueryDto query) { - validateQuery(query); - HardCanteenUserApptQueryDto exportQuery = copyUserReservationQuery(query); - if (!narrowQueryToReservationCandidates(exportQuery)) { - return List.of(); - } - Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); - List reservationRows = - listAllFilteredExportRows(exportQuery, getQueryDates(exportQuery)); - if (reservationRows.isEmpty()) { - return List.of(); - } - UserReservationExportSummary summary = - new UserReservationExportSummary(exportQuery, mealSegmentLabelMap); - summary.addRows(reservationRows); - List excelRows = new ArrayList<>(reservationRows.size() + 1); - reservationRows.stream().map(row -> toExcelRow(row, exportQuery, mealSegmentLabelMap)) - .forEach(excelRows::add); - excelRows.add(toExcelSummaryRow(summary, mealSegmentLabelMap)); - return excelRows; - } - - /** - * 在异步线程中分批查询用户预约、生成 Excel 并上传文件中心。 - * - * @param query 后台线程独占的用户预约筛选条件 - * @param reporter 当前异步任务进度报告器 - * @return 文件中心下载地址和业务文件名 - */ - private AsyncTaskResult exportUserReservations(HardCanteenUserApptQueryDto query, - AsyncTaskProgressReporter reporter) { - if (!narrowQueryToReservationCandidates(query)) { - throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); - } - Page firstUserPage = fetchExportUserPage(1L, USER_RESERVATION_EXPORT_BATCH_SIZE, query); - long userTotal = firstUserPage.getTotal(); - if (userTotal <= 0L) { - throw new IllegalStateException("没有符合当前条件的用户预约数据"); - } - validateUserReservationExportRowCapacity(userTotal, query); - int progressTotal = calculateUserReservationExportProgressTotal(userTotal); - reporter.updateTotal(progressTotal, "已确定 " + userTotal + " 位符合条件用户,将分批生成预约文件"); - - String fileName = "用户预约查询_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; - Path outputPath = null; - try { - outputPath = Files.createTempFile("canteen-user-appt-export-", ".xlsx"); - long exportedRowTotal = writeUserReservationWorkbook(outputPath, query, firstUserPage, userTotal, - reporter); - String fileUrl = uploadUserReservationWorkbook(outputPath, fileName); - reporter.advance("用户预约导出文件已生成,共 " + exportedRowTotal + " 条"); - return AsyncTaskResult.file(fileUrl, fileName); - } catch (IllegalStateException exception) { - throw exception; - } catch (Exception exception) { - log.error("|用户预约查询导出|生成或保存文件失败,fileName:{}", fileName, exception); - throw new IllegalStateException("生成用户预约导出文件失败", exception); - } finally { - deleteTemporaryExportFile(outputPath); - } - } - - /** - * 按权限用户分页读取并把当前批次结果写入 EasyExcel 流式工作簿。 - * - *

订餐和核销状态依赖订单关联结果,因此在每批用户展开日期后执行;批次写入完成即可释放, - * 不会把整个日期范围的全部用户行同时留在 JVM 堆中。

- * - * @param outputPath 最终上传前使用的临时 XLSX 文件 - * @param query 已冻结的筛选条件 - * @param firstUserPage 已用于取得用户总数的第一页,避免重复远程调用 - * @param userTotal 任务开始时取得的权限用户总数 - * @param reporter 当前异步任务进度报告器 - * @return 实际写入 Excel 的用户日期行数 - */ - private long writeUserReservationWorkbook(Path outputPath, HardCanteenUserApptQueryDto query, - Page firstUserPage, long userTotal, - AsyncTaskProgressReporter reporter) { - List queryDates = getQueryDates(query); - Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); - long processedUserTotal = 0L; - long exportedRowTotal = 0L; - long current = 1L; - UserReservationExportSummary summary = new UserReservationExportSummary(query, mealSegmentLabelMap); - - try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenUserApptExcelVo.class).build()) { - WriteSheet writeSheet = EasyExcel.writerSheet(USER_RESERVATION_EXPORT_SHEET_NAME).build(); - Page userPage = firstUserPage; - while (processedUserTotal < userTotal) { - List pageRecords = userPage.getRecords() == null ? List.of() : userPage.getRecords(); - if (pageRecords.isEmpty()) { - break; - } - int remainingUserCount = Math.toIntExact(Math.min(pageRecords.size(), userTotal - processedUserTotal)); - List batchUsers = pageRecords.subList(0, remainingUserCount); - List batchReservationRows = - filterRows(buildRows(batchUsers, query, queryDates), query); - long nextExportedRowTotal = exportedRowTotal + batchReservationRows.size(); - if (nextExportedRowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { - throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " - + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); - } - List batchRows = batchReservationRows.stream() - .map(row -> toExcelRow(row, query, mealSegmentLabelMap)).toList(); - if (!batchRows.isEmpty()) { - excelWriter.write(batchRows, writeSheet); - summary.addRows(batchReservationRows); - } - exportedRowTotal = nextExportedRowTotal; - processedUserTotal += batchUsers.size(); - reporter.advance("已处理 " + processedUserTotal + " / " + userTotal + " 位用户,已写入 " - + exportedRowTotal + " 条预约记录"); - if (processedUserTotal >= userTotal) { - break; - } - userPage = fetchExportUserPage(++current, USER_RESERVATION_EXPORT_BATCH_SIZE, query); - } - if (processedUserTotal != userTotal) { - throw new IllegalStateException("导出用户数据在执行期间发生变化,预期 " + userTotal - + " 位,实际处理 " + processedUserTotal + " 位"); - } - if (exportedRowTotal == 0L) { - throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); - } - excelWriter.write(List.of(toExcelSummaryRow(summary, mealSegmentLabelMap)), writeSheet); - } - return exportedRowTotal; - } - - /** - * 根据权限用户总数计算“用户查询批次数 + 文件上传和任务收尾”的进度总步数。 - * - * @param userTotal 当前筛选条件下的权限用户总数 - * @return 统一进度组件使用的总步数 - */ - private int calculateUserReservationExportProgressTotal(long userTotal) { - return BatchExcelExportTemplate.calculateProgressTotal(userTotal, USER_RESERVATION_EXPORT_BATCH_SIZE, - USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS); - } - - /** - * 在没有订餐或核销状态筛选时,提前校验确定会生成的“用户 × 日期”明细行数。 - * - *

存在关联状态筛选时最终行数只能在分批关联订单后确定,写入过程仍会执行单工作表上限保护。

- * - * @param userTotal 当前用户筛选和数据权限范围内的用户总数 - * @param query 已冻结的日期和预约状态筛选条件 - */ - private void validateUserReservationExportRowCapacity(long userTotal, HardCanteenUserApptQueryDto query) { - if (hasJoinedStatusFilter(query)) { - return; - } - long rowTotal; - try { - rowTotal = Math.multiplyExact(userTotal, (long) getQueryDates(query).size()); - } catch (ArithmeticException exception) { - throw new IllegalStateException("用户预约导出数据量过大,请缩小查询范围后分批导出", exception); - } - if (rowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { - throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " - + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); - } - } - - /** - * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 - * - * @param outputPath 已完成写入的临时文件 - * @param fileName 用户下载时使用的业务文件名 - * @return 文件中心提供的下载地址 - * @throws Exception 临时文件读取或远程上传失败时抛出 - */ - private String uploadUserReservationWorkbook(Path outputPath, String fileName) throws Exception { - MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), - EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath)); - R uploadResult = remoteFileService.innerUpload(exportFile, USER_RESERVATION_EXPORT_FILE_DIR, null, - USER_RESERVATION_EXPORT_FILE_TYPE); - if (uploadResult == null || !uploadResult.isOk() - || !(uploadResult.getData() instanceof Map resultData)) { - throw new IllegalStateException("文件中心未返回有效的用户预约导出结果"); - } - Object resultUrl = resultData.get("url"); - if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { - throw new IllegalStateException("文件中心未返回用户预约导出下载地址"); - } - return resultUrl.toString(); - } - - /** - * 复制用户预约查询条件,避免异步任务与控制器请求对象共享可变数组或列表。 - * - * @param source 已通过基础校验的请求筛选条件 - * @return 后台线程独占的完整筛选条件副本 + * @param source 原始用户预约筛选条件 + * @return 与原条件值等价且集合独立的查询副本 */ private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) { HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto(); @@ -498,40 +211,53 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。 + * 复制可选用户筛选列表,保留空值语义并避免远程请求对象之间共享可变列表。 * - * @param source 请求对象中的可变列表 + * @param source 原始列表,可为空 * @param 列表元素类型 - * @return 空值保持为空,非空列表返回独立副本 + * @return 独立列表副本或空值 */ private List copyList(List source) { return source == null ? null : new ArrayList<>(source); } /** - * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。 + * 按当前用户预约查询条件生成每日和查询范围总计。 * - * @param fileName 用户看到的 UTF-8 文件名 - * @return 文件中心上传接口使用的原始文件名 + *

统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数, + * 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。

+ * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 按日期升序排列并包含范围总计的统计结果 */ - private String encodeUploadOriginalFilename(String fileName) { - return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); - } + @Override + public HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query) { + validateQuery(query); + List queryDates = getQueryDates(query); + List users = fetchAllUsers(query); + List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + List orders = fetchReservationOrders(userIds, query); + List rows = filterRows(buildRows(users, query, queryDates, orders), query); + Map orderMap = orders.stream().filter(order -> order.getId() != null) + .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); + Map> carMap = fetchReservationCars(orders); - /** - * 清理异步导出创建的最终临时文件;清理失败只记录告警,不覆盖原始任务结果。 - * - * @param outputPath 待删除的临时文件路径,允许为空 - */ - private void deleteTemporaryExportFile(Path outputPath) { - if (outputPath == null) { - return; - } - try { - Files.deleteIfExists(outputPath); - } catch (Exception exception) { - log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception); + Map dailyBuckets = new LinkedHashMap<>(); + queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket())); + StatisticsBucket totalBucket = new StatisticsBucket(); + for (HardCanteenUserApptVo row : rows) { + StatisticsBucket dailyBucket = dailyBuckets.get(row.getApptUseDate()); + if (dailyBucket == null) { + continue; + } + dailyBucket.addRow(row, orderMap, carMap); + totalBucket.addRow(row, orderMap, carMap); } + + HardCanteenUserApptStatisticsVo result = new HardCanteenUserApptStatisticsVo(); + result.setTotal(totalBucket.toSummary()); + result.setDaily(queryDates.stream().map(date -> dailyBuckets.get(date).toDaily(date)).toList()); + return result; } /** @@ -642,20 +368,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return filterRows(buildRows(users, query, queryDates), query); } - /** - * 获取完整用户导出资料并在关联预约订单后执行状态筛选。 - * - *

与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。

- * - * @param query 查询条件 - * @param queryDates 查询范围内按升序排列的全部日期 - * @return 携带完整用户主资料和身份资料的预约导出行 - */ - private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query, - List queryDates) { - List users = fetchAllExportUsers(query); - return filterRows(buildRows(users, query, queryDates), query); - } /** * 在没有关联状态筛选时,按“用户在前、日期在后”的稳定顺序查询当前结果页。 @@ -797,23 +509,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return response.getData(); } - /** - * 调用 UPMS 导出分页查询完整用户主资料、角色岗位和身份扩展。 - * - * @param current 当前页码 - * @param size 每页数量 - * @param query 用户筛选条件 - * @return 含准确总数和完整导出资料的用户分页 - */ - private Page fetchExportUserPage(long current, long size, HardCanteenUserApptQueryDto query) { - R> response = remoteUserService.getScopeExportUserPage(current, size, - buildUserQuery(query)); - if (response == null || !response.isOk() || response.getData() == null) { - String message = response == null ? null : response.getMsg(); - throw new IllegalStateException(StrUtil.isBlank(message) ? "查询用户导出资料失败" : message); - } - return response.getData(); - } /** * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。 @@ -853,30 +548,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return users; } - /** - * 分页遍历完整用户导出资料,供兼容同步导出使用。 - * - *

管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。

- * - * @param query 用户筛选条件 - * @return 权限范围内携带角色岗位和身份资料的全部用户 - */ - private List fetchAllExportUsers(HardCanteenUserApptQueryDto query) { - List users = new ArrayList<>(); - long current = 1L; - long total; - do { - Page page = fetchExportUserPage(current, USER_BATCH_SIZE, query); - List records = page.getRecords() == null ? List.of() : page.getRecords(); - users.addAll(records); - total = page.getTotal(); - if (records.isEmpty()) { - break; - } - current++; - } while (users.size() < total); - return users; - } /** * 将用户列表与日期范围内的预约订单按用户、日期左关联,每个用户每天至少生成一行。 @@ -893,6 +564,23 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); List orders = fetchReservationOrders(userIds, query); + return buildRows(users, query, queryDates, orders); + } + + /** + * 使用已加载的预约订单构造用户-日期行,供列表查询和统计查询共用同一行生成逻辑。 + * + * @param users 权限范围内的用户 + * @param query 日期、食堂和餐段筛选条件 + * @param queryDates 查询范围内的自然日 + * @param orders 已按相同筛选条件加载的有效预约订单 + * @return 按用户顺序和日期顺序排列的预约行 + */ + private List buildRows(List users, HardCanteenUserApptQueryDto query, + List queryDates, List orders) { + if (users == null || users.isEmpty()) { + return List.of(); + } Map merchantNameMap = fetchMerchantNameMap(orders); Map>> orderMap = orders.stream() .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) @@ -955,6 +643,27 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return orders; } + /** + * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * + * @param orders 当前筛选口径下的预约订单 + * @return 父购物车主键到商品快照的映射 + */ + private Map> fetchReservationCars(List orders) { + List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + if (parentCarIds.isEmpty()) { + return Map.of(); + } + Map> result = new LinkedHashMap<>(); + for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size()); + hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end)) + .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); + } + return result; + } + /** * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 * @@ -1029,6 +738,226 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return detail; } + /** + * 统计查询的内存聚合器,按用户日期、餐段和菜品三个层级累计指标。 + */ + private static final class StatisticsBucket { + + /** 用户-日期行总数。 */ + private long userCount; + + /** 已订餐用户-日期行数。 */ + private long reservedUserCount; + + /** 未订餐用户-日期行数。 */ + private long unreservedUserCount; + + /** 有效预约订单数。 */ + private long orderCount; + + /** 菜品总份数。 */ + private long dishCount; + + /** 预约总价,单位为分。 */ + private long totalPrice; + + /** 去重用户主键集合。 */ + private final Set uniqueUserIds = new HashSet<>(); + + /** 餐段编码到餐段聚合结果的有序映射。 */ + private final Map meals = new LinkedHashMap<>(); + + /** 餐段和菜品名称到菜品聚合结果的有序映射。 */ + private final Map dishes = new LinkedHashMap<>(); + + /** + * 累加一条用户-日期预约行及其订单商品快照。 + * + * @param row 用户-日期预约行 + * @param orderMap 订单主键到订单对象的映射 + * @param carMap 父购物车主键到商品快照的映射 + */ + private void addRow(HardCanteenUserApptVo row, Map orderMap, + Map> carMap) { + userCount++; + if (row.getUserId() != null) { + uniqueUserIds.add(row.getUserId()); + } + List rowOrders = row.getOrders() == null ? List.of() : row.getOrders(); + if (rowOrders.isEmpty()) { + unreservedUserCount++; + return; + } + reservedUserCount++; + Set rowOrderIds = new HashSet<>(); + for (HardCanteenUserApptOrderVo rowOrder : rowOrders) { + Long orderId = rowOrder.getId(); + HardOrderVo order = orderId == null ? null : orderMap.get(orderId); + if (orderId != null && rowOrderIds.add(orderId)) { + orderCount++; + totalPrice += number(rowOrder.getTotalPrice()); + } + String hourType = normalize(rowOrder.getHourType(), "未标记餐段"); + MealStatisticsAccumulator meal = meals.computeIfAbsent(hourType, + key -> new MealStatisticsAccumulator(key)); + if (orderId != null && meal.orderIds.add(orderId)) { + meal.reservedUserDateKeys.add(row.getUserId() + "_" + row.getApptUseDate()); + meal.orderCount++; + meal.totalPrice += number(rowOrder.getTotalPrice()); + } + List cars = order == null ? List.of() : carMap.getOrDefault(order.getParentCarId(), List.of()); + for (HardCarEntity car : cars) { + long count = number(car.getProductCount()); + dishCount += count; + meal.dishCount += count; + String productName = normalize(car.getProductName(), "未命名菜品"); + String dishKey = hourType + "\u0000" + productName; + DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, + key -> new DishStatisticsAccumulator(hourType, productName)); + dish.productCount += count; + dish.totalPrice += number(car.getTotalPrice()); + if (orderId != null) { + dish.orderIds.add(orderId); + } + } + } + } + + /** + * 转换为总计汇总响应。 + * + * @return 总计统计数据 + */ + private HardCanteenUserApptStatisticsSummaryVo toSummary() { + HardCanteenUserApptStatisticsSummaryVo summary = new HardCanteenUserApptStatisticsSummaryVo(); + summary.setUserCount(userCount); + summary.setUniqueUserCount((long) uniqueUserIds.size()); + summary.setReservedUserCount(reservedUserCount); + summary.setUnreservedUserCount(unreservedUserCount); + summary.setOrderCount(orderCount); + summary.setDishCount(dishCount); + summary.setTotalPrice(totalPrice); + summary.setMeals(meals.values().stream().map(MealStatisticsAccumulator::toVo).toList()); + summary.setDishes(dishes.values().stream().map(DishStatisticsAccumulator::toVo).toList()); + return summary; + } + + /** + * 转换为每日统计响应。 + * + * @param date 用餐日期 + * @return 每日统计数据 + */ + private HardCanteenUserApptDailyStatisticsVo toDaily(LocalDate date) { + HardCanteenUserApptDailyStatisticsVo daily = new HardCanteenUserApptDailyStatisticsVo(); + daily.setDate(date); + HardCanteenUserApptStatisticsSummaryVo summary = toSummary(); + daily.setUserCount(summary.getUserCount()); + daily.setUniqueUserCount(summary.getUniqueUserCount()); + daily.setReservedUserCount(summary.getReservedUserCount()); + daily.setUnreservedUserCount(summary.getUnreservedUserCount()); + daily.setOrderCount(summary.getOrderCount()); + daily.setDishCount(summary.getDishCount()); + daily.setTotalPrice(summary.getTotalPrice()); + daily.setMeals(summary.getMeals()); + daily.setDishes(summary.getDishes()); + return daily; + } + + /** 将可能为空的数值安全归一为零。 */ + private static long number(Number value) { + return value == null ? 0L : value.longValue(); + } + + /** 将可能为空的名称安全归一为统计展示文案。 */ + private static String normalize(String value, String fallback) { + return StrUtil.isBlank(value) ? fallback : value; + } + } + + /** 餐段统计的可变聚合状态。 */ + private static final class MealStatisticsAccumulator { + + /** 餐段编码或兼容展示文案。 */ + private final String hourType; + + /** 餐段内去重的用户-用餐日期键,范围总计按订餐人次累计并可与每日结果相加。 */ + private final Set reservedUserDateKeys = new HashSet<>(); + + /** 餐段内去重的订单主键。 */ + private final Set orderIds = new HashSet<>(); + + /** 餐段菜品份数。 */ + private long dishCount; + + /** 餐段预约订单数。 */ + private long orderCount; + + /** 餐段预约总价,单位为分。 */ + private long totalPrice; + + /** + * 创建指定餐段的聚合状态。 + * + * @param hourType 餐段协议编码或历史兼容文案 + */ + private MealStatisticsAccumulator(String hourType) { + this.hourType = hourType; + } + + /** 转换为餐段统计响应。 */ + private HardCanteenUserApptMealStatisticsVo toVo() { + HardCanteenUserApptMealStatisticsVo vo = new HardCanteenUserApptMealStatisticsVo(); + vo.setHourType(hourType); + vo.setReservedUserCount((long) reservedUserDateKeys.size()); + vo.setOrderCount(orderCount); + vo.setDishCount(dishCount); + vo.setTotalPrice(totalPrice); + return vo; + } + } + + /** 菜品统计的可变聚合状态。 */ + private static final class DishStatisticsAccumulator { + + /** 餐段编码或兼容展示文案。 */ + private final String hourType; + + /** 下单时固化的菜品名称。 */ + private final String productName; + + /** 菜品快照份数。 */ + private long productCount; + + /** 菜品快照总价,单位为分。 */ + private long totalPrice; + + /** 包含该菜品的去重订单主键。 */ + private final Set orderIds = new HashSet<>(); + + /** + * 创建指定餐段和菜品名称的聚合状态。 + * + * @param hourType 餐段协议编码或历史兼容文案 + * @param productName 下单时保存的菜品名称 + */ + private DishStatisticsAccumulator(String hourType, String productName) { + this.hourType = hourType; + this.productName = productName; + } + + /** 转换为菜品统计响应。 */ + private HardCanteenUserApptDishStatisticsVo toVo() { + HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo(); + vo.setHourType(hourType); + vo.setProductName(productName); + vo.setProductCount(productCount); + vo.setOrderCount((long) orderIds.size()); + vo.setTotalPrice(totalPrice); + return vo; + } + } + /** * 汇总预约明细的商品总价,历史空金额按零分计算。 * @@ -1110,336 +1039,19 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return result; } - /** - * 将用户预约汇总转换为单行 Excel 数据,保持“一个用户 + 一个日期”导出口径。 - * - * @param row 用户预约汇总 - * @param query 原始查询条件,用于无订单时保留所选餐段 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return Excel 导出行 - */ - private HardCanteenUserApptExcelVo toExcelRow(HardCanteenUserApptVo row, - HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - List orders = row.getOrders(); - List verifiedOrders = orders.stream().filter(order -> - Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).toList(); - HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); - excel.setUserId(row.getUserId()); - excel.setUserName(row.getUserName()); - excel.setEmail(row.getEmail()); - excel.setPhone(row.getPhone()); - excel.setDeptName(row.getDeptName()); - excel.setPostNames(joinPostNames(row.getPostList())); - excel.setIdentityTypeNames(joinIdentityNames(row.getIdentityTypes())); - if (row.getStudentInfo() != null) { - excel.setStudentNo(row.getStudentInfo().getStudentNo()); - excel.setSchoolName(row.getStudentInfo().getSchoolName()); - excel.setClassName(row.getStudentInfo().getClassName()); - excel.setGuardianPhones(row.getStudentInfo().getGuardianPhones()); - } - if (row.getTeacherInfo() != null) { - excel.setTeacherNo(row.getTeacherInfo().getTeacherNo()); - } - excel.setApptUseDate(row.getApptUseDate()); - excel.setReservationStatus(Objects.equals(row.getReservationStatus(), RESERVATION_STATUS_RESERVED) - ? "已订餐" : "未订餐"); - excel.setHourTypes(orders.isEmpty() - ? (StrUtil.isBlank(query.getHourType()) ? "全部餐段" - : resolveMealSegmentLabel(query.getHourType(), mealSegmentLabelMap)) - : joinDistinct(orders, order -> resolveMealSegmentLabel(order.getHourType(), - mealSegmentLabelMap))); - excel.setBreakfastReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.MORNING)); - excel.setLunchReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NOONING)); - excel.setDinnerReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NIGHT)); - excel.setLateNightReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.LATE_NIGHT)); - excel.setMealSegmentTotalPrices(buildMealSegmentTotalPrices(orders, query, mealSegmentLabelMap)); - excel.setTotalPrice(centsToYuan(row.getTotalPrice())); - excel.setProductDescriptions(joinDistinct(orders, order -> - StrUtil.isBlank(order.getProductDesc()) ? "订餐内容未记录" : order.getProductDesc())); - excel.setMerchantNames(joinDistinct(orders, HardCanteenUserApptOrderVo::getMerchantName)); - excel.setVerificationStatus(verificationStatusText(row.getVerificationStatus())); - excel.setVerificationTimes(joinDistinct(verifiedOrders, order -> order.getUpdateTime() == null - ? "核销时间未记录" : EXCEL_DATE_TIME_FORMATTER.format(order.getUpdateTime()))); - return excel; - } - /** - * 生成用户预约导出文件末尾的金额合计行。 - * - *

合计仍使用与明细相同的导出模型,姓名列标记“合计”,餐段金额和当日金额分别写入对应统计列, - * 其余人员及预约字段保持为空。

- * - * @param summary 已累计全部导出分片的金额汇总 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 可直接追加到 EasyExcel 工作表末尾的合计行 - */ - private HardCanteenUserApptExcelVo toExcelSummaryRow(UserReservationExportSummary summary, - Map mealSegmentLabelMap) { - HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); - excel.setUserName("合计"); - excel.setMealSegmentTotalPrices(buildExportSummaryMealSegmentTotalPrices(summary, - mealSegmentLabelMap)); - excel.setTotalPrice(centsToYuan(summary.totalPrice)); - return excel; - } - /** - * 查询用餐时间段字典并保留字典排序;远程调用失败时由实际订单编码继续完成导出。 - * - * @return 餐段编码到展示标签的有序映射 - */ - private Map fetchMealSegmentLabelMap() { - try { - R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); - if (response == null || !response.isOk() || response.getData() == null) { - log.warn("查询用餐时间段字典失败,导出将使用订单餐段编码"); - return Map.of(); - } - return response.getData().stream().filter(item -> - StrUtil.isNotBlank(item.getItemValue()) && StrUtil.isNotBlank(item.getLabel())) - .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, - (left, right) -> left, LinkedHashMap::new)); - } catch (Exception exception) { - log.warn("查询用餐时间段字典异常,导出将使用订单餐段编码,原因:{}", exception.getMessage()); - return Map.of(); - } - } - /** - * 将餐段协议编码转换为导出文案,并兼容历史空编码。 - * - * @param hourType 订单或查询条件中的餐段协议编码 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 字典标签;未知编码保留原值,空编码返回“未标记餐段” - */ - private String resolveMealSegmentLabel(String hourType, Map mealSegmentLabelMap) { - if (StrUtil.isBlank(hourType)) { - return "未标记餐段"; - } - return mealSegmentLabelMap.getOrDefault(hourType, hourType); - } - /** - * 生成一个固定餐段的预约状态。 - * - * @param orders 用户当天有效预约明细 - * @param query 原始查询条件,用于区分未订餐和未参与查询的餐段 - * @param hourType 固定餐段协议枚举 - * @return “已订N单”“未订餐”或“未查询” - */ - private String buildMealSegmentReservation(List orders, - HardCanteenUserApptQueryDto query, - HardHourTypeEnum hourType) { - if (StrUtil.isNotBlank(query.getHourType()) && !Objects.equals(query.getHourType(), hourType.getType())) { - return "未查询"; - } - long orderCount = orders.stream().filter(order -> - Objects.equals(hourType.getType(), order.getHourType())).count(); - return orderCount == 0L ? "未订餐" : "已订" + orderCount + "单"; - } - /** - * 生成各餐段商品总价摘要,未订餐段明确输出零元。 - * - * @param orders 用户当天有效预约明细 - * @param query 原始查询条件,选择餐段时只生成该餐段金额 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 例如“早餐:0.00元;午餐:18.50元”的摘要 - */ - private String buildMealSegmentTotalPrices(List orders, - HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - Map segments = resolveMealSegments(orders, query, mealSegmentLabelMap); - if (segments.isEmpty()) { - return "全部餐段:0.00元"; - } - return segments.entrySet().stream().map(entry -> { - long totalPrice = orders.stream().filter(order -> - Objects.equals(entry.getKey(), order.getHourType())) - .map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) - .mapToLong(Integer::longValue).sum(); - return entry.getValue() + ":" + formatYuan(totalPrice); - }).collect(Collectors.joining(";")); - } - /** - * 格式化全部导出明细累计后的各餐段金额。 - * - * @param summary 跨分片累计的金额汇总 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 例如“早餐:60.80元;午餐:119.00元”的餐段合计 - */ - private String buildExportSummaryMealSegmentTotalPrices(UserReservationExportSummary summary, - Map mealSegmentLabelMap) { - if (summary.mealSegmentTotalPrices.isEmpty()) { - return "全部餐段:0.00元"; - } - return summary.mealSegmentTotalPrices.entrySet().stream() - .map(entry -> resolveMealSegmentLabel(entry.getKey(), mealSegmentLabelMap) - + ":" + formatYuan(entry.getValue())) - .collect(Collectors.joining(";")); - } - /** - * 合并当前字典餐段和订单中的历史餐段编码,供预约数量及金额摘要复用。 - * - * @param orders 用户当天有效预约明细 - * @param query 原始查询条件 - * @param mealSegmentLabelMap 餐段编码到字典标签的映射 - * @return 保持字典顺序并补齐历史编码的餐段映射 - */ - private Map resolveMealSegments(List orders, - HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - Map segments = new LinkedHashMap<>(); - if (StrUtil.isNotBlank(query.getHourType())) { - segments.put(query.getHourType(), - mealSegmentLabelMap.getOrDefault(query.getHourType(), query.getHourType())); - } else { - segments.putAll(mealSegmentLabelMap); - orders.stream().map(HardCanteenUserApptOrderVo::getHourType).filter(StrUtil::isNotBlank) - .forEach(hourType -> segments.putIfAbsent(hourType, hourType)); - } - return segments; - } - /** - * 提取、去空、去重并连接预约明细字段。 - * - * @param orders 预约明细列表 - * @param mapper 字段提取函数 - * @return 使用中文分号连接的字段值;没有有效值时返回空字符串 - */ - private String joinDistinct(List orders, - Function mapper) { - return orders.stream().map(mapper).filter(StrUtil::isNotBlank).distinct() - .collect(Collectors.joining(";")); - } - /** - * 将整数分转换为精确的元金额。 - * - * @param price 商品总价,单位为分;为空时按零计算 - * @return 以元为单位且固定两位小数精度的金额 - */ - private BigDecimal centsToYuan(Long price) { - return BigDecimal.valueOf(price == null ? 0L : price, 2); - } - /** - * 将整数分格式化为带元单位的两位小数文案。 - * - * @param price 商品总价,单位为分 - * @return 例如“18.50元”的金额文案 - */ - private String formatYuan(long price) { - return centsToYuan(price).toPlainString() + "元"; - } - /** - * 拼接用户岗位名称,空岗位列表输出空字符串。 - * - * @param posts 完整用户导出分页返回的岗位列表 - * @return 以中文逗号分隔的岗位名称 - */ - private String joinPostNames(List posts) { - if (posts == null || posts.isEmpty()) { - return ""; - } - return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) - .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); - } - /** - * 按用户身份协议顺序拼接身份中文名称。 - * - * @param identityTypes UPMS 返回的用户身份协议编码 - * @return 以中文逗号分隔的身份名称;未知历史编码保留原值 - */ - private String joinIdentityNames(List identityTypes) { - if (identityTypes == null || identityTypes.isEmpty()) { - return ""; - } - return identityTypes.stream().map(identityType -> { - UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); - return identity == null ? identityType : identity.getDescription(); - }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); - } - /** - * 将核销状态协议值转换为导出文案。 - * - * @param status 核销状态协议值 - * @return 中文核销状态 - */ - private String verificationStatusText(String status) { - if (Objects.equals(status, VERIFICATION_STATUS_VERIFIED)) { - return "已核销"; - } - if (Objects.equals(status, VERIFICATION_STATUS_PARTIAL)) { - return "部分核销"; - } - if (Objects.equals(status, VERIFICATION_STATUS_UNVERIFIED)) { - return "待核销"; - } - return "无需核销"; - } - /** - * 用户预约导出跨分片金额汇总器。 - * - *

所有金额均以分为单位累计,生成最终合计行时才统一转换为元,避免分片导出过程中出现精度损失。

- */ - private static final class UserReservationExportSummary { - - /** 各餐段累计金额,键为订单餐段协议编码,值为商品总价,单位为分。 */ - private final Map mealSegmentTotalPrices = new LinkedHashMap<>(); - - /** 全部导出明细的当日金额累计值,单位为分。 */ - private long totalPrice; - - /** - * 创建跨分片汇总器,并按查询餐段或餐段字典顺序预置零金额。 - * - * @param query 本次导出的餐段筛选条件 - * @param mealSegmentLabelMap 餐段编码到字典标签的有序映射 - */ - private UserReservationExportSummary(HardCanteenUserApptQueryDto query, - Map mealSegmentLabelMap) { - if (StrUtil.isNotBlank(query.getHourType())) { - mealSegmentTotalPrices.put(query.getHourType(), 0L); - } else { - mealSegmentLabelMap.keySet().forEach(hourType -> - mealSegmentTotalPrices.put(hourType, 0L)); - } - } - - /** - * 累计一批已经通过全部查询条件筛选的用户日期明细。 - * - * @param rows 当前分片即将写入 Excel 的用户日期明细 - */ - private void addRows(List rows) { - rows.forEach(this::addRow); - } - - /** - * 累计单个用户日期明细的当日总金额和各餐段金额。 - * - * @param row 当前导出的一条用户日期明细 - */ - private void addRow(HardCanteenUserApptVo row) { - totalPrice = Math.addExact(totalPrice, row.getTotalPrice() == null ? 0L : row.getTotalPrice()); - if (row.getOrders() == null || row.getOrders().isEmpty()) { - return; - } - row.getOrders().stream().filter(Objects::nonNull) - .filter(order -> StrUtil.isNotBlank(order.getHourType())) - .forEach(order -> mealSegmentTotalPrices.merge(order.getHourType(), - order.getTotalPrice() == null ? 0L : order.getTotalPrice().longValue(), - Math::addExact)); - } - } } 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 e8b94040..0c0a55fb 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 @@ -172,7 +172,7 @@ public class HardOrderServiceImpl extends ServiceImpl contexts = loadDailyMenuReserveGroupContexts(payerUserId, groups); Long merchantId = contexts.get(0).parentCar().getMerchantId(); MchManageVo merchant = validateDailyMenuReserveMerchant(merchantId); - MchAutoRefundConfigVo autoRefundConfig = loadDailyMenuAutoRefundConfig(merchantId); validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant); - validateDailyMenuCartProducts(merchantId, contexts); + Map inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size()); validateDailyMenuSingleProductLimit(merchant, contexts); List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)) @@ -1462,6 +1464,9 @@ public class HardOrderServiceImpl extends ServiceImpl validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, context.reservation())); List createdOrders = new ArrayList<>(); + Map reservationByOrderId = new LinkedHashMap<>(); + String inventoryBizId = null; + boolean inventoryOperationAttempted = false; try { for (DailyMenuReserveGroupContext context : contexts) { String subject = buildDailyMenuReserveOrderSubject(context.carList()); @@ -1472,20 +1477,27 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) { + private void registerDailyMenuRollbackCompensation(List createdOrders, + List contexts, + int dinerCount, + Map inventorySnapshot, + String inventoryBizId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { throw new RuntimeException("订餐事务状态异常,请稍后重试"); } List rollbackOrders = List.copyOf(createdOrders); + List rollbackContexts = List.copyOf(contexts); + Map rollbackInventory = Map.copyOf(inventorySnapshot); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { /** - * 本地事务明确回滚时补偿远程钱包;状态未知时保留流水,避免数据库实际已提交却误退款。 + * 本地事务明确回滚时补偿远程库存和钱包;状态未知时不主动反向操作,避免数据库实际已提交却误恢复。 * * @param status Spring 事务完成状态 */ @Override public void afterCompletion(int status) { if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { - compensateReserveOrderWallets(rollbackOrders); + compensateDailyMenuReserveBatch(rollbackOrders, rollbackContexts, dinerCount, + rollbackInventory, inventoryBizId); } } }); @@ -1741,18 +1764,27 @@ public class HardOrderServiceImpl extends ServiceImpl 168)) { + return expiredEnabled ? config : null; + } + if (expiredEnabled && (config.getExpiredReserveRefundDelayHours() == null + || config.getExpiredReserveRefundDelayHours() < 1 + || config.getExpiredReserveRefundDelayHours() > 168)) { return null; } return config; @@ -1773,18 +1805,31 @@ public class HardOrderServiceImpl extends ServiceImpl= 1 + && config.getExpiredReserveRefundDelayHours() <= 168 + && reservation.usageEndDateTime() != null + && config.getId() != null) { + order.setExpiredRefundConfigId(config.getId()); + order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime()); + order.setExpiredRefundDeadline(reservation.usageEndDateTime().plusHours(config.getExpiredReserveRefundDelayHours())); + order.setExpiredRefundRestoreInventory(Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) + ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode()); } } @@ -1853,12 +1898,17 @@ public class HardOrderServiceImpl extends ServiceImpl contexts) { + private Map validateDailyMenuCartProducts(Long merchantId, + List contexts, + int dinerCount) { List productIds = contexts.stream() .flatMap(context -> context.carList().stream()) .map(HardCarEntity::getProductId) @@ -1873,6 +1923,12 @@ public class HardOrderServiceImpl extends ServiceImpl product.getId() != null) .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + Map requiredStock = contexts.stream() + .flatMap(context -> context.carList().stream()) + .collect(Collectors.toMap(HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), dinerCount), + Math::addExact, + LinkedHashMap::new)); for (DailyMenuReserveGroupContext context : contexts) { for (HardCarEntity car : context.carList()) { CanteenSpuEntity product = productMap.get(car.getProductId()); @@ -1883,6 +1939,10 @@ public class HardOrderServiceImpl extends ServiceImpl entry : requiredStock.entrySet()) { + CanteenSpuEntity product = productMap.get(entry.getKey()); + if (product.getStock() == null || product.getStock() < entry.getValue()) { + throw new RuntimeException(product.getName() + "库存不足"); + } + } + return requiredStock.keySet().stream().collect(Collectors.toMap(Function.identity(), + productId -> productMap.get(productId).getStock(), (first, second) -> first, LinkedHashMap::new)); } /** @@ -1927,7 +1995,7 @@ public class HardOrderServiceImpl extends ServiceImpl contexts, int dinerCount, + boolean reduce, Map inventorySnapshot, + String inventoryBizId) { + // ROLLBACK 必须原样携带 APPLY 的负增量,由食堂服务负责取反,不能在调用端提前改成正数。 + int factor = -dinerCount; + Map stockChanges = contexts.stream() + .flatMap(context -> context.carList().stream()) + .collect(Collectors.toMap(HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), factor), + Math::addExact, + LinkedHashMap::new)); + CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO(); + stockDto.setBizId(inventoryBizId); + stockDto.setOperation(reduce ? "APPLY" : "ROLLBACK"); + stockDto.setItems(stockChanges.entrySet().stream().map(entry -> { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(entry.getKey()); + item.setIncrCount(entry.getValue()); + item.setBeforeStock(inventorySnapshot.get(entry.getKey())); + return item; + }).toList()); + R result = remoteCanteenSpuService.updateSkuStockIdempotent(stockDto); + if (result == null || !result.isOk()) { + throw new RuntimeException(reduce ? "每日菜谱库存不足或扣减失败" : "每日菜谱库存恢复失败"); + } + } + + /** + * 使用本次批次首个订单 ID 构造每日菜谱库存业务号,避免同一购物车重试命中历史回滚状态。 + * + * @param createdOrders 已分配 ID 的本次每日菜谱订单 + * @return 本批次库存幂等业务号 + */ + private String buildDailyMenuReserveInventoryBizId(List createdOrders) { + Long firstOrderId = createdOrders.stream() + .map(HardOrderEntity::getId) + .filter(Objects::nonNull) + .findFirst() + .orElseThrow(() -> new RuntimeException("每日菜谱订单批次数据异常")); + return "HARD_DAILY_MENU_ORDER_BATCH:" + firstOrderId; + } + + /** + * 补偿每日菜谱批次已尝试的基础 SPU 库存和钱包操作;远程超时按同一业务号重复调用也不会重复恢复。 + * + * @param createdOrders 已创建并可能已产生钱包支付流水的订单 + * @param contexts 原始日期餐段购物车上下文 + * @param dinerCount 实际用餐人数 + * @param inventorySnapshot 扣库存前的基础 SPU 库存快照 + * @param inventoryBizId 已尝试扣库存时的业务号;未发起库存请求时为空 + */ + private void compensateDailyMenuReserveBatch(List createdOrders, + List contexts, + int dinerCount, + Map inventorySnapshot, + String inventoryBizId) { + if (StrUtil.isNotBlank(inventoryBizId)) { + try { + updateDailyMenuReserveInventory(contexts, dinerCount, false, inventorySnapshot, inventoryBizId); + } catch (Exception compensationException) { + log.error("|{}|每日菜谱库存补偿失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException); + } + } + compensateReserveOrderWallets(createdOrders); } /** @@ -3709,15 +3866,160 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, + YesNoEnum isReduce, Long afterSaleId) { + if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { + throw new RuntimeException("每日菜谱退款库存缺少售后单 ID"); + } + List carList = hardCarService.getByParentCarId(order.getParentCarId()); + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("每日菜谱库存操作失败,购物车不存在"); + } + if (ObjectUtil.isNotEmpty(refundCarList)) { + carList = filterRefundInventoryCars(carList, refundCarList); + } + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("每日菜谱库存操作失败,退款商品不存在"); + } + + int factor = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? -1 : 1; + Map stockChanges = carList.stream().collect(Collectors.toMap( + HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), factor), + Math::addExact, + LinkedHashMap::new)); + R> productResult = remoteCanteenSpuService + .getSpuListByIds(new ArrayList<>(stockChanges.keySet())); + if (productResult == null || !productResult.isOk() || productResult.getData() == null) { + throw new RuntimeException(productResult == null ? "查询每日菜谱库存无响应" + : StrUtil.blankToDefault(productResult.getMsg(), "查询每日菜谱库存失败")); + } + Map productMap = productResult.getData().stream() + .filter(Objects::nonNull) + .filter(product -> product.getId() != null) + .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + Map inventorySnapshot = new LinkedHashMap<>(); + for (Map.Entry entry : stockChanges.entrySet()) { + CanteenSpuEntity product = productMap.get(entry.getKey()); + if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) { + throw new RuntimeException("每日菜谱商品不存在或不属于当前商户"); + } + int productType = product.getSpuType() == null ? 1 : product.getSpuType(); + if (productType != 1 && productType != 2) { + throw new RuntimeException(product.getName() + "商品模式异常,请联系商户"); + } + if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) { + throw new RuntimeException(product.getName() + "库存不足"); + } + inventorySnapshot.put(entry.getKey(), product.getStock()); + } + + // 售后单 ID 隔离多次部分退款;同一售后单的支付重试必须复用同一业务号,避免重复恢复库存。 + String inventoryBizId = afterSaleId == null + ? "HARD_DAILY_MENU_ORDER:" + order.getId() + : "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId; + registerDailyMenuInventoryRollback(stockChanges, inventorySnapshot, inventoryBizId); + applyDailyMenuInventoryChange(stockChanges, inventorySnapshot, inventoryBizId, "APPLY"); + } + + /** + * 当前 Hardware 本地事务回滚时反向撤销已经尝试的每日菜谱库存操作,覆盖远程成功但本地提交失败的窗口。 + * + * @param stockChanges 原 APPLY 使用的基础 SPU 库存增量 + * @param inventorySnapshot 操作前库存快照 + * @param inventoryBizId 本次订单或售后库存业务号 + */ + private void registerDailyMenuInventoryRollback(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("库存事务状态异常,请稍后重试"); + } + Map rollbackChanges = Map.copyOf(stockChanges); + Map rollbackSnapshot = Map.copyOf(inventorySnapshot); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 本地事务明确回滚时调用基础 SPU 幂等反向操作;重复回调由业务号状态兜底。 + * + * @param status Spring 事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_ROLLED_BACK) { + return; + } + try { + applyDailyMenuInventoryChange(rollbackChanges, rollbackSnapshot, inventoryBizId, "ROLLBACK"); + } catch (Exception compensationException) { + log.error("|{}|每日菜谱订单库存回滚失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException); + } + } + }); + } + + /** + * 将基础 SPU 增量转换为食堂库存协议并校验远程业务响应,APPLY 与 ROLLBACK 始终复用原增量和快照。 + * + * @param stockChanges SPU ID 与原始库存增量 + * @param inventorySnapshot 操作前库存快照 + * @param inventoryBizId 稳定库存业务号 + * @param operation APPLY 应用或 ROLLBACK 反向撤销 + */ + private void applyDailyMenuInventoryChange(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId, + String operation) { + CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO(); + dto.setBizId(inventoryBizId); + dto.setOperation(operation); + dto.setItems(stockChanges.entrySet().stream().map(entry -> { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(entry.getKey()); + item.setIncrCount(entry.getValue()); + item.setBeforeStock(inventorySnapshot.get(entry.getKey())); + return item; + }).toList()); + R result = remoteCanteenSpuService.updateSkuStockIdempotent(dto); + if (result == null || !result.isOk()) { + throw new RuntimeException(result == null ? "每日菜谱库存服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "每日菜谱库存操作失败")); + } + } + @Override @Transactional(rollbackFor = Exception.class) public void fastenerInventory(HardOrderEntity order, YesNoEnum isReduce) { - getSelf().fastenerInventory(order, null, isReduce); + getSelf().fastenerInventory(order, null, isReduce, null); } @Override @Transactional(rollbackFor = Exception.class) public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce) { + getSelf().fastenerInventory(order, refundCarList, isReduce, null); + } + + /** + * 按订单商品来源执行库存变更。每日菜谱使用基础 SPU 幂等接口,售后单 ID 用于隔离多次部分退款; + * 其他商品来源继续沿用原库存服务和协议,避免改变既有在线订餐、超市与商城行为。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示使用整单购物车 + * @param isReduce 是否扣减库存 + * @param afterSaleId 本次售后单 ID,非退款或历史调用可为空 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId) { HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); if (!ObjectUtil.isAllNotEmpty(hardTypeEnum, orderTypeEnum)) return; @@ -3726,9 +4028,9 @@ public class HardOrderServiceImpl extends ServiceImpl + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 3a11b21c..6dfadd69 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -336,6 +336,138 @@ order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') desc, o.hour_type + + + + + + + + + select DATE_FORMAT(a.create_time, '%Y-%m-%d') as createTime, o.user_dept_id, @@ -245,7 +300,7 @@ SUM(a.total_price) as totalPrice, count(distinct o.id) as timeCount, count(distinct o.user_id) as payUserCount - from hard_car a + from inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and o.hour_type not in ('-1') @@ -344,7 +399,7 @@ SUM(a.total_price) as totalPrice, count(distinct o.id) as timeCount, count(distinct o.user_id) as payUserCount - from hard_car a + from inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and o.hour_type not in ('-1') @@ -388,7 +443,7 @@ count(distinct o.id) as timeCount, SUM(a.product_count) as payCount, SUM(a.total_price) as totalPrice - from hard_car a + from inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and o.hour_type not in ('-1') @@ -432,7 +487,7 @@ a.product_name as productName, SUM(a.product_count) as payCount, SUM(a.total_price) as totalPrice - from hard_car a + from inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and o.hour_type not in ('-1') @@ -517,7 +572,7 @@ SUM(a.product_count) as count, SUM(a.total_price) as totalPrice, DATE_FORMAT(a.create_time, '%Y-%m-%d') as create_time - from hard_car a + from inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null @@ -580,7 +635,7 @@ SUM(a.total_price) as totalPrice, count(distinct o.id) as timeCount, count(distinct o.user_id) as payUserCount - from hard_car a + from inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and o.hour_type not in ('-1') @@ -645,7 +700,7 @@ SUM(a.total_price) as totalPrice, count(distinct o.id) as timeCount, count(distinct o.user_id) as payUserCount - from hard_car a + from inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and o.hour_type not in ('-1') diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java index a61771bd..fcd0e1fd 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java @@ -63,15 +63,15 @@ public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface { private List identityTypes; /** - * 学生身份扩展信息 + * 学生身份扩展信息;写入时作为学生资料提交,查询时按各字段约定匹配。 */ - @Schema(description = "学生身份扩展信息") + @Schema(description = "学生身份扩展信息或学生身份查询条件") private SysUserStudentDto studentInfo; /** - * 老师身份扩展信息,与学生身份扩展信息独立传输。 + * 老师身份扩展信息,与学生身份扩展信息独立传输;查询时老师学号按完整编号精确匹配。 */ - @Schema(description = "老师身份扩展信息") + @Schema(description = "老师身份扩展信息或老师身份查询条件") private SysUserTeacherDto teacherInfo; /** 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 92e07f01..c747579e 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 @@ -136,6 +136,58 @@ d.dept_id + + + + AND EXISTS ( + SELECT 1 + FROM sys_user_identity user_identity + WHERE user_identity.user_id = u.user_id + AND user_identity.identity_type IN + + #{identityType} + + ) + + + + AND EXISTS ( + SELECT 1 + FROM sys_user_student student_identity + WHERE student_identity.user_id = u.user_id + + AND student_identity.student_no = #{query.studentInfo.studentNo} + + + AND student_identity.school_name = #{query.studentInfo.schoolName} + + + + AND student_identity.class_name LIKE #{studentClassNameLike} + + + + AND student_identity.guardian_phones LIKE #{studentGuardianPhoneLike} + + ) + + + + AND EXISTS ( + SELECT 1 + FROM sys_user_teacher teacher_identity + WHERE teacher_identity.user_id = u.user_id + AND teacher_identity.teacher_no = #{query.teacherInfo.teacherNo} + ) + + + + - c.name as catgoryName from mch_manage a @@ -123,4 +140,4 @@ order by a.sort desc , a.dept_id desc , a.create_time desc - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java new file mode 100644 index 00000000..881c4618 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/handler/MchManageJacksonTypeHandlerTest.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.wallet.api.handler; + +import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * 商户 JSON 字段在自动映射和 XML 映射下的目标类型测试。 + */ +class MchManageJacksonTypeHandlerTest { + + /** + * XML 映射只传入 List 原始类型时,元素仍应反序列化为钱包别名对象而不是 Map。 + */ + @Test + void rawListTypeShouldDeserializeWalletAliasElements() { + MchManageJacksonTypeHandler handler = new MchManageJacksonTypeHandler(List.class); + + Object value = handler.parse("[{\"walletTypeId\":10,\"walletUniqueCode\":\"EAT\",\"visible\":true,\"aliasName\":\"餐补\"}]"); + + List aliases = assertInstanceOf(List.class, value); + MchManageEntity.WalletAlias alias = assertInstanceOf(MchManageEntity.WalletAlias.class, aliases.get(0)); + assertEquals(10L, alias.getWalletTypeId()); + assertEquals("EAT", alias.getWalletUniqueCode()); + assertEquals("餐补", alias.getAliasName()); + } + + /** + * 普通对象字段应继续按 XML 显式传入的目标 Class 反序列化。 + */ + @Test + void concreteTypeShouldDeserializeConfiguredClass() { + MchManageJacksonTypeHandler handler = new MchManageJacksonTypeHandler(MchManageEntity.FunctionScope.class); + + Object value = handler.parse("{\"mallEnable\":true,\"tableReservation\":false}"); + + MchManageEntity.FunctionScope functionScope = assertInstanceOf(MchManageEntity.FunctionScope.class, value); + assertEquals(Boolean.TRUE, functionScope.getMallEnable()); + assertEquals(Boolean.FALSE, functionScope.getTableReservation()); + } +} From e5b25d0647a4273883ecc02ebee632b66cbbdd90 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Sun, 23 Aug 2026 14:39:31 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat:=E5=AF=BC=E5=87=BAexcel=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dCanteenProductApptExportMergeHandler.java | 199 ++++++++++++------ ...teenProductApptExportMergeHandlerTest.java | 123 +++++++++++ 2 files changed, 254 insertions(+), 68 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java index ad62bf19..70c29432 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java @@ -1,9 +1,13 @@ package com.bsd.pjcloud.hard.excel; +import com.alibaba.excel.write.handler.RowWriteHandler; import com.alibaba.excel.write.handler.WorkbookWriteHandler; import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteTableHolder; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; import org.apache.poi.ss.usermodel.HorizontalAlignment; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -16,10 +20,10 @@ import java.util.Map; /** * 食堂预约菜品统计导出合并处理器。 * - *

数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列; - * 处理器在工作簿关闭前合并父层级字段,避免分批写入时依赖 Easy Excel 的行缓存。

+ *

数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列。 + * Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。

*/ -public class HardCanteenProductApptExportMergeHandler implements WorkbookWriteHandler { +public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler { /** 日期餐段汇总列。 */ private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5}; @@ -30,87 +34,144 @@ public class HardCanteenProductApptExportMergeHandler implements WorkbookWriteHa /** Easy Excel 双层表头占用两行,数据从第三行开始。 */ private static final int DATA_START_ROW = 2; + /** 导出列总数,用于逐行应用居中样式。 */ + private static final int COLUMN_COUNT = 17; + + /** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */ + private static final short HEAD_FONT_SIZE = 10; + /** - * 工作簿关闭前统一处理数据行样式、层级合并和列宽。 + * 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。 + */ + private static final int[] COLUMN_WIDTHS = {12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11}; + + /** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */ + private final Map centeredStyles = new HashMap<>(); + + /** 当前日期餐段分组的起始行,使用 POI 从零开始的行号。 */ + private int summaryGroupStart = -1; + + /** 当前商户部门分组的起始行,使用 POI 从零开始的行号。 */ + private int merchantGroupStart = -1; + + /** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */ + private int lastDataRow = -1; + + /** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */ + private Sheet currentSheet; + + /** + * 数据行完成写入后立即处理样式和分组边界,避免该行被流式工作簿刷出内存后无法再次读取。 * *

Easy Excel 已为金额列设置数字格式,居中时必须从原样式复制再覆盖对齐属性, * 不能直接替换成全新的样式,否则金额会失去两位小数格式。

* + * @param writeSheetHolder 当前工作表持有者 + * @param writeTableHolder 当前表格持有者,本导出未使用独立表格 + * @param row 刚完成写入且尚在内存中的行 + * @param relativeRowIndex 当前批次中的相对行号 + * @param isHead 是否为表头行 + */ + @Override + public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, + Integer relativeRowIndex, Boolean isHead) { + if (row == null) { + return; + } + if (Boolean.TRUE.equals(isHead)) { + applyCompactHeadFont(row, writeSheetHolder.getParentWriteWorkbookHolder()); + return; + } + if (row.getRowNum() < DATA_START_ROW) { + return; + } + currentSheet = writeSheetHolder.getSheet(); + applyCenteredStyle(row, writeSheetHolder.getParentWriteWorkbookHolder()); + int rowIndex = row.getRowNum(); + boolean summaryRow = hasValue(row, 0); + boolean merchantRow = hasValue(row, 6); + if (summaryRow) { + mergeColumns(currentSheet, summaryGroupStart, rowIndex - 1, SUMMARY_COLUMNS); + summaryGroupStart = rowIndex; + mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS); + merchantGroupStart = -1; + } + if (merchantRow) { + mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS); + merchantGroupStart = rowIndex; + } + lastDataRow = rowIndex; + } + + /** + * 工作簿关闭前收尾最后一个日期餐段和商户部门分组,并统一设置列宽。 + * * @param holder Easy Excel 当前工作簿持有者 */ @Override public void afterWorkbookDispose(WriteWorkbookHolder holder) { - Sheet sheet = holder.getWorkbook().getNumberOfSheets() == 0 ? null : holder.getWorkbook().getSheetAt(0); - if (sheet == null || sheet.getLastRowNum() < DATA_START_ROW) { + if (currentSheet == null || lastDataRow < DATA_START_ROW) { return; } - Map centeredStyles = new HashMap<>(); - int lastRow = sheet.getLastRowNum(); - for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow; rowIndex++) { - Row row = sheet.getRow(rowIndex); - if (row == null) { + mergeColumns(currentSheet, summaryGroupStart, lastDataRow, SUMMARY_COLUMNS); + mergeColumns(currentSheet, merchantGroupStart, lastDataRow, MERCHANT_COLUMNS); + for (int column = 0; column < COLUMN_WIDTHS.length; column++) { + currentSheet.setColumnWidth(column, COLUMN_WIDTHS[column] * 256); + } + } + + /** + * 在当前行仍处于流式窗口时复制原数字格式并应用水平、垂直居中。 + * + * @param row 当前数据行 + * @param holder 当前工作簿持有者,用于创建可复用样式 + */ + private void applyCenteredStyle(Row row, WriteWorkbookHolder holder) { + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { continue; } - for (int column = 0; column < 17; column++) { - Cell cell = row.getCell(column); - if (cell != null) { - CellStyle originalStyle = cell.getCellStyle(); - short styleIndex = originalStyle.getIndex(); - CellStyle centeredStyle = centeredStyles.computeIfAbsent(styleIndex, key -> { - CellStyle copiedStyle = holder.getWorkbook().createCellStyle(); - copiedStyle.cloneStyleFrom(originalStyle); - copiedStyle.setAlignment(HorizontalAlignment.CENTER); - copiedStyle.setVerticalAlignment(VerticalAlignment.CENTER); - return copiedStyle; - }); - cell.setCellStyle(centeredStyle); - } - } - } - mergeSummaryGroups(sheet, lastRow); - mergeMerchantGroups(sheet, lastRow); - int[] widths = {13, 12, 11, 11, 13, 14, 18, 18, 13, 12, 11, 11, 13, 14, 24, 11, 14}; - for (int column = 0; column < widths.length; column++) { - sheet.setColumnWidth(column, widths[column] * 256); + CellStyle originalStyle = cell.getCellStyle(); + short styleIndex = originalStyle.getIndex(); + CellStyle centeredStyle = centeredStyles.computeIfAbsent(styleIndex, key -> { + CellStyle copiedStyle = holder.getWorkbook().createCellStyle(); + copiedStyle.cloneStyleFrom(originalStyle); + copiedStyle.setAlignment(HorizontalAlignment.CENTER); + copiedStyle.setVerticalAlignment(VerticalAlignment.CENTER); + return copiedStyle; + }); + cell.setCellStyle(centeredStyle); } } - /** 合并每个日期餐段组的汇总列。 */ - private void mergeSummaryGroups(Sheet sheet, int lastRow) { - int groupStart = -1; - for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow + 1; rowIndex++) { - boolean boundary = rowIndex > lastRow || hasValue(sheet, rowIndex, 0); - if (!boundary) { + /** + * 缩小当前表头行使用的字体;只调整字号并保留 Easy Excel 生成的字体名称、颜色和加粗属性。 + * + * @param row 当前表头行 + * @param holder 当前工作簿持有者,用于定位表头样式引用的字体 + */ + private void applyCompactHeadFont(Row row, WriteWorkbookHolder holder) { + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { continue; } - if (groupStart >= 0) { - mergeColumns(sheet, groupStart, rowIndex - 1, SUMMARY_COLUMNS); - } - groupStart = rowIndex > lastRow ? -1 : rowIndex; + Font font = holder.getWorkbook().getFontAt(cell.getCellStyle().getFontIndex()); + font.setFontHeightInPoints(HEAD_FONT_SIZE); } } - /** 合并每个商户部门组的统计列,商品明细行共享商户统计。 */ - private void mergeMerchantGroups(Sheet sheet, int lastRow) { - int groupStart = -1; - for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow + 1; rowIndex++) { - boolean summaryRow = rowIndex <= lastRow && hasValue(sheet, rowIndex, 0); - boolean merchantRow = rowIndex <= lastRow && hasValue(sheet, rowIndex, 6); - // 日期汇总行也是商户分组的边界,不能让上一日期最后一个商户跨过汇总行继续合并。 - boolean boundary = rowIndex > lastRow || summaryRow || (merchantRow && groupStart >= 0); - if (boundary && groupStart >= 0) { - mergeColumns(sheet, groupStart, rowIndex - 1, MERCHANT_COLUMNS); - groupStart = -1; - } - if (merchantRow) { - groupStart = rowIndex; - } - } - } - - /** 按列范围添加合并单元格。 */ + /** + * 按列范围添加合并单元格;单行或尚未开始的分组无需创建合并区域。 + * + * @param sheet 当前导出工作表 + * @param firstRow 分组起始行,使用 POI 从零开始的行号 + * @param lastRow 分组结束行,使用 POI 从零开始的行号 + * @param columns 需要纵向合并的列索引 + */ private void mergeColumns(Sheet sheet, int firstRow, int lastRow, int[] columns) { - if (firstRow >= lastRow) { + if (sheet == null || firstRow < DATA_START_ROW || firstRow >= lastRow) { return; } for (int column : columns) { @@ -118,12 +179,14 @@ public class HardCanteenProductApptExportMergeHandler implements WorkbookWriteHa } } - /** 判断指定单元格是否包含非空文本。 */ - private boolean hasValue(Sheet sheet, int rowIndex, int columnIndex) { - Row row = sheet.getRow(rowIndex); - if (row == null) { - return false; - } + /** + * 判断当前尚在内存中的行是否在指定列包含非空值。 + * + * @param row 当前数据行 + * @param columnIndex 用于识别行层级的列索引 + * @return 指定单元格存在且包含非空文本时返回 {@code true} + */ + private boolean hasValue(Row row, int columnIndex) { Cell cell = row.getCell(columnIndex); return cell != null && !cell.toString().trim().isEmpty(); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java new file mode 100644 index 00000000..148ba09b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java @@ -0,0 +1,123 @@ +package com.bsd.pjcloud.hard.excel; + +import com.alibaba.excel.EasyExcel; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 菜品预约导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。 + */ +class HardCanteenProductApptExportMergeHandlerTest { + + /** + * 构造超过默认流式窗口的两组数据,验证前后日期餐段及商户部门区域都能完成合并。 + * + * @throws Exception 临时文件或工作簿读写失败时抛出 + */ + @Test + void shouldMergeAllGroupsAfterEarlierRowsAreFlushed() throws Exception { + Path output = Files.createTempFile("canteen-product-appt-merge-test-", ".xlsx"); + try { + List rows = new ArrayList<>(); + rows.add(summaryRow(LocalDate.of(2026, 8, 23), "早餐")); + rows.add(merchantRow(LocalDate.of(2026, 8, 23), "早餐")); + addProductRows(rows, 130); + rows.add(summaryRow(LocalDate.of(2026, 8, 24), "午餐")); + rows.add(merchantRow(LocalDate.of(2026, 8, 24), "午餐")); + addProductRows(rows, 5); + + EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class) + .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()) + .sheet("菜品预约统计").doWrite(rows); + + try (InputStream input = Files.newInputStream(output); Workbook workbook = new XSSFWorkbook(input)) { + Set mergedRegions = workbook.getSheetAt(0).getMergedRegions().stream() + .map(region -> region.formatAsString()).collect(Collectors.toSet()); + assertTrue(mergedRegions.contains("A3:A134")); + assertTrue(mergedRegions.contains("F3:F134")); + assertTrue(mergedRegions.contains("G4:G134")); + assertTrue(mergedRegions.contains("N4:N134")); + assertTrue(mergedRegions.contains("A135:A141")); + assertTrue(mergedRegions.contains("G136:G141")); + assertEquals(10, workbook.getFontAt(workbook.getSheetAt(0).getRow(0).getCell(0) + .getCellStyle().getFontIndex()).getFontHeightInPoints()); + assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0)); + assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(4)); + assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(12)); + assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(14)); + assertEquals(9 * 256, workbook.getSheetAt(0).getColumnWidth(15)); + } + } finally { + Files.deleteIfExists(output); + } + } + + /** + * 构造日期餐段汇总行。 + * + * @param date 预约日期 + * @param hourType 餐段名称 + * @return 仅填写汇总列的导出行 + */ + private HardCanteenProductApptExcelVo summaryRow(LocalDate date, String hourType) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setSummaryDate(date); + row.setSummaryHourType(hourType); + row.setSummaryPayUserCount(1L); + row.setSummaryTimeCount(1L); + row.setSummaryPayCount(1L); + row.setSummaryTotalPrice(BigDecimal.ONE); + return row; + } + + /** + * 构造商户部门统计行。 + * + * @param date 预约日期 + * @param hourType 餐段名称 + * @return 仅填写商户部门列的导出行 + */ + private HardCanteenProductApptExcelVo merchantRow(LocalDate date, String hourType) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setMerchantName("测试食堂"); + row.setDepartmentName("测试部门"); + row.setMerchantDate(date); + row.setMerchantHourType(hourType); + row.setMerchantPayUserCount(1L); + row.setMerchantTimeCount(1L); + row.setMerchantPayCount(1L); + row.setMerchantTotalPrice(BigDecimal.ONE); + return row; + } + + /** + * 追加指定数量的商品明细行,用于触发流式工作簿刷出早期数据行。 + * + * @param rows 待追加的导出行集合 + * @param count 商品明细行数量 + */ + private void addProductRows(List rows, int count) { + for (int index = 0; index < count; index++) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setProductName("测试商品" + index); + row.setProductCount(1L); + row.setProductTotalPrice(BigDecimal.ONE); + rows.add(row); + } + } +} From 5b4a505780b41fe9b5021eab50e5f7882c5e7b58 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Sun, 23 Aug 2026 15:16:37 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=E5=AF=BC=E5=87=BAexcel=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dCanteenProductApptExportMergeHandler.java | 130 ++++++++++++++++++ ...teenProductApptExportMergeHandlerTest.java | 42 +++++- 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java index 70c29432..48cf38fc 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java @@ -5,6 +5,7 @@ import com.alibaba.excel.write.handler.WorkbookWriteHandler; import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; import com.alibaba.excel.write.metadata.holder.WriteTableHolder; +import org.apache.poi.ss.usermodel.BorderStyle; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.Font; @@ -13,6 +14,8 @@ import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.VerticalAlignment; import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; +import org.apache.poi.xssf.usermodel.XSSFColor; import java.util.HashMap; import java.util.Map; @@ -40,6 +43,15 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler /** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */ private static final short HEAD_FONT_SIZE = 10; + /** 日期分组边框线型,参考模板使用中等线宽突出每天的数据边界。 */ + private static final BorderStyle DATE_GROUP_BORDER_STYLE = BorderStyle.MEDIUM; + + /** 日期分组边框颜色,按预约日期顺序交替使用蓝色和橙色。 */ + private static final byte[][] DATE_GROUP_BORDER_COLORS = { + {0x5B, (byte) 0x9B, (byte) 0xD5}, + {(byte) 0xED, 0x7D, 0x31} + }; + /** * 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。 */ @@ -48,6 +60,9 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler /** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */ private final Map centeredStyles = new HashMap<>(); + /** 原样式、日期颜色和边框位置到彩色边框样式的缓存,避免重复创建工作簿样式。 */ + private final Map dateGroupBorderStyles = new HashMap<>(); + /** 当前日期餐段分组的起始行,使用 POI 从零开始的行号。 */ private int summaryGroupStart = -1; @@ -57,6 +72,12 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler /** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */ private int lastDataRow = -1; + /** 当前预约日期分组序号,同一天的所有餐段共用一个序号和边框颜色。 */ + private int dateGroupIndex = -1; + + /** 当前预约日期的单元格值标识,用于判断新餐段是否仍属于同一天。 */ + private String currentDateKey; + /** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */ private Sheet currentSheet; @@ -90,12 +111,24 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler int rowIndex = row.getRowNum(); boolean summaryRow = hasValue(row, 0); boolean merchantRow = hasValue(row, 6); + boolean newDateGroup = false; if (summaryRow) { + String summaryDateKey = getCellValueKey(row, 0); + newDateGroup = currentDateKey == null || !currentDateKey.equals(summaryDateKey); + if (newDateGroup) { + Row previousDateLastRow = currentSheet.getRow(rowIndex - 1); + applyDateGroupBorder(previousDateLastRow, writeSheetHolder.getParentWriteWorkbookHolder(), + dateGroupIndex, false, true); + currentDateKey = summaryDateKey; + dateGroupIndex++; + } mergeColumns(currentSheet, summaryGroupStart, rowIndex - 1, SUMMARY_COLUMNS); summaryGroupStart = rowIndex; mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS); merchantGroupStart = -1; } + applyDateGroupBorder(row, writeSheetHolder.getParentWriteWorkbookHolder(), dateGroupIndex, + newDateGroup, false); if (merchantRow) { mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS); merchantGroupStart = rowIndex; @@ -115,6 +148,10 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler } mergeColumns(currentSheet, summaryGroupStart, lastDataRow, SUMMARY_COLUMNS); mergeColumns(currentSheet, merchantGroupStart, lastDataRow, MERCHANT_COLUMNS); + Row lastRow = currentSheet.getRow(lastDataRow); + if (lastRow != null) { + applyDateGroupBorder(lastRow, holder, dateGroupIndex, false, true); + } for (int column = 0; column < COLUMN_WIDTHS.length; column++) { currentSheet.setColumnWidth(column, COLUMN_WIDTHS[column] * 256); } @@ -162,6 +199,99 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler } } + /** + * 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。 + * + *

流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出; + * 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。

+ * + * @param row 当前数据行 + * @param holder 当前工作簿持有者,用于创建彩色边框样式 + * @param groupIndex 当前预约日期分组序号,未识别到日期时为负数 + * @param topBorder 是否为当天首行并设置顶部边框 + * @param bottomBorder 是否为当天末行并设置底部边框 + */ + private void applyDateGroupBorder(Row row, WriteWorkbookHolder holder, int groupIndex, boolean topBorder, + boolean bottomBorder) { + if (row == null || groupIndex < 0) { + return; + } + int colorIndex = groupIndex % DATE_GROUP_BORDER_COLORS.length; + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell cell = row.getCell(column); + if (cell == null) { + continue; + } + boolean leftBorder = column == 0; + boolean rightBorder = column == COLUMN_COUNT - 1; + if (!leftBorder && !rightBorder && !topBorder && !bottomBorder) { + continue; + } + CellStyle borderedStyle = getDateGroupBorderStyle(cell.getCellStyle(), holder, colorIndex, + leftBorder, rightBorder, topBorder, bottomBorder); + cell.setCellStyle(borderedStyle); + } + } + + /** + * 创建或复用保留原有数字格式、对齐方式的彩色边框样式。 + * + * @param originalStyle 当前单元格原样式 + * @param holder 当前工作簿持有者 + * @param colorIndex 日期边框颜色索引 + * @param leftBorder 是否设置左边框 + * @param rightBorder 是否设置右边框 + * @param topBorder 是否设置上边框 + * @param bottomBorder 是否设置下边框 + * @return 应用分组边框后的样式 + */ + private CellStyle getDateGroupBorderStyle(CellStyle originalStyle, WriteWorkbookHolder holder, int colorIndex, + boolean leftBorder, boolean rightBorder, boolean topBorder, + boolean bottomBorder) { + String cacheKey = originalStyle.getIndex() + ":" + colorIndex + ":" + leftBorder + rightBorder + + topBorder + bottomBorder; + CellStyle cachedStyle = dateGroupBorderStyles.get(cacheKey); + if (cachedStyle != null) { + return cachedStyle; + } + CellStyle copiedStyle = holder.getWorkbook().createCellStyle(); + copiedStyle.cloneStyleFrom(originalStyle); + if (!(copiedStyle instanceof XSSFCellStyle xssfStyle)) { + throw new IllegalStateException("菜品预约导出仅支持 XLSX 彩色边框样式"); + } + XSSFColor color = new XSSFColor(DATE_GROUP_BORDER_COLORS[colorIndex]); + if (leftBorder) { + xssfStyle.setBorderLeft(DATE_GROUP_BORDER_STYLE); + xssfStyle.setLeftBorderColor(color); + } + if (rightBorder) { + xssfStyle.setBorderRight(DATE_GROUP_BORDER_STYLE); + xssfStyle.setRightBorderColor(color); + } + if (topBorder) { + xssfStyle.setBorderTop(DATE_GROUP_BORDER_STYLE); + xssfStyle.setTopBorderColor(color); + } + if (bottomBorder) { + xssfStyle.setBorderBottom(DATE_GROUP_BORDER_STYLE); + xssfStyle.setBottomBorderColor(color); + } + dateGroupBorderStyles.put(cacheKey, copiedStyle); + return copiedStyle; + } + + /** + * 获取日期汇总单元格的稳定值标识;同一预约日期的不同餐段会得到相同标识。 + * + * @param row 当前日期餐段汇总行 + * @param columnIndex 预约日期所在列索引 + * @return 用于判断日期是否变化的非空值标识 + */ + private String getCellValueKey(Row row, int columnIndex) { + Cell cell = row.getCell(columnIndex); + return cell == null ? "" : cell.toString().trim(); + } + /** * 按列范围添加合并单元格;单行或尚未开始的分组无需创建合并区域。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java index 148ba09b..7c3a1519 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java @@ -2,8 +2,11 @@ package com.bsd.pjcloud.hard.excel; import com.alibaba.excel.EasyExcel; import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; +import org.apache.poi.ss.usermodel.BorderStyle; import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xssf.usermodel.extensions.XSSFCellBorder.BorderSide; import org.junit.jupiter.api.Test; import java.io.InputStream; @@ -16,6 +19,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -37,8 +41,11 @@ class HardCanteenProductApptExportMergeHandlerTest { rows.add(summaryRow(LocalDate.of(2026, 8, 23), "早餐")); rows.add(merchantRow(LocalDate.of(2026, 8, 23), "早餐")); addProductRows(rows, 130); - rows.add(summaryRow(LocalDate.of(2026, 8, 24), "午餐")); - rows.add(merchantRow(LocalDate.of(2026, 8, 24), "午餐")); + rows.add(summaryRow(LocalDate.of(2026, 8, 23), "午餐")); + rows.add(merchantRow(LocalDate.of(2026, 8, 23), "午餐")); + addProductRows(rows, 2); + rows.add(summaryRow(LocalDate.of(2026, 8, 24), "早餐")); + rows.add(merchantRow(LocalDate.of(2026, 8, 24), "早餐")); addProductRows(rows, 5); EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class) @@ -52,8 +59,10 @@ class HardCanteenProductApptExportMergeHandlerTest { assertTrue(mergedRegions.contains("F3:F134")); assertTrue(mergedRegions.contains("G4:G134")); assertTrue(mergedRegions.contains("N4:N134")); - assertTrue(mergedRegions.contains("A135:A141")); - assertTrue(mergedRegions.contains("G136:G141")); + assertTrue(mergedRegions.contains("A135:A138")); + assertTrue(mergedRegions.contains("G136:G138")); + assertTrue(mergedRegions.contains("A139:A145")); + assertTrue(mergedRegions.contains("G140:G145")); assertEquals(10, workbook.getFontAt(workbook.getSheetAt(0).getRow(0).getCell(0) .getCellStyle().getFontIndex()).getFontHeightInPoints()); assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0)); @@ -61,6 +70,31 @@ class HardCanteenProductApptExportMergeHandlerTest { assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(12)); assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(14)); assertEquals(9 * 256, workbook.getSheetAt(0).getColumnWidth(15)); + XSSFCellStyle firstGroupTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(2) + .getCell(0).getCellStyle(); + XSSFCellStyle firstGroupSideStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3) + .getCell(0).getCellStyle(); + XSSFCellStyle firstGroupRightStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3) + .getCell(16).getCellStyle(); + XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(134) + .getCell(0).getCellStyle(); + XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(137) + .getCell(0).getCellStyle(); + XSSFCellStyle secondDateTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(138) + .getCell(0).getCellStyle(); + XSSFCellStyle lastDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(144) + .getCell(0).getCellStyle(); + assertEquals(BorderStyle.MEDIUM, firstGroupTopStyle.getBorderTop()); + assertEquals(BorderStyle.MEDIUM, firstGroupSideStyle.getBorderLeft()); + assertEquals(BorderStyle.MEDIUM, firstGroupRightStyle.getBorderRight()); + assertEquals(BorderStyle.NONE, sameDateMealTopStyle.getBorderTop()); + assertEquals(BorderStyle.MEDIUM, firstDateBottomStyle.getBorderBottom()); + assertEquals(BorderStyle.MEDIUM, secondDateTopStyle.getBorderTop()); + assertEquals(BorderStyle.MEDIUM, lastDateBottomStyle.getBorderBottom()); + assertArrayEquals(new byte[]{0x5B, (byte) 0x9B, (byte) 0xD5}, + firstGroupTopStyle.getBorderColor(BorderSide.TOP).getRGB()); + assertArrayEquals(new byte[]{(byte) 0xED, 0x7D, 0x31}, + secondDateTopStyle.getBorderColor(BorderSide.TOP).getRGB()); } } finally { Files.deleteIfExists(output); From 78ce41a4de0181b89971ff9bbabd90f03ee561e6 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Sun, 23 Aug 2026 15:59:24 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=E4=BA=BA=E5=91=98=E8=8F=9C=E5=93=81?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E7=BB=9F=E8=AE=A1=E5=A2=9E=E5=8A=A0=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E5=92=8C=E8=BA=AB=E4=BB=BD=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/HardCanteenUserApptQueryDto.java | 21 ++- ...HardCanteenApptTotalExportServiceImpl.java | 105 ++++++++++-- .../HardCanteenApptTotalServiceImpl.java | 44 +++++- ...CanteenApptTotalExportServiceImplTest.java | 149 ++++++++++++++++++ .../bsd/pjcloud/admin/api/dto/UserDTO.java | 3 +- .../admin/api/feign/RemoteUserService.java | 2 +- .../base/BaseUserIdentityQueryInterface.java | 71 +++++++++ .../admin/controller/SysUserController.java | 2 +- .../service/impl/SysUserServiceImpl.java | 2 +- .../main/resources/mapper/SysUserMapper.xml | 17 ++ 10 files changed, 397 insertions(+), 19 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java index e8384b42..d16c4ae4 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java @@ -1,6 +1,9 @@ package com.bsd.pjcloud.hard.api.dto.order; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -17,7 +20,7 @@ import java.util.List; */ @Data @Schema(description = "食堂用户预约查询条件") -public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface { +public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { /** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */ @NotNull(message = "用餐日期范围不能为空") @@ -29,6 +32,22 @@ public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface { @Schema(description = "用户姓名、登录账号或手机号") private String nameOrPhone; + /** 用户身份协议编码集合;为空时不限制身份类型,编码由 UPMS 身份表和前端字典共同约束。 */ + @Schema(description = "用户身份类型集合") + private List identityTypes; + + /** 学生身份扩展查询条件;字段匹配语义沿用 UPMS 用户查询。 */ + @Schema(description = "学生身份扩展查询条件") + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;老师学号为空时不限制老师资料。 */ + @Schema(description = "老师身份扩展查询条件") + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态筛选:1 表示存在未删除人脸,0 表示不存在,空值表示不限制。 */ + @Schema(description = "是否已录入人脸:1已录入,0未录入") + private String hasFace; + /** 部门树选中的部门主键集合;为空时使用当前操作者完整的数据权限范围。 */ @Schema(description = "部门主键集合") private List deptIds; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java index ca95abc9..b8c40d3d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java @@ -7,6 +7,8 @@ import com.alibaba.excel.write.metadata.WriteSheet; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysDictItem; import com.bsd.pjcloud.admin.api.entity.SysPost; @@ -446,6 +448,10 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot target.setApptUseDateList(source.getApptUseDateList() == null ? null : source.getApptUseDateList().clone()); target.setNameOrPhone(source.getNameOrPhone()); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + target.setHasFace(source.getHasFace()); target.setDeptIds(copyList(source.getDeptIds())); target.setIsInclude(source.getIsInclude()); target.setMerchantId(source.getMerchantId()); @@ -467,6 +473,36 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot return source == null ? null : new ArrayList<>(source); } + /** + * 复制学生身份查询条件,确保异步导出线程不共享请求对象的可变扩展资料。 + * + * @param source 原始学生身份查询条件 + * @return 独立的学生身份查询条件;源值为空时返回空值 + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) return null; + SysUserStudentDto target = new SysUserStudentDto(); + target.setStudentNo(source.getStudentNo()); + target.setSchoolName(source.getSchoolName()); + target.setClassName(source.getClassName()); + target.setGuardianPhones(source.getGuardianPhones()); + target.setExtJson(source.getExtJson()); + return target; + } + + /** + * 复制老师身份查询条件,确保异步导出线程不共享请求对象的可变扩展资料。 + * + * @param source 原始老师身份查询条件 + * @return 独立的老师身份查询条件;源值为空时返回空值 + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) return null; + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + /** * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。 * @@ -625,11 +661,15 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。 * * @param query 用户预约查询条件 - * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象 + * @return 姓名手机号、身份、部门包含关系和指定用户条件的独立请求对象 */ private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) { UserDTO userQuery = new UserDTO(); userQuery.setNameOrPhone(query.getNameOrPhone()); + userQuery.setIdentityTypes(copyList(query.getIdentityTypes())); + userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo())); + userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo())); + userQuery.setHasFace(query.getHasFace()); userQuery.setDeptIds(copyList(query.getDeptIds())); userQuery.setIsInclude(query.getIsInclude()); userQuery.setUserIds(copyList(query.getUserIds())); @@ -1377,16 +1417,46 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot continue; } String hourType = summary.getHourType(); + String summaryKey = date + "|" + hourType; + List merchantRows = merchantGroups.getOrDefault(summaryKey, List.of()); + Map> detailRowsByMerchant = new LinkedHashMap<>(); + Map detailCountsByMerchant = new HashMap<>(); + Map detailPricesByMerchant = new HashMap<>(); + long summaryProductCount = 0L; + long summaryTotalPrice = 0L; + for (HardCanteenProductApptExportRecord merchant : merchantRows) { + String merchantKey = groupKey(merchant); + List detailRows = productGroups + .getOrDefault(merchantKey, List.of()).stream() + .flatMap(product -> toProductRows(product, + menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream()) + .toList(); + long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum(); + long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum(); + // 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。 + if (detailRows.isEmpty()) { + detailCount = valueOrZero(merchant.getPayCount()); + detailPrice = valueOrZero(merchant.getTotalPrice()); + } + detailRowsByMerchant.put(merchantKey, detailRows); + detailCountsByMerchant.put(merchantKey, detailCount); + detailPricesByMerchant.put(merchantKey, detailPrice); + summaryProductCount += detailCount; + summaryTotalPrice += detailPrice; + } + if (merchantRows.isEmpty()) { + summaryProductCount = valueOrZero(summary.getPayCount()); + summaryTotalPrice = valueOrZero(summary.getTotalPrice()); + } HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo(); summaryRow.setSummaryDate(date); summaryRow.setSummaryHourType(resolveHourName(hourType, hourNames)); summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount())); summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount())); - summaryRow.setSummaryPayCount(valueOrZero(summary.getPayCount())); - summaryRow.setSummaryTotalPrice(centsToYuan(summary.getTotalPrice())); + // 汇总数量必须与下方已展开的商品明细一致;套餐按组成商品份数计入,而不是只计套餐份数。 + summaryRow.setSummaryPayCount(summaryProductCount); + summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice)); rows.add(summaryRow); - String summaryKey = date + "|" + hourType; - List merchantRows = merchantGroups.getOrDefault(summaryKey, List.of()); for (HardCanteenProductApptExportRecord merchant : merchantRows) { HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo(); merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录")); @@ -1395,14 +1465,13 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot merchantRow.setMerchantHourType(resolveHourName(hourType, hourNames)); merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount())); merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount())); - merchantRow.setMerchantPayCount(valueOrZero(merchant.getPayCount())); - merchantRow.setMerchantTotalPrice(centsToYuan(merchant.getTotalPrice())); - rows.add(merchantRow); String merchantKey = groupKey(merchant); - List productRows = productGroups.getOrDefault(merchantKey, List.of()); - for (HardCanteenProductApptExportRecord product : productRows) { - rows.addAll(toProductRows(product, menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date)))); - } + merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault(merchantKey, + valueOrZero(merchant.getPayCount()))); + merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault(merchantKey, + valueOrZero(merchant.getTotalPrice())))); + rows.add(merchantRow); + rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of())); } } return rows; @@ -1609,6 +1678,18 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2).setScale(2, RoundingMode.HALF_UP); } + /** + * 将商品明细展示金额从元转换回分,用于按最终商品行重新计算日期餐段和部门合计。 + * + *

商品行已经按两位小数输出,因此这里使用半舍入恢复整数分,避免直接使用 double 导致合计出现尾差。

+ * + * @param yuan 商品明细展示金额,单位为元;为空按零处理 + * @return 金额,单位为分 + */ + private long yuanToCents(BigDecimal yuan) { + return yuan == null ? 0L : yuan.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(); + } + /** 解析餐段名称,字典缺失时保留原始编码。 */ private String resolveHourName(String hourType, Map hourNames) { return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段")); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index f0c90ae6..414e6cf0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -4,6 +4,8 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; @@ -200,6 +202,10 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ target.setApptUseDateList(source.getApptUseDateList() == null ? null : source.getApptUseDateList().clone()); target.setNameOrPhone(source.getNameOrPhone()); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + target.setHasFace(source.getHasFace()); target.setDeptIds(copyList(source.getDeptIds())); target.setIsInclude(source.getIsInclude()); target.setMerchantId(source.getMerchantId()); @@ -221,13 +227,43 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return source == null ? null : new ArrayList<>(source); } + /** + * 复制学生身份查询条件,避免分页预筛选改动请求对象持有的可变扩展资料。 + * + * @param source 原始学生身份查询条件 + * @return 独立的学生身份查询条件;源值为空时返回空值 + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) return null; + SysUserStudentDto target = new SysUserStudentDto(); + target.setStudentNo(source.getStudentNo()); + target.setSchoolName(source.getSchoolName()); + target.setClassName(source.getClassName()); + target.setGuardianPhones(source.getGuardianPhones()); + target.setExtJson(source.getExtJson()); + return target; + } + + /** + * 复制老师身份查询条件,避免分页预筛选改动请求对象持有的可变扩展资料。 + * + * @param source 原始老师身份查询条件 + * @return 独立的老师身份查询条件;源值为空时返回空值 + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) return null; + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + /** * 按当前用户预约查询条件生成每日和查询范围总计。 * *

统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数, * 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。

* - * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件 * @return 按日期升序排列并包含范围总计的统计结果 */ @Override @@ -514,11 +550,15 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。 * * @param query 用户预约查询条件 - * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象 + * @return 姓名手机号、身份、部门包含关系和指定用户条件的独立请求对象 */ private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) { UserDTO userQuery = new UserDTO(); userQuery.setNameOrPhone(query.getNameOrPhone()); + userQuery.setIdentityTypes(copyList(query.getIdentityTypes())); + userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo())); + userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo())); + userQuery.setHasFace(query.getHasFace()); userQuery.setDeptIds(copyList(query.getDeptIds())); userQuery.setIsInclude(query.getIsInclude()); userQuery.setUserIds(copyList(query.getUserIds())); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java new file mode 100644 index 00000000..67654c95 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java @@ -0,0 +1,149 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysDictItem; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteDictService; +import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService; +import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; +import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 菜品预约导出服务测试,验证日期餐段和部门合计与套餐展开后的商品明细保持同一数量口径。 + */ +class HardCanteenApptTotalExportServiceImplTest { + + /** + * 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,而不是累计套餐份数。 + */ + @Test + void shouldAggregateMealSegmentTotalsAcrossDepartmentsFromExpandedProductRows() { + HardCarMapper hardCarMapper = mock(HardCarMapper.class); + RemoteDailyMenuService remoteDailyMenuService = mock(RemoteDailyMenuService.class); + RemoteDeptService remoteDeptService = mock(RemoteDeptService.class); + RemoteDictService remoteDictService = mock(RemoteDictService.class); + RemoteMchService remoteMchService = mock(RemoteMchService.class); + HardCanteenApptTotalExportServiceImpl service = mock(HardCanteenApptTotalExportServiceImpl.class, + org.mockito.Answers.CALLS_REAL_METHODS); + ReflectionTestUtils.setField(service, "hardCarMapper", hardCarMapper); + ReflectionTestUtils.setField(service, "remoteDailyMenuService", remoteDailyMenuService); + ReflectionTestUtils.setField(service, "remoteDeptService", remoteDeptService); + ReflectionTestUtils.setField(service, "remoteDictService", remoteDictService); + ReflectionTestUtils.setField(service, "remoteMchService", remoteMchService); + + LocalDate date = LocalDate.of(2026, 8, 24); + HardCanteenProductTotalVo summary = new HardCanteenProductTotalVo(); + summary.setCreateTime(date.atStartOfDay()); + summary.setHourType("1"); + summary.setPayUserCount(2L); + summary.setTimeCount(2L); + summary.setPayCount(5L); + summary.setTotalPrice(800L); + + HardCanteenProductApptExportRecord merchant = new HardCanteenProductApptExportRecord(); + merchant.setApptDate(date.toString()); + merchant.setHourType("1"); + merchant.setMerchantId(10L); + merchant.setUserDeptId(20L); + merchant.setPayUserCount(1L); + merchant.setTimeCount(1L); + merchant.setPayCount(1L); + merchant.setTotalPrice(300L); + + HardCanteenProductApptExportRecord product = new HardCanteenProductApptExportRecord(); + product.setApptDate(date.toString()); + product.setHourType("1"); + product.setMerchantId(10L); + product.setUserDeptId(20L); + product.setProductId(100L); + product.setProductName("套餐"); + product.setPayCount(1L); + product.setTotalPrice(300L); + + HardCanteenProductApptExportRecord secondMerchant = new HardCanteenProductApptExportRecord(); + secondMerchant.setApptDate(date.toString()); + secondMerchant.setHourType("1"); + secondMerchant.setMerchantId(10L); + secondMerchant.setUserDeptId(30L); + secondMerchant.setPayUserCount(1L); + secondMerchant.setTimeCount(1L); + secondMerchant.setPayCount(4L); + secondMerchant.setTotalPrice(500L); + + HardCanteenProductApptExportRecord secondProduct = new HardCanteenProductApptExportRecord(); + secondProduct.setApptDate(date.toString()); + secondProduct.setHourType("1"); + secondProduct.setMerchantId(10L); + secondProduct.setUserDeptId(30L); + secondProduct.setProductId(200L); + secondProduct.setProductName("单品"); + secondProduct.setPayCount(4L); + secondProduct.setTotalPrice(500L); + + DailyMenuAppVO menu = new DailyMenuAppVO(); + menu.setSpuId(100L); + OnlineSpuComboItemVO first = new OnlineSpuComboItemVO(); + first.setName("主食"); + first.setQuantity(1); + first.setPrice(100); + OnlineSpuComboItemVO second = new OnlineSpuComboItemVO(); + second.setName("配菜"); + second.setQuantity(2); + second.setPrice(100); + menu.setComboItems(List.of(first, second)); + + MchManageVo merchantVo = new MchManageVo(); + merchantVo.setId(10L); + merchantVo.setName("测试食堂"); + SysDept dept = new SysDept(); + dept.setDeptId(20L); + dept.setName("测试部门"); + SysDept secondDept = new SysDept(); + secondDept.setDeptId(30L); + secondDept.setName("第二部门"); + SysDictItem dictItem = new SysDictItem(); + dictItem.setItemValue("1"); + dictItem.setLabel("早餐"); + + when(hardCarMapper.listApptExportSummary(any())).thenReturn(List.of(summary)); + when(hardCarMapper.listApptExportMerchant(any())).thenReturn(List.of(merchant, secondMerchant)); + when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, secondProduct)); + when(remoteDailyMenuService.getCanteenList(date.toString(), 1, null, 10L)).thenReturn(R.ok(List.of(menu))); + when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchantVo))); + when(remoteDeptService.getDeptByDeptIds(List.of(20L, 30L))) + .thenReturn(R.ok(java.util.Map.of(20L, dept, 30L, secondDept))); + when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of(dictItem))); + + List rows = ReflectionTestUtils.invokeMethod(service, "buildProductRows", + null, date); + + assertEquals(6, rows.size()); + assertEquals(7L, rows.get(0).getSummaryPayCount()); + assertEquals("8.00", rows.get(0).getSummaryTotalPrice().toPlainString()); + assertEquals(3L, rows.get(1).getMerchantPayCount()); + assertEquals("3.00", rows.get(1).getMerchantTotalPrice().toPlainString()); + assertEquals(1L, rows.get(2).getProductCount()); + assertEquals(2L, rows.get(3).getProductCount()); + assertEquals(4L, rows.get(4).getMerchantPayCount()); + assertEquals("5.00", rows.get(4).getMerchantTotalPrice().toPlainString()); + assertEquals(4L, rows.get(5).getProductCount()); + assertEquals("早餐", rows.get(0).getSummaryHourType()); + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java index fcd0e1fd..ca66205a 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java @@ -23,6 +23,7 @@ import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -37,7 +38,7 @@ import java.util.List; @Data @Schema(description = "系统用户传输对象") @EqualsAndHashCode(callSuper = true) -public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface { +public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { /** * 角色ID diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index 2d9f122d..d71761cf 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -59,7 +59,7 @@ public interface RemoteUserService { /** * 按当前登录人的数据权限分页查询用户基础信息和身份资料。 * - *

该接口不查询角色、岗位、人脸和审批流程,适用于只需要用户、部门及身份展示信息的业务列表。

+ *

该接口不返回角色、岗位、人脸和审批资料,但支持按人脸录入状态筛选,适用于只需要用户、部门及身份展示信息的业务列表。

* * @param current 当前页码,从 1 开始 * @param size 每页用户数量 diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java new file mode 100644 index 00000000..e2d05c1e --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java @@ -0,0 +1,71 @@ +package com.bsd.pjcloud.admin.api.vo.base; + +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; + +import java.util.List; + +/** + * 用户身份查询条件公共契约。 + * + *

管理端用户查询和跨服务业务查询都通过这组字段筛选用户身份及人脸录入状态。身份类型使用 UPMS + * 的持久化/接口编码;学生、老师扩展资料中的字段沿用各自 DTO 的查询语义。

+ */ +public interface BaseUserIdentityQueryInterface { + + /** + * 获取用户身份类型筛选集合。 + * + * @return UPMS 身份协议编码集合;为空时不限制身份类型 + */ + List getIdentityTypes(); + + /** + * 设置用户身份类型筛选集合。 + * + * @param identityTypes UPMS 身份协议编码集合;为空时不限制身份类型 + */ + void setIdentityTypes(List identityTypes); + + /** + * 获取学生身份扩展筛选条件。 + * + * @return 学号、学校、班级或监护人手机号条件;对象为空或字段均为空时不限制学生资料 + */ + SysUserStudentDto getStudentInfo(); + + /** + * 设置学生身份扩展筛选条件。 + * + * @param studentInfo 学生身份查询条件;为空或字段均为空时不限制学生资料 + */ + void setStudentInfo(SysUserStudentDto studentInfo); + + /** + * 获取老师身份扩展筛选条件。 + * + * @return 老师学号条件;对象为空或学号为空时不限制老师资料 + */ + SysUserTeacherDto getTeacherInfo(); + + /** + * 设置老师身份扩展筛选条件。 + * + * @param teacherInfo 老师身份查询条件;为空或学号为空时不限制老师资料 + */ + void setTeacherInfo(SysUserTeacherDto teacherInfo); + + /** + * 获取人脸录入状态筛选条件。 + * + * @return {@code 1} 表示存在未删除人脸记录,{@code 0} 表示不存在,空值表示不限制 + */ + String getHasFace(); + + /** + * 设置人脸录入状态筛选条件。 + * + * @param hasFace {@code 1} 表示已录入,{@code 0} 表示未录入,空值表示全部 + */ + void setHasFace(String hasFace); +} 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 b70bff65..5c8dc258 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 @@ -334,7 +334,7 @@ public class SysUserController { /** * 分页查询当前操作者数据权限内的用户基础信息和身份资料。 * - *

该接口供无需角色、岗位、人脸和审批信息的业务列表调用,避免复用管理端完整用户分页产生额外查询。

+ *

该接口供无需返回角色、岗位、人脸和审批信息、但可能按人脸录入状态筛选的业务列表调用,避免复用管理端完整用户分页产生额外查询。

* * @param page 分页参数 * @param 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 d6d4f9c6..3bebaef5 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 @@ -370,7 +370,7 @@ public class SysUserServiceImpl extends ServiceImpl impl * 分页查询用户基础信息并批量补齐身份资料。 * *

预约等业务列表只需要用户、部门和身份信息,因此这里使用无嵌套角色、岗位映射的专用 SQL, - * 同时跳过人脸和审批远程查询。

+ * 同时不返回人脸资料和审批信息,但保留人脸录入状态筛选。

* * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 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 c747579e..bc0634f3 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 @@ -376,6 +376,23 @@ + + + + + AND EXISTS ( + SELECT 1 FROM sys_user_face f + WHERE f.user_id = u.user_id AND f.del_flag = '0' + ) + + + AND NOT EXISTS ( + SELECT 1 FROM sys_user_face f + WHERE f.user_id = u.user_id AND f.del_flag = '0' + ) + + + ORDER BY u.create_time DESC, u.user_id DESC From d18167e0840ebb7c44d62f760bb3e65d3be7b09b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Sun, 23 Aug 2026 18:49:40 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat:=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A2=84=E7=BA=A6=E6=97=B6=E9=97=B4=20feat:?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A3=9F=E5=A0=82=E8=8F=9C=E5=93=81=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E7=BB=9F=E8=AE=A1=E5=AF=BC=E5=87=BA=20feat:=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=AE=A2=E5=8D=95=E8=AF=86=E5=88=AB=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260823_add_hard_order_evidence_image.sql | 44 ++ db/800pjcloudx_hardware.sql | 45 +- .../order/HardOrderEvidenceRoleEnum.java | 58 ++ .../order/HardOrderEvidenceStatusEnum.java | 58 ++ .../order/HardOrderEvidenceTypeEnum.java | 53 ++ .../hard/api/dto/hard/HardCreateOrderDto.java | 15 + .../order/HardOrderEvidenceMetadataDto.java | 127 ++++ .../dto/order/HardOrderEvidenceSubmitDto.java | 46 ++ .../dto/order/HardOrderEvidenceUploadDto.java | 31 + .../order/HardOrderEvidenceImageEntity.java | 208 ++++++ .../vo/order/HardOrderEvidenceImageVo.java | 33 + .../vo/order/HardOrderEvidenceResolveVo.java | 34 + .../HardOrderEvidenceUploadResultVo.java | 50 ++ .../hard/api/vo/order/HardOrderVo.java | 5 + .../excel/HardCanteenProductApptExcelVo.java | 16 +- .../HardOrderEvidenceImageController.java | 85 +++ .../controller/order/HardOrderController.java | 2 +- ...dCanteenProductApptExportMergeHandler.java | 9 +- .../order/HardOrderEvidenceImageMapper.java | 47 ++ .../hard/mapper/order/HardOrderMapper.java | 35 + .../pjcloud/hard/service/HardCarService.java | 8 + .../HardOrderEvidenceImageService.java | 65 ++ .../hard/service/HardOrderService.java | 16 + ...HardCanteenApptTotalExportServiceImpl.java | 50 +- .../impl/order/HardCarServiceImpl.java | 11 + .../HardOrderEvidenceImageServiceImpl.java | 657 ++++++++++++++++++ .../impl/order/HardOrderServiceImpl.java | 144 +++- .../mapper/order/HardOrderMapper.xml | 143 +++- ...teenProductApptExportMergeHandlerTest.java | 45 +- ...CanteenApptTotalExportServiceImplTest.java | 46 +- .../api/feign/RemoteUserFaceService.java | 14 + .../api/vo/SysUserFaceEvidenceSourceVo.java | 38 + .../controller/SysUserFaceController.java | 11 + .../admin/mapper/SysUserFaceMapper.java | 23 + .../admin/service/SysUserFaceService.java | 10 + .../service/impl/SysUserFaceServiceImpl.java | 10 + 36 files changed, 2215 insertions(+), 77 deletions(-) create mode 100644 db/20260823_add_hard_order_evidence_image.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java diff --git a/db/20260823_add_hard_order_evidence_image.sql b/db/20260823_add_hard_order_evidence_image.sql new file mode 100644 index 00000000..d0561bd3 --- /dev/null +++ b/db/20260823_add_hard_order_evidence_image.sql @@ -0,0 +1,44 @@ +-- 订单识别证据图片从表。 +-- 本表独立于 hard_order,退款、撤销和订单逻辑删除均不自动清理证据文件或证据记录。 +CREATE TABLE `pjcloudx_hardware`.`hard_order_evidence_image` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键', + `order_id` bigint(20) NOT NULL COMMENT '订单主键,来源hard_order.id', + `android_order_code` varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照', + `evidence_type` varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION', + `evidence_role` varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE', + `sequence_no` int(11) NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始', + `user_id` bigint(20) DEFAULT NULL COMMENT '识别用户主键快照', + `face_sample_id` varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照', + `reference_image_url` varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址', + `similarity` decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1', + `similarity_threshold` decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1', + `hard_id` bigint(20) NOT NULL COMMENT '产生证据的设备主键', + `captured_at` datetime DEFAULT NULL COMMENT '终端实际采集时间', + `sys_file_id` bigint(20) DEFAULT NULL COMMENT '文件中心sys_file记录主键', + `storage_file_name` varchar(255) DEFAULT NULL COMMENT '文件中心对象名', + `image_url` varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址', + `image_hash` char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希', + `image_size` bigint(20) DEFAULT NULL COMMENT '实际文件大小,单位字节', + `image_width` int(11) DEFAULT NULL COMMENT '实际图片宽度,单位像素', + `image_height` int(11) DEFAULT NULL COMMENT '实际图片高度,单位像素', + `status` varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE', + `failure_reason` varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径', + `app_version` varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除', + `tenant_id` bigint(20) NOT NULL COMMENT '租户主键', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hard_order_evidence_position` + (`tenant_id`, `order_id`, `evidence_type`, `evidence_role`, `sequence_no`, `del_flag`), + KEY `idx_hard_order_evidence_android_order` + (`tenant_id`, `hard_id`, `android_order_code`, `del_flag`), + KEY `idx_hard_order_evidence_status` + (`tenant_id`, `status`, `update_time`, `del_flag`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT = '硬件订单识别证据图片'; diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql index 0c2a37f1..173d7d84 100644 --- a/db/800pjcloudx_hardware.sql +++ b/db/800pjcloudx_hardware.sql @@ -301,6 +301,50 @@ CREATE TABLE hard_order PRIMARY KEY (id) ) ENGINE = InnoDB COMMENT ='订单表'; +-- ---------------------------- +-- Table structure for hard_order_evidence_image +-- ---------------------------- +DROP TABLE IF EXISTS `hard_order_evidence_image`; +CREATE TABLE hard_order_evidence_image +( + id bigint NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键', + order_id bigint NOT NULL COMMENT '订单主键,来源hard_order.id', + android_order_code varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照', + evidence_type varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION', + evidence_role varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE', + sequence_no int NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始', + user_id bigint DEFAULT NULL COMMENT '识别用户主键快照', + face_sample_id varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照', + reference_image_url varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址', + similarity decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1', + similarity_threshold decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1', + hard_id bigint NOT NULL COMMENT '产生证据的设备主键', + captured_at datetime DEFAULT NULL COMMENT '终端实际采集时间', + sys_file_id bigint DEFAULT NULL COMMENT '文件中心sys_file记录主键', + storage_file_name varchar(255) DEFAULT NULL COMMENT '文件中心对象名', + image_url varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址', + image_hash char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希', + image_size bigint DEFAULT NULL COMMENT '实际文件大小,单位字节', + image_width int DEFAULT NULL COMMENT '实际图片宽度,单位像素', + image_height int DEFAULT NULL COMMENT '实际图片高度,单位像素', + status varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE', + failure_reason varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径', + app_version varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本', + create_by varchar(64) DEFAULT NULL COMMENT '创建人', + create_time datetime DEFAULT NULL COMMENT '创建时间', + update_by varchar(64) DEFAULT NULL COMMENT '修改人', + update_time datetime DEFAULT NULL COMMENT '修改时间', + del_flag char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除', + tenant_id bigint NOT NULL COMMENT '租户主键', + PRIMARY KEY (id), + UNIQUE KEY uk_hard_order_evidence_position + (tenant_id, order_id, evidence_type, evidence_role, sequence_no, del_flag), + KEY idx_hard_order_evidence_android_order + (tenant_id, hard_id, android_order_code, del_flag), + KEY idx_hard_order_evidence_status + (tenant_id, status, update_time, del_flag) +) ENGINE = InnoDB COMMENT ='硬件订单识别证据图片'; + -- ---------------------------- -- Table structure for hard_order_address @@ -724,4 +768,3 @@ ALTER TABLE hard_xfj_log ADD INDEX idx_start_time (start_time); ALTER TABLE hard_xfj_log ADD INDEX idx_end_time (end_time); - diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java new file mode 100644 index 00000000..64e0846f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 订单证据图片角色,编码用于唯一位置约束和跨端展示。 + */ +@Getter +public enum HardOrderEvidenceRoleEnum { + + /** + * 人脸库中被命中的后台参考人脸,由服务端从受信文件中心复制为订单快照。 + */ + FACE_REFERENCE("FACE_REFERENCE", "后台参考人脸"), + + /** + * 终端实际通过比对的现场人脸裁剪图,由终端异步上传。 + */ + FACE_LIVE("FACE_LIVE", "现场识别人脸"), + + /** + * 菜谱识别的现场餐盘图片预留角色,当前版本不创建该类证据。 + */ + MENU_LIVE("MENU_LIVE", "餐盘现场图"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据图片角色。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceRoleEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析图片角色,未知编码返回 {@code null}。 + * + * @param code 数据库或接口编码 + * @return 对应角色;未知时返回 {@code null} + */ + public static HardOrderEvidenceRoleEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java new file mode 100644 index 00000000..f74b4c13 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 服务端订单证据状态,表达证据是否仍待补传、已经可查看或已永久缺失。 + */ +@Getter +public enum HardOrderEvidenceStatusEnum { + + /** + * 订单已记录识别元数据,但图片仍等待终端上传或服务端复制。 + */ + PENDING("PENDING", "待补传"), + + /** + * 文件中心已经保存订单专属图片并可在订单详情中查看。 + */ + AVAILABLE("AVAILABLE", "可查看"), + + /** + * 本地文件过期、损坏或参考图片变化,已确认无法继续补齐。 + */ + UNAVAILABLE("UNAVAILABLE", "永久缺失"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据状态。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceStatusEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析证据状态,未知编码返回 {@code null}。 + * + * @param code 数据库或接口编码 + * @return 对应状态;未知时返回 {@code null} + */ + public static HardOrderEvidenceStatusEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java new file mode 100644 index 00000000..53101e28 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java @@ -0,0 +1,53 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 订单证据业务类型,编码同时用于数据库、终端 SQLite 和跨端接口,禁止随展示文案调整。 + */ +@Getter +public enum HardOrderEvidenceTypeEnum { + + /** + * 人脸识别证据,当前版本保存后台参考人脸和现场识别人脸。 + */ + FACE_RECOGNITION("FACE_RECOGNITION", "人脸识别"), + + /** + * 菜谱识别证据预留值,当前版本不执行菜谱识别或图片采集。 + */ + MENU_RECOGNITION("MENU_RECOGNITION", "菜谱识别"); + + /** + * 数据库和接口使用的稳定编码。 + */ + private final String code; + + /** + * PC 订单详情使用的中文名称。 + */ + private final String description; + + /** + * 构造订单证据业务类型。 + * + * @param code 数据库与跨端协议编码 + * @param description 中文展示名称 + */ + HardOrderEvidenceTypeEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 按协议编码解析证据类型,未知编码返回 {@code null} 供调用方拒绝非法请求。 + * + * @param code 数据库或接口编码 + * @return 对应类型;未知时返回 {@code null} + */ + public static HardOrderEvidenceTypeEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java index f3169e9e..f0be9f4d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java @@ -4,7 +4,9 @@ import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -75,4 +77,17 @@ public class HardCreateOrderDto { @Schema(description = "离线支付是否成功") private Boolean offlinePaySuccess; + + /** + * 当前终端应用版本,仅用于订单证据问题追溯,不参与订单计价和支付。 + */ + @Schema(description = "终端应用版本") + private String appVersion; + + /** + * 人脸支付成功后需要挂接的证据元数据;旧终端不传时保持订单创建协议兼容。 + */ + @Schema(description = "订单识别证据元数据") + @Valid + private List evidenceMetadataList; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java new file mode 100644 index 00000000..7d2f2895 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java @@ -0,0 +1,127 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单创建和证据补传共用的识别元数据,不包含图片字节和服务端文件信息。 + */ +@Data +@Schema(description = "订单识别证据元数据") +public class HardOrderEvidenceMetadataDto { + + /** + * 证据业务类型,使用 HardOrderEvidenceTypeEnum 的稳定编码。 + */ + @NotBlank(message = "证据类型不能为空") + @Size(max = 32, message = "证据类型不能超过32个字符") + @Schema(description = "证据类型") + private String evidenceType; + + /** + * 图片角色,使用 HardOrderEvidenceRoleEnum 的稳定编码。 + */ + @NotBlank(message = "证据图片角色不能为空") + @Size(max = 32, message = "证据图片角色不能超过32个字符") + @Schema(description = "证据图片角色") + private String evidenceRole; + + /** + * 同类型同角色下的图片序号,从0开始;人脸证据固定为0,菜谱识别可递增。 + */ + @NotNull(message = "证据图片序号不能为空") + @Min(value = 0, message = "证据图片序号不能小于0") + @Max(value = 99, message = "证据图片序号不能大于99") + @Schema(description = "证据图片序号") + private Integer sequenceNo; + + /** + * 识别用户主键;未识别到平台用户时为空,不能由终端使用其他用户替代。 + */ + @Schema(description = "识别用户主键") + private Long userId; + + /** + * 虹软搜索样本编号;仅人脸识别角色使用,服务端据此校验后台参考人脸。 + */ + @Size(max = 64, message = "人脸样本编号不能超过64个字符") + @Schema(description = "人脸样本编号") + private String faceSampleId; + + /** + * 识别时终端人脸库记录的参考图片地址,仅作为服务端复制参考图的线索,不直接作为可访问地址返回。 + */ + @Size(max = 512, message = "参考人脸地址不能超过512个字符") + @Schema(description = "参考人脸地址") + private String referenceImageUrl; + + /** + * 识别时终端人脸快照对应的后台更新时间;旧终端允许为空,新终端用于识别同地址内容替换。 + */ + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "参考人脸来源版本") + private LocalDateTime referenceImageVersion; + + /** + * 识别时实际相似度,取值0到1;为空表示当前证据类型不使用相似度。 + */ + @DecimalMin(value = "0", message = "相似度不能小于0") + @DecimalMax(value = "1", message = "相似度不能大于1") + @Schema(description = "识别相似度") + private BigDecimal similarity; + + /** + * 识别时终端生效的相似度阈值快照,取值0到1。 + */ + @DecimalMin(value = "0", message = "相似度阈值不能小于0") + @DecimalMax(value = "1", message = "相似度阈值不能大于1") + @Schema(description = "相似度阈值快照") + private BigDecimal similarityThreshold; + + /** + * 终端实际完成识别的本地时间;为空时服务端仅保留订单创建时间。 + */ + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @Schema(description = "终端采集时间") + private LocalDateTime capturedAt; + + /** + * 终端按原始证据文件计算的 SHA-256;图片尚未生成或永久缺失时允许为空。 + */ + @Pattern(regexp = "(?i)^[0-9a-f]{64}$", message = "图片哈希必须为64位SHA-256") + @Schema(description = "图片SHA-256") + private String imageHash; + + /** + * 终端证据文件大小,单位字节;参考图无法读取或文件尚未生成时允许为空。 + */ + @Min(value = 0, message = "图片大小不能小于0") + @Schema(description = "图片大小,单位字节") + private Long imageSize; + + /** + * 终端解码后的图片宽度,单位像素。 + */ + @Min(value = 1, message = "图片宽度必须大于0") + @Schema(description = "图片宽度,单位像素") + private Integer imageWidth; + + /** + * 终端解码后的图片高度,单位像素。 + */ + @Min(value = 1, message = "图片高度必须大于0") + @Schema(description = "图片高度,单位像素") + private Integer imageHeight; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java new file mode 100644 index 00000000..99cd0168 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端提交订单证据元数据或永久缺失结果的请求。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单证据元数据提交请求") +public class HardOrderEvidenceSubmitDto extends HardOrderEvidenceMetadataDto { + + /** + * 终端本地稳定订单号,用于接口响应丢失后的服务端订单对账。 + */ + @NotBlank(message = "终端订单号不能为空") + @Size(max = 128, message = "终端订单号不能超过128个字符") + @Schema(description = "终端订单号") + private String androidOrderCode; + + /** + * 终端请求写入的目标状态,仅允许 PENDING 或 UNAVAILABLE;现场图片上传成功由服务端改为 AVAILABLE。 + */ + @NotBlank(message = "证据状态不能为空") + @Size(max = 32, message = "证据状态不能超过32个字符") + @Schema(description = "证据状态") + private String status; + + /** + * 永久缺失原因;状态为 UNAVAILABLE 时必填,禁止包含本地完整敏感路径。 + */ + @Size(max = 500, message = "失败原因不能超过500个字符") + @Schema(description = "永久缺失原因") + private String failureReason; + + /** + * 产生识别证据的终端应用版本,用于问题追溯。 + */ + @Size(max = 64, message = "应用版本不能超过64个字符") + @Schema(description = "终端应用版本") + private String appVersion; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java new file mode 100644 index 00000000..1b02f844 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端上传订单现场证据图片时随 multipart 一并提交的元数据。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单现场证据上传请求") +public class HardOrderEvidenceUploadDto extends HardOrderEvidenceMetadataDto { + + /** + * 终端本地稳定订单号,服务端通过该编号解析真实订单主键。 + */ + @NotBlank(message = "终端订单号不能为空") + @Size(max = 128, message = "终端订单号不能超过128个字符") + @Schema(description = "终端订单号") + private String androidOrderCode; + + /** + * 产生识别证据的终端应用版本,用于问题追溯。 + */ + @Size(max = 64, message = "应用版本不能超过64个字符") + @Schema(description = "终端应用版本") + private String appVersion; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java new file mode 100644 index 00000000..77bd85aa --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java @@ -0,0 +1,208 @@ +package com.bsd.pjcloud.hard.api.entity.order; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单识别证据图片,从属于 hard_order 并长期保存争议处理所需的文件快照和识别元数据。 + */ +@Data +@TenantTable +@TableName("hard_order_evidence_image") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据图片") +public class HardOrderEvidenceImageEntity extends Model { + + /** + * 证据记录自增主键。 + */ + @TableId(type = IdType.AUTO) + @Schema(description = "证据记录主键") + private Long id; + + /** + * 服务端订单主键,来源于 hard_order.id。 + */ + @Schema(description = "订单主键") + private Long orderId; + + /** + * 终端本地稳定订单号快照,来源于 hard_order.android_order_code。 + */ + @Schema(description = "终端订单号") + private String androidOrderCode; + + /** + * 证据业务类型,保存 HardOrderEvidenceTypeEnum.code。 + */ + @Schema(description = "证据类型") + private String evidenceType; + + /** + * 图片角色,保存 HardOrderEvidenceRoleEnum.code。 + */ + @Schema(description = "证据图片角色") + private String evidenceRole; + + /** + * 同一订单、类型和角色下的图片序号,从0开始。 + */ + @Schema(description = "证据图片序号") + private Integer sequenceNo; + + /** + * 识别用户主键快照;无法识别到平台用户时允许为空。 + */ + @Schema(description = "识别用户主键") + private Long userId; + + /** + * 虹软人脸样本编号快照,来源于 sys_user_face.auto_code。 + */ + @Schema(description = "人脸样本编号") + private String faceSampleId; + + /** + * 识别时终端命中的后台参考图片地址快照,仅用于与 UPMS 权威记录比对,不直接作为展示地址。 + */ + @Schema(description = "参考人脸来源地址快照") + private String referenceImageUrl; + + /** + * 终端识别相似度快照,取值0到1。 + */ + @Schema(description = "识别相似度") + private BigDecimal similarity; + + /** + * 终端识别相似度阈值快照,取值0到1。 + */ + @Schema(description = "识别相似度阈值") + private BigDecimal similarityThreshold; + + /** + * 产生证据的服务端设备主键,来源于 hard_order.hard_id。 + */ + @Schema(description = "设备主键") + private Long hardId; + + /** + * 终端实际完成识别的本地时间,用于与订单时间交叉核验。 + */ + @Schema(description = "终端采集时间") + private LocalDateTime capturedAt; + + /** + * 文件中心 sys_file 主键;状态非 AVAILABLE 时允许为空。 + */ + @Schema(description = "文件中心记录主键") + private Long sysFileId; + + /** + * 文件中心对象名,仅供服务端读取或下载,不由终端提交。 + */ + @Schema(description = "文件中心对象名") + private String storageFileName; + + /** + * PC 可使用统一请求层访问的文件相对地址。 + */ + @Schema(description = "图片访问地址") + private String imageUrl; + + /** + * 实际保存文件的 SHA-256,用于幂等和防止证据被静默替换。 + */ + @Schema(description = "图片SHA-256") + private String imageHash; + + /** + * 实际保存文件大小,单位字节。 + */ + @Schema(description = "图片大小,单位字节") + private Long imageSize; + + /** + * 实际解码图片宽度,单位像素。 + */ + @Schema(description = "图片宽度,单位像素") + private Integer imageWidth; + + /** + * 实际解码图片高度,单位像素。 + */ + @Schema(description = "图片高度,单位像素") + private Integer imageHeight; + + /** + * 证据状态,保存 HardOrderEvidenceStatusEnum.code。 + */ + @Schema(description = "证据状态") + private String status; + + /** + * 永久缺失或服务端复制失败的摘要,不保存终端完整文件路径。 + */ + @Schema(description = "失败原因") + private String failureReason; + + /** + * 产生证据的终端应用版本快照。 + */ + @Schema(description = "终端应用版本") + private String appVersion; + + /** + * 创建人,由 MyBatis 填充器写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** + * 证据占位记录创建时间,由 MyBatis 填充器写入。 + */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** + * 修改人,由 MyBatis 填充器在状态变化时写入。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** + * 最近一次状态、文件或错误信息更新时间。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** + * 逻辑删除标记;订单退款、取消和删除不会联动改变该值。 + */ + @TableLogic + @TableField(fill = FieldFill.INSERT) + @Schema(description = "删除标记") + private String delFlag; + + /** + * 租户主键,由当前请求租户上下文写入并由数据层自动隔离。 + */ + @Schema(description = "租户主键") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java new file mode 100644 index 00000000..ee164957 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * PC 订单详情使用的订单证据图片,附带稳定编码对应的中文名称。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据图片详情") +public class HardOrderEvidenceImageVo extends HardOrderEvidenceImageEntity { + + /** + * 证据类型中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据类型名称") + private String evidenceTypeName; + + /** + * 图片角色中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据图片角色名称") + private String evidenceRoleName; + + /** + * 证据状态中文名称,未知历史编码时回退原编码。 + */ + @Schema(description = "证据状态名称") + private String statusName; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java new file mode 100644 index 00000000..4c022089 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 终端按本地订单号对账时返回的服务端订单与证据快照。 + */ +@Data +@Builder +@Schema(description = "订单证据对账结果") +public class HardOrderEvidenceResolveVo { + + /** + * 服务端订单主键。 + */ + @Schema(description = "服务端订单主键") + private Long orderId; + + /** + * 服务端订单状态,终端仅在现有支付流程确认成功后上传证据。 + */ + @Schema(description = "订单状态") + private String orderStatus; + + /** + * 当前服务端已登记的证据位置和状态,用于终端幂等跳过成功项。 + */ + @Schema(description = "证据列表") + private List evidenceImages; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java new file mode 100644 index 00000000..43388162 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * 终端提交元数据、复制参考图或上传现场图后的幂等处理结果。 + */ +@Data +@Builder +@Schema(description = "订单证据处理结果") +public class HardOrderEvidenceUploadResultVo { + + /** + * 服务端证据记录主键。 + */ + @Schema(description = "证据记录主键") + private Long evidenceId; + + /** + * 服务端订单主键。 + */ + @Schema(description = "订单主键") + private Long orderId; + + /** + * 处理后的证据状态。 + */ + @Schema(description = "证据状态") + private String status; + + /** + * AVAILABLE 状态下的图片访问地址,其他状态允许为空。 + */ + @Schema(description = "图片访问地址") + private String imageUrl; + + /** + * 是否实际上传或复制了新文件;幂等命中时为 false。 + */ + @Schema(description = "是否新写入文件") + private Boolean uploaded; + + /** + * 面向终端同步日志的简短处理结果。 + */ + @Schema(description = "处理结果说明") + private String message; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 95cf42a0..753f2b7c 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -102,4 +102,9 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac * 当前访问者是否可以查看预约核销码。 */ private Boolean canViewReserveCode; + + /** + * 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。 + */ + private List evidenceImages; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java index a7e345f0..bdf91045 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java @@ -11,7 +11,8 @@ import java.time.LocalDate; /** * 食堂预约菜品统计自定义导出行。 * - *

一组日期和餐段按汇总行、商户部门行、商品明细行组织;不同层级只填写所属列,配合自定义合并处理器形成树形报表。

+ *

一组日期和餐段按汇总行、商户部门行、商品明细行组织;商品统计在同一商户部门块内按名称合并套餐展开后的明细, + * 不同层级只填写所属列,配合自定义合并处理器形成树形报表。

*/ @Data public class HardCanteenProductApptExcelVo { @@ -87,6 +88,19 @@ public class HardCanteenProductApptExcelVo { @NumberFormat("#,##0.00") private BigDecimal productTotalPrice; + /** 商品统计中的商品名称,同一商户部门内按规范化后的名称合并。 */ + @ExcelProperty({"商品统计", "商品名称"}) + private String statisticProductName; + + /** 商品统计中的商品总数量,按退款扣减及套餐展开后的有效明细累计。 */ + @ExcelProperty({"商品统计", "商品总数量"}) + private Long statisticProductCount; + + /** 商品统计中的商品总价,单位为元,按退款扣减及套餐展开后的有效明细累计。 */ + @ExcelProperty({"商品统计", "商品总价格"}) + @NumberFormat("#,##0.00") + private BigDecimal statisticProductTotalPrice; + /** 导出行层级,仅供合并处理器识别,不写入 Excel。 */ @ExcelIgnore private String rowLevel; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java new file mode 100644 index 00000000..a3d26519 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java @@ -0,0 +1,85 @@ +package com.bsd.pjcloud.hard.controller.hard; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 收银终端订单识别证据接口,使用统一 Token、租户头和 DEVICE-ID 校验调用方身份。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-order-evidence") +@Tag(description = "hard-order-evidence", name = "订单识别证据") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardOrderEvidenceImageController { + + /** + * 订单识别证据业务服务。 + */ + private final HardOrderEvidenceImageService hardOrderEvidenceImageService; + + /** + * 对账终端订单并返回服务端证据状态。 + * + * @param hardId 当前绑定设备主键 + * @param androidOrderCode 终端本地订单号 + * @return 服务端订单和证据列表 + */ + @GetMapping("/ad/resolve") + @Operation(summary = "按终端订单号对账识别证据") + public R resolve(@RequestHeader("DEVICE-ID") Long hardId, + @RequestParam String androidOrderCode) { + return R.ok(hardOrderEvidenceImageService.resolve(hardId, androidOrderCode)); + } + + /** + * 登记证据元数据、复制参考图或报告终端图片永久缺失。 + * + * @param hardId 当前绑定设备主键 + * @param dto 证据元数据和目标状态 + * @return 处理后的证据状态 + */ + @PostMapping("/ad/submit") + @Operation(summary = "提交订单识别证据元数据") + public R submit(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardOrderEvidenceSubmitDto dto) { + return R.ok(hardOrderEvidenceImageService.submit(hardId, dto)); + } + + /** + * 上传现场识别图片,服务端校验图片内容后写入订单专属文件目录。 + * + * @param hardId 当前绑定设备主键 + * @param dto 订单与识别元数据 + * @param file JPEG 或 PNG 现场图片 + * @return 幂等上传结果 + */ + @PostMapping(value = "/ad/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "上传订单现场识别图片") + public R upload(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @ModelAttribute HardOrderEvidenceUploadDto dto, + @RequestPart("file") MultipartFile file) { + return R.ok(hardOrderEvidenceImageService.upload(hardId, dto, file)); + } +} 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 d3780447..7bbedc38 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 @@ -621,7 +621,7 @@ public class HardOrderController { @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") public R getById(@PathVariable("id") Long id) { - return R.ok(hardwareOrderService.join(id)); + return R.ok(hardwareOrderService.joinWithEvidence(id)); } @Operation(summary = "离线订单手动支付", description = "离线订单手动支付") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java index 48cf38fc..b8c95bab 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java @@ -23,7 +23,8 @@ import java.util.Map; /** * 食堂预约菜品统计导出合并处理器。 * - *

数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列。 + *

数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列; + * 第 17 至 19 列展示同一商户部门内按名称合并后的商品统计。 * Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。

*/ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler { @@ -38,7 +39,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler private static final int DATA_START_ROW = 2; /** 导出列总数,用于逐行应用居中样式。 */ - private static final int COLUMN_COUNT = 17; + private static final int COLUMN_COUNT = 20; /** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */ private static final short HEAD_FONT_SIZE = 10; @@ -55,7 +56,9 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler /** * 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。 */ - private static final int[] COLUMN_WIDTHS = {12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11}; + private static final int[] COLUMN_WIDTHS = { + 12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12 + }; /** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */ private final Map centeredStyles = new HashMap<>(); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java new file mode 100644 index 00000000..c00bf28f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java @@ -0,0 +1,47 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 订单识别证据 Mapper,所有查询由租户拦截器自动限定当前租户。 + */ +@Mapper +public interface HardOrderEvidenceImageMapper extends PjcloudBaseMapper { + + /** + * 按订单读取未逻辑删除的证据,稳定按类型、角色、序号和主键排序。 + * + * @param orderId 服务端订单主键 + * @return 当前租户下的订单证据列表 + */ + default List listByOrderId(Long orderId) { + return selectList(new LambdaQueryWrapper() + .eq(HardOrderEvidenceImageEntity::getOrderId, orderId) + .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceType) + .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceRole) + .orderByAsc(HardOrderEvidenceImageEntity::getSequenceNo) + .orderByAsc(HardOrderEvidenceImageEntity::getId)); + } + + /** + * 查询订单中的唯一证据位置,用于上传幂等和内容冲突检查。 + * + * @param orderId 服务端订单主键 + * @param evidenceType 证据类型稳定编码 + * @param evidenceRole 图片角色稳定编码 + * @param sequenceNo 同角色图片序号 + * @return 已存在的证据;不存在时返回 {@code null} + */ + default HardOrderEvidenceImageEntity findPosition(Long orderId, String evidenceType, String evidenceRole, Integer sequenceNo) { + return selectOne(new LambdaQueryWrapper() + .eq(HardOrderEvidenceImageEntity::getOrderId, orderId) + .eq(HardOrderEvidenceImageEntity::getEvidenceType, evidenceType) + .eq(HardOrderEvidenceImageEntity::getEvidenceRole, evidenceRole) + .eq(HardOrderEvidenceImageEntity::getSequenceNo, sequenceNo)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index c87698d7..f7e54249 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -130,8 +130,43 @@ public interface HardOrderMapper extends PjcloudBaseMapper { return selectListByScope(commonWrapper(dto), DataScope.of()); } + /** + * 按订单筛选条件和数据范围分页查询订单详情,并保持创建时间或更新时间倒序。 + * + * @param page 分页参数;App 可见性查询会由服务层指定对应的轻量统计语句 + * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件 + * @param dataScope 后台数据范围;App 查询传 {@code null} + * @return 当前页订单详情及符合相同筛选条件的总数 + */ Page scopePageList(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 先按 App 可见范围和全部筛选条件分页读取订单主键,避免展示表关联参与全量排序。 + * + * @param page 主键分页参数,排序规则与订单列表保持一致 + * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件 + * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null} + * @return 当前页有序订单主键及符合条件的总数 + */ + Page appVisibilityPageIds(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 按已分页的订单主键批量读取展示详情,仅关联当前页设备、最新售后单和付款人。 + * + * @param orderIds 当前页订单主键集合,调用方保证非空且已完成租户和可见范围过滤 + * @return 当前租户内存在的订单详情;SQL 不承诺返回顺序,由服务层按主键页顺序重排 + */ + List listAppPageDetails(@Param("orderIds") List orderIds); + + /** + * 统计 App 当前访问者可见的订单数量,不执行列表展示字段所需的多表关联。 + * + * @param dto 订单基础筛选、设备类型、最新售后单号及 App 可见范围条件 + * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null} + * @return 当前租户内符合全部筛选条件的未删除订单数量 + */ + Long appVisibilityCount(@Param("dto") HardOrderDto dto, DataScope dataScope); + List scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope); /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java index fe8e1f9f..356b7b78 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java @@ -25,6 +25,14 @@ public interface HardCarService extends IService { List getByParentCarId(Long parentCarId); + /** + * 按父购物车主键批量查询未删除的购物车商品快照,供订单分页结果一次性装配商品列表。 + * + * @param parentCarIds 父购物车主键集合,空集合不发起数据库查询 + * @return 按父购物车主键和商品主键排序的商品快照 + */ + List getByParentCarIds(List parentCarIds); + List mchDetail(Long merchantId, Long userId, HardTypeEnum hardType); Integer computerCarTotalPrice(@NotNull HardTypeEnum sysType, @NotNull HardOrderTypeEnum orderType, @NotEmpty List list); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java new file mode 100644 index 00000000..f46577a8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java @@ -0,0 +1,65 @@ +package com.bsd.pjcloud.hard.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 订单识别证据服务,负责设备与订单校验、幂等占位、现场图上传和参考图复制。 + */ +public interface HardOrderEvidenceImageService extends IService { + + /** + * 按终端订单号对账服务端订单和已登记证据。 + * + * @param hardId 请求头中的当前设备主键 + * @param androidOrderCode 终端本地稳定订单号 + * @return 服务端订单及证据状态 + */ + HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode); + + /** + * 提交证据元数据、触发参考图复制或报告永久缺失。 + * + * @param hardId 请求头中的当前设备主键 + * @param dto 证据位置、识别快照和目标状态 + * @return 幂等处理结果 + */ + HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto); + + /** + * 上传终端现场证据图片,实际文件内容会与终端哈希和尺寸交叉校验。 + * + * @param hardId 请求头中的当前设备主键 + * @param dto 订单与证据元数据 + * @param file JPEG 或 PNG 图片 + * @return 幂等上传结果 + */ + HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file); + + /** + * 为已成功创建的订单登记证据占位;该操作失败不得回滚订单事务。 + * + * @param order 已创建订单快照 + * @param metadataList 终端随订单提交的可选证据元数据 + * @param appVersion 终端应用版本 + */ + void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion); + + /** + * 查询订单详情所需的证据列表并补齐中文展示名称。 + * + * @param orderId 服务端订单主键 + * @return 排序后的证据列表 + */ + List listByOrderId(Long orderId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index 19026a2b..91874f94 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -52,6 +52,14 @@ public interface HardOrderService extends IService { */ Page appMchPageJoin(Page page, HardOrderDto dto, PjcloudUser user); + /** + * 按筛选条件和数据范围分页查询订单,并装配列表展示所需的关联信息。 + * + * @param page 订单分页参数 + * @param dto 订单筛选与关联信息加载选项 + * @param dataScope 后台数据范围;App 查询传 {@code null} + * @return 订单分页结果 + */ Page scopePageList(Page page, HardOrderDto dto, DataScope dataScope); /** @@ -68,6 +76,14 @@ public interface HardOrderService extends IService { HardOrderVo join(Long id); + /** + * 查询后台管理端订单详情并填充长期识别证据;调用入口负责沿用订单查看权限。 + * + * @param id 订单主键 + * @return 订单详情及识别证据;订单不存在时返回 {@code null} + */ + HardOrderVo joinWithEvidence(Long id); + /** * 查询并校验 App 用户可查看的订单详情。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java index b8c40d3d..8a8698af 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java @@ -1391,7 +1391,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot * * @param query 日期批次查询条件 * @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取 - * @return 当前批次的汇总、商户部门和商品明细行 + * @return 当前批次的汇总、商户部门、商品明细和商品统计行 */ private List buildProductRows(HardOrderDto query, LocalDate fixedDate) { List summaries = hardCarMapper.listApptExportSummary(query); @@ -1431,6 +1431,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot .flatMap(product -> toProductRows(product, menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream()) .toList(); + List statisticRows = buildProductStatistics(detailRows); long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum(); long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum(); // 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。 @@ -1438,7 +1439,11 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot detailCount = valueOrZero(merchant.getPayCount()); detailPrice = valueOrZero(merchant.getTotalPrice()); } - detailRowsByMerchant.put(merchantKey, detailRows); + List merchantProductRows = new ArrayList<>( + detailRows.size() + statisticRows.size()); + merchantProductRows.addAll(detailRows); + merchantProductRows.addAll(statisticRows); + detailRowsByMerchant.put(merchantKey, merchantProductRows); detailCountsByMerchant.put(merchantKey, detailCount); detailPricesByMerchant.put(merchantKey, detailPrice); summaryProductCount += detailCount; @@ -1551,6 +1556,47 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot return row; } + /** + * 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。 + * + *

套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键, + * 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。

+ * + * @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细 + * @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致 + */ + private List buildProductStatistics( + List detailRows) { + Map statistics = new LinkedHashMap<>(); + for (HardCanteenProductApptExcelVo detailRow : detailRows) { + String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim(); + String statisticKey = normalizeProductName(displayName); + HardCanteenProductApptExcelVo statistic = statistics.computeIfAbsent(statisticKey, key -> { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setStatisticProductName(displayName); + row.setStatisticProductCount(0L); + row.setStatisticProductTotalPrice(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); + return row; + }); + statistic.setStatisticProductCount(valueOrZero(statistic.getStatisticProductCount()) + + valueOrZero(detailRow.getProductCount())); + long statisticTotalPrice = yuanToCents(statistic.getStatisticProductTotalPrice()) + + yuanToCents(detailRow.getProductTotalPrice()); + statistic.setStatisticProductTotalPrice(centsToYuan(statisticTotalPrice)); + } + return new ArrayList<>(statistics.values()); + } + + /** + * 规范化商品名称中的换行和连续空白,用于合并仅排版不同的同名套餐组成商品。 + * + * @param productName 首次出现的商品展示名称 + * @return 去除首尾空白并把连续空白压缩为单个空格的统计键 + */ + private String normalizeProductName(String productName) { + return productName.replaceAll("\\s+", " ").trim(); + } + /** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */ private Map fetchDailyMenus(List products, LocalDate fixedDate) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java index 42c6e106..8686df17 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java @@ -173,6 +173,17 @@ public class HardCarServiceImpl extends ServiceImpl getByParentCarIds(List parentCarIds) { + return baseMapper.listByParentCarIds(parentCarIds); + } + @Override public HardParentCarEntity getParentCarById(Long id) { return hardParentCarMapper.selectById(id); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java new file mode 100644 index 00000000..1533cc25 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java @@ -0,0 +1,657 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.lock.LockInfo; +import com.baomidou.lock.LockTemplate; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceRoleEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceStatusEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderEvidenceImageMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService; +import feign.Response; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 订单识别证据服务实现,证据文件和订单事务相互独立,失败只影响证据状态。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HardOrderEvidenceImageServiceImpl extends ServiceImpl + implements HardOrderEvidenceImageService { + + /** + * 证据服务诊断日志标签,日志中不输出图片、Token 或本地完整路径。 + */ + private static final String TAG = "订单识别证据"; + + /** + * 终端现场图最大接收大小,单位字节;终端目标约100KB,服务端留出编码差异余量。 + */ + private static final int LIVE_IMAGE_MAX_BYTES = 512 * 1024; + + /** + * 后台参考人脸原图最大读取大小,单位字节,防止异常文件耗尽服务内存。 + */ + private static final int REFERENCE_IMAGE_MAX_BYTES = 10 * 1024 * 1024; + + /** + * 终端现场图允许的最大边长,超过时拒绝而不在服务端二次改变证据内容。 + */ + private static final int LIVE_IMAGE_MAX_EDGE = 640; + + /** + * 单个证据位置分布式锁的最长持有时间,单位毫秒。 + */ + private static final long EVIDENCE_LOCK_EXPIRE_MILLIS = 180_000L; + + /** + * 并发上传同一证据时等待锁的最长时间,单位毫秒。 + */ + private static final long EVIDENCE_LOCK_WAIT_MILLIS = 5_000L; + + /** + * 文件中心保存订单证据时使用的稳定目录前缀。 + */ + private static final String FILE_DIRECTORY_PREFIX = "order-evidence"; + + /** + * 文件中心标识订单证据文件的稳定业务类型。 + */ + private static final String FILE_TYPE = "order-evidence"; + + /** + * UPMS 人脸图片受信下载接口的路径后缀;其他来源地址一律拒绝。 + */ + private static final String TRUSTED_FACE_FILE_PATH = "/sys-file/oss/file"; + + /** + * 订单主表 Mapper,用于按终端订单号和设备校验订单归属。 + */ + private final HardOrderMapper hardOrderMapper; + + /** + * 设备 Mapper,用于确认请求头 DEVICE-ID 属于当前租户的有效设备。 + */ + private final HardManagerMapper hardManagerMapper; + + /** + * UPMS 人脸远程服务,用于校验用户、样本和审核通过原图的一致性。 + */ + private final RemoteUserFaceService remoteUserFaceService; + + /** + * UPMS 文件中心远程服务,用于读取受信参考图并保存订单专属文件。 + */ + private final RemoteFileService remoteFileService; + + /** + * 分布式锁模板,用于串行化同一订单证据位置的并发上传。 + */ + private final LockTemplate lockTemplate; + + /** + * 按终端订单号解析服务端订单并返回当前证据状态。 + */ + @Override + public HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode) { + HardOrderEntity order = requireOrder(hardId, androidOrderCode, false); + return HardOrderEvidenceResolveVo.builder() + .orderId(order.getId()) + .orderStatus(order.getOrderStatus()) + .evidenceImages(listByOrderId(order.getId())) + .build(); + } + + /** + * 提交元数据时先建立占位;参考人脸继续执行受信复制,现场图等待 multipart 上传。 + */ + @Override + public HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto) { + HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), true); + validateMetadata(dto, order); + HardOrderEvidenceStatusEnum requestedStatus = HardOrderEvidenceStatusEnum.valueOfCode(dto.getStatus()); + if (requestedStatus != HardOrderEvidenceStatusEnum.PENDING + && requestedStatus != HardOrderEvidenceStatusEnum.UNAVAILABLE) { + throw new RuntimeException("终端只允许提交待补传或永久缺失状态"); + } + if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE && StrUtil.isBlank(dto.getFailureReason())) { + throw new RuntimeException("永久缺失时必须说明失败原因"); + } + return withEvidenceLock(order, dto, () -> { + HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "证据已经上传,无需重复处理"); + } + if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传"); + } + if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE) { + entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode()); + entity.setFailureReason(sanitizeFailure(dto.getFailureReason())); + updateById(entity); + return result(entity, false, "已记录证据永久缺失"); + } + if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) { + return copyReferenceImage(entity, order, dto); + } + return result(entity, false, "证据元数据已登记,等待现场图片上传"); + }); + } + + /** + * 上传现场图片时严格校验哈希、格式、尺寸和唯一位置,文件中心成功后才切换 AVAILABLE。 + */ + @Override + public HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file) { + HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), true); + validateMetadata(dto, order); + if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) { + throw new RuntimeException("后台参考人脸必须由服务端复制,终端不能直接上传"); + } + if (StrUtil.isBlank(dto.getImageHash())) { + throw new RuntimeException("现场图片哈希不能为空"); + } + ImageSnapshot image = readImage(file, LIVE_IMAGE_MAX_BYTES, true); + if (!dto.getImageHash().equalsIgnoreCase(image.hash())) { + throw new RuntimeException("现场图片内容与终端哈希不一致"); + } + validateReportedImage(dto, image); + return withEvidenceLock(order, dto, () -> { + HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) { + if (!image.hash().equalsIgnoreCase(entity.getImageHash())) { + throw new RuntimeException("该证据位置已保存其他图片,禁止覆盖"); + } + return result(entity, false, "相同证据已经上传"); + } + if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传"); + } + validateHashConflict(entity, image.hash()); + storeImage(entity, order, image, evidenceFileName(order, dto, image.extension())); + return result(entity, true, "现场证据上传成功"); + }); + } + + /** + * 订单提交的证据清单只创建 PENDING 占位,不执行远程文件调用,确保订单主链路快速完成。 + */ + @Override + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) + public void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion) { + if (order == null || metadataList == null || metadataList.isEmpty()) return; + for (HardOrderEvidenceMetadataDto metadata : metadataList) { + validateMetadata(metadata, order); + HardOrderEvidenceImageEntity existing = baseMapper.findPosition(order.getId(), metadata.getEvidenceType(), + metadata.getEvidenceRole(), metadata.getSequenceNo()); + if (existing != null) { + validateHashConflict(existing, metadata.getImageHash()); + continue; + } + HardOrderEvidenceImageEntity entity = buildEntity(order, metadata, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + save(entity); + } + } + + /** + * 转换数据库实体并补齐稳定编码的中文名称,未知历史编码保持原值便于排障。 + */ + @Override + public List listByOrderId(Long orderId) { + if (orderId == null) return List.of(); + return baseMapper.listByOrderId(orderId).stream().map(entity -> { + HardOrderEvidenceImageVo vo = BeanUtils.toBean(entity, HardOrderEvidenceImageVo.class); + HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(entity.getEvidenceType()); + HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(entity.getEvidenceRole()); + HardOrderEvidenceStatusEnum status = HardOrderEvidenceStatusEnum.valueOfCode(entity.getStatus()); + vo.setEvidenceTypeName(type == null ? entity.getEvidenceType() : type.getDescription()); + vo.setEvidenceRoleName(role == null ? entity.getEvidenceRole() : role.getDescription()); + vo.setStatusName(status == null ? entity.getStatus() : status.getDescription()); + return vo; + }).toList(); + } + + /** + * 校验设备存在,并按设备和终端订单号限定订单,避免跨设备探测或写入证据。 + */ + private HardOrderEntity requireOrder(Long hardId, String androidOrderCode, boolean requirePaid) { + if (hardId == null) throw new RuntimeException("DEVICE-ID不能为空"); + if (StrUtil.isBlank(androidOrderCode)) throw new RuntimeException("终端订单号不能为空"); + HardManagerEntity hard = hardManagerMapper.selectById(hardId); + if (hard == null) throw new RuntimeException("当前租户下设备不存在"); + HardOrderEntity order = hardOrderMapper.selectOne(new LambdaQueryWrapper() + .eq(HardOrderEntity::getHardId, hardId) + .eq(HardOrderEntity::getAndroidOrderCode, androidOrderCode)); + if (order == null) throw new RuntimeException("订单不存在或不属于当前设备"); + if (requirePaid && !HardOrderStatusEnum.getPayPrice().contains(order.getOrderStatus())) { + throw new RuntimeException("订单尚未确认支付成功,暂不能保存识别证据"); + } + return order; + } + + /** + * 校验类型、角色、序号、用户和人脸样本约束,阻止终端构造不一致的证据位置。 + */ + private void validateMetadata(HardOrderEvidenceMetadataDto dto, HardOrderEntity order) { + if (dto == null) throw new RuntimeException("证据元数据不能为空"); + HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(dto.getEvidenceType()); + HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(dto.getEvidenceRole()); + if (type == null || role == null) throw new RuntimeException("证据类型或图片角色不合法"); + if (dto.getSequenceNo() == null || dto.getSequenceNo() < 0 || dto.getSequenceNo() > 99) { + throw new RuntimeException("证据图片序号不合法"); + } + if (type == HardOrderEvidenceTypeEnum.FACE_RECOGNITION) { + if (role != HardOrderEvidenceRoleEnum.FACE_REFERENCE && role != HardOrderEvidenceRoleEnum.FACE_LIVE) { + throw new RuntimeException("人脸识别证据角色不合法"); + } + if (dto.getSequenceNo() != 0) throw new RuntimeException("人脸识别证据序号必须为0"); + if (dto.getUserId() == null || dto.getUserId() <= 0 || StrUtil.isBlank(dto.getFaceSampleId())) { + throw new RuntimeException("人脸识别证据缺少用户或样本编号"); + } + if (!Objects.equals(order.getUserId(), dto.getUserId())) { + throw new RuntimeException("识别用户与订单用户不一致"); + } + if (role == HardOrderEvidenceRoleEnum.FACE_REFERENCE && StrUtil.isBlank(dto.getReferenceImageUrl())) { + throw new RuntimeException("参考人脸证据缺少识别时图片地址"); + } + } else if (role != HardOrderEvidenceRoleEnum.MENU_LIVE) { + throw new RuntimeException("菜谱识别证据角色不合法"); + } + } + + /** + * 创建或更新 PENDING 元数据,AVAILABLE 和 UNAVAILABLE 终态只做哈希一致性检查并保持不可变。 + */ + private HardOrderEvidenceImageEntity upsertMetadata(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) { + HardOrderEvidenceImageEntity entity = baseMapper.findPosition(order.getId(), dto.getEvidenceType(), + dto.getEvidenceRole(), dto.getSequenceNo()); + if (entity == null) { + entity = buildEntity(order, dto, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + save(entity); + return entity; + } + validateHashConflict(entity, dto.getImageHash()); + if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus()) + || HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) { + return entity; + } + fillMetadata(entity, dto, appVersion); + entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode()); + entity.setFailureReason(null); + updateById(entity); + return entity; + } + + /** + * 构造新的证据占位实体并写入订单、设备和租户快照。 + */ + private HardOrderEvidenceImageEntity buildEntity(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) { + HardOrderEvidenceImageEntity entity = new HardOrderEvidenceImageEntity(); + entity.setOrderId(order.getId()); + entity.setAndroidOrderCode(order.getAndroidOrderCode()); + entity.setHardId(order.getHardId()); + entity.setTenantId(TenantContextHolder.getTenantId()); + fillMetadata(entity, dto, appVersion); + return entity; + } + + /** + * 更新终端识别快照;文件中心字段只能由 storeImage 写入,不能由终端覆盖。 + */ + private void fillMetadata(HardOrderEvidenceImageEntity entity, HardOrderEvidenceMetadataDto dto, String appVersion) { + entity.setEvidenceType(dto.getEvidenceType()); + entity.setEvidenceRole(dto.getEvidenceRole()); + entity.setSequenceNo(dto.getSequenceNo()); + entity.setUserId(dto.getUserId()); + entity.setFaceSampleId(dto.getFaceSampleId()); + entity.setReferenceImageUrl(dto.getReferenceImageUrl()); + entity.setSimilarity(dto.getSimilarity()); + entity.setSimilarityThreshold(dto.getSimilarityThreshold()); + entity.setCapturedAt(dto.getCapturedAt()); + if (StrUtil.isNotBlank(dto.getImageHash())) entity.setImageHash(dto.getImageHash().toLowerCase()); + if (dto.getImageSize() != null) entity.setImageSize(dto.getImageSize()); + if (dto.getImageWidth() != null) entity.setImageWidth(dto.getImageWidth()); + if (dto.getImageHeight() != null) entity.setImageHeight(dto.getImageHeight()); + if (StrUtil.isNotBlank(appVersion)) entity.setAppVersion(appVersion); + } + + /** + * 通过 UPMS 权威记录校验参考图,来源已变化时标记永久缺失,远程暂时失败则保留 PENDING 重试。 + */ + private HardOrderEvidenceUploadResultVo copyReferenceImage(HardOrderEvidenceImageEntity entity, + HardOrderEntity order, + HardOrderEvidenceMetadataDto dto) { + R sourceResult = remoteUserFaceService.getEvidenceSource(dto.getUserId(), dto.getFaceSampleId()); + if (sourceResult == null || !sourceResult.isOk()) { + throw new RuntimeException(sourceResult == null || StrUtil.isBlank(sourceResult.getMsg()) + ? "查询后台参考人脸失败" : sourceResult.getMsg()); + } + SysUserFaceEvidenceSourceVo source = sourceResult.getData(); + if (source == null || StrUtil.isBlank(source.getFaceImg())) { + return markUnavailable(entity, "识别命中的后台人脸已不存在或不再可用"); + } + if (!Objects.equals(dto.getReferenceImageUrl(), source.getFaceImg())) { + return markUnavailable(entity, "后台参考人脸在识别后已发生变化"); + } + if (dto.getReferenceImageVersion() != null + && !Objects.equals(dto.getReferenceImageVersion(), source.getSourceUpdatedAt())) { + return markUnavailable(entity, "后台参考人脸版本在识别后已发生变化"); + } + String sourceFileName; + try { + sourceFileName = trustedFileName(source.getFaceImg()); + } catch (RuntimeException exception) { + return markUnavailable(entity, exception.getMessage()); + } + ImageSnapshot image; + try (Response response = remoteFileService.getFile(sourceFileName)) { + if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) { + throw new RuntimeException("读取后台参考人脸失败"); + } + image = readImage(response.body().asInputStream(), sourceFileName, REFERENCE_IMAGE_MAX_BYTES, false); + } catch (IOException exception) { + throw new RuntimeException("读取后台参考人脸失败", exception); + } + if (StrUtil.isNotBlank(dto.getImageHash()) && !dto.getImageHash().equalsIgnoreCase(image.hash())) { + return markUnavailable(entity, "后台参考人脸内容与识别时哈希不一致"); + } + validateHashConflict(entity, image.hash()); + storeImage(entity, order, image, evidenceFileName(order, dto, image.extension())); + return result(entity, true, "后台参考人脸已复制为订单证据"); + } + + /** + * 将不可恢复的参考图变化记录为 UNAVAILABLE,保留已有元数据供 PC 追溯。 + */ + private HardOrderEvidenceUploadResultVo markUnavailable(HardOrderEvidenceImageEntity entity, String reason) { + entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode()); + entity.setFailureReason(sanitizeFailure(reason)); + updateById(entity); + return result(entity, false, reason); + } + + /** + * 写入文件中心并在成功响应后原子更新证据记录,不删除任何历史证据文件。 + */ + @SuppressWarnings("unchecked") + private void storeImage(HardOrderEvidenceImageEntity entity, HardOrderEntity order, + ImageSnapshot image, String originalName) { + MultipartFile uploadFile = new MockMultipartFile("file", originalName, image.contentType(), + new ByteArrayInputStream(image.bytes())); + R uploadResult = remoteFileService.innerUpload(uploadFile, + FILE_DIRECTORY_PREFIX + "/" + order.getId(), null, FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() || !(uploadResult.getData() instanceof Map storage)) { + throw new RuntimeException(uploadResult == null || StrUtil.isBlank(uploadResult.getMsg()) + ? "订单证据写入文件中心失败" : uploadResult.getMsg()); + } + entity.setSysFileId(numberValue(storage.get("id"))); + entity.setStorageFileName(requiredString(storage.get("fileName"), "文件中心未返回对象名")); + entity.setImageUrl(requiredString(storage.get("url"), "文件中心未返回访问地址")); + entity.setImageHash(image.hash()); + entity.setImageSize((long) image.bytes().length); + entity.setImageWidth(image.width()); + entity.setImageHeight(image.height()); + entity.setStatus(HardOrderEvidenceStatusEnum.AVAILABLE.getCode()); + entity.setFailureReason(null); + updateById(entity); + } + + /** + * 读取终端 MultipartFile 并复用统一图片内容校验。 + */ + private ImageSnapshot readImage(MultipartFile file, int maxBytes, boolean liveImage) { + if (file == null || file.isEmpty()) throw new RuntimeException("上传图片不能为空"); + try { + return readImage(new ByteArrayInputStream(file.getBytes()), file.getOriginalFilename(), maxBytes, liveImage); + } catch (IOException exception) { + throw new RuntimeException("读取上传图片失败", exception); + } + } + + /** + * 有上限地读取并解码图片,拒绝伪造后缀、空内容和超大像素尺寸。 + */ + private ImageSnapshot readImage(InputStream inputStream, String originalName, int maxBytes, boolean liveImage) { + byte[] bytes = readLimitedBytes(inputStream, maxBytes); + try { + BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(bytes)); + if (bufferedImage == null || bufferedImage.getWidth() <= 0 || bufferedImage.getHeight() <= 0) { + throw new RuntimeException("文件不是有效图片"); + } + String extension = detectExtension(bytes, originalName); + if (liveImage && Math.max(bufferedImage.getWidth(), bufferedImage.getHeight()) > LIVE_IMAGE_MAX_EDGE) { + throw new RuntimeException("现场图片最长边不能超过640像素"); + } + String contentType = "png".equals(extension) ? "image/png" : "image/jpeg"; + return new ImageSnapshot(bytes, sha256(bytes), bufferedImage.getWidth(), bufferedImage.getHeight(), extension, contentType); + } catch (IOException exception) { + throw new RuntimeException("图片解码失败", exception); + } + } + + /** + * 按字节签名识别 JPEG/PNG,扩展名只作为错误信息补充而不作为信任依据。 + */ + private String detectExtension(byte[] bytes, String originalName) { + if (bytes.length >= 3 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8 && (bytes[2] & 0xff) == 0xff) { + return "jpg"; + } + if (bytes.length >= 8 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) { + return "png"; + } + throw new RuntimeException("仅支持真实的JPEG或PNG图片:" + StrUtil.blankToDefault(originalName, "未命名文件")); + } + + /** + * 按上限读取远程或上传字节流,超过上限立即拒绝以控制内存占用。 + */ + private byte[] readLimitedBytes(InputStream inputStream, int maxBytes) { + try (InputStream source = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int total = 0; + int read; + while ((read = source.read(buffer)) >= 0) { + total += read; + if (total > maxBytes) throw new RuntimeException("图片文件超过允许大小"); + output.write(buffer, 0, read); + } + if (total == 0) throw new RuntimeException("图片文件不能为空"); + return output.toByteArray(); + } catch (IOException exception) { + throw new RuntimeException("读取图片文件失败", exception); + } + } + + /** + * 校验终端上报的大小和宽高,防止元数据与实际证据内容不一致。 + */ + private void validateReportedImage(HardOrderEvidenceMetadataDto dto, ImageSnapshot image) { + if (dto.getImageSize() != null && dto.getImageSize() != image.bytes().length) { + throw new RuntimeException("现场图片大小与终端上报不一致"); + } + if (dto.getImageWidth() != null && !dto.getImageWidth().equals(image.width())) { + throw new RuntimeException("现场图片宽度与终端上报不一致"); + } + if (dto.getImageHeight() != null && !dto.getImageHeight().equals(image.height())) { + throw new RuntimeException("现场图片高度与终端上报不一致"); + } + } + + /** + * 从受信文件接口地址提取对象名,禁止服务端按终端参数访问任意 URL。 + */ + private String trustedFileName(String sourceUrl) { + try { + var components = UriComponentsBuilder.fromUriString(sourceUrl).build(); + String path = components.getPath(); + String fileName = components.getQueryParams().getFirst("fileName"); + if (StrUtil.isBlank(path) || !path.endsWith(TRUSTED_FACE_FILE_PATH) || StrUtil.isBlank(fileName) + || fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { + throw new RuntimeException("后台参考人脸来源不受信任"); + } + return fileName; + } catch (IllegalArgumentException exception) { + throw new RuntimeException("后台参考人脸地址不合法", exception); + } + } + + /** + * 相同证据位置只接受相同哈希;未知哈希的 PENDING 占位可在首次文件上传时补齐。 + */ + private void validateHashConflict(HardOrderEvidenceImageEntity existing, String incomingHash) { + if (existing != null && StrUtil.isNotBlank(existing.getImageHash()) && StrUtil.isNotBlank(incomingHash) + && !existing.getImageHash().equalsIgnoreCase(incomingHash)) { + throw new RuntimeException("该证据位置已登记其他图片哈希,禁止覆盖"); + } + } + + /** + * 使用不含租户编号的稳定业务 Key 串行化证据位置,租户前缀由 Redis 基础设施自动处理。 + */ + private T withEvidenceLock(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, LockedAction action) { + String lockKey = String.format("hard:order-evidence:%s:%s:%s:%s", order.getId(), dto.getEvidenceType(), + dto.getEvidenceRole(), dto.getSequenceNo()); + LockInfo lock = lockTemplate.lock(lockKey, EVIDENCE_LOCK_EXPIRE_MILLIS, EVIDENCE_LOCK_WAIT_MILLIS); + if (lock == null) throw new RuntimeException("该订单证据正在处理,请稍后重试"); + try { + return action.run(); + } finally { + if (!lockTemplate.releaseLock(lock)) log.warn("|{}|证据锁释放失败|orderId:{}|", TAG, order.getId()); + } + } + + /** + * 生成不包含用户姓名等敏感信息的订单证据文件名。 + */ + private String evidenceFileName(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String extension) { + return String.format("order-%s-%s-%s-%s.%s", order.getId(), dto.getEvidenceType().toLowerCase(), + dto.getEvidenceRole().toLowerCase(), dto.getSequenceNo(), extension); + } + + /** + * 将任意失败说明限制为单行500字符,避免终端路径或异常栈进入证据主记录。 + */ + private String sanitizeFailure(String source) { + String value = StrUtil.blankToDefault(source, "证据文件不可用").replace('\n', ' ').replace('\r', ' ').trim(); + return value.length() <= 500 ? value : value.substring(0, 500); + } + + /** + * 计算图片 SHA-256,使用 Java 17 十六进制编码保持终端与服务端一致。 + */ + private String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前JVM不支持SHA-256", exception); + } + } + + /** + * 将文件中心返回的数值主键安全转换为 Long。 + */ + private Long numberValue(Object value) { + if (value instanceof Number number) return number.longValue(); + if (value != null && StrUtil.isNotBlank(value.toString())) { + try { + return Long.valueOf(value.toString()); + } catch (NumberFormatException ignored) { + // 统一在下方抛出文件中心协议错误。 + } + } + throw new RuntimeException("文件中心未返回有效文件主键"); + } + + /** + * 读取文件中心必需字符串字段,空值作为协议错误处理。 + */ + private String requiredString(Object value, String message) { + if (value == null || StrUtil.isBlank(value.toString())) throw new RuntimeException(message); + return value.toString(); + } + + /** + * 构造终端同步使用的简短幂等结果。 + */ + private HardOrderEvidenceUploadResultVo result(HardOrderEvidenceImageEntity entity, boolean uploaded, String message) { + return HardOrderEvidenceUploadResultVo.builder() + .evidenceId(entity.getId()) + .orderId(entity.getOrderId()) + .status(entity.getStatus()) + .imageUrl(entity.getImageUrl()) + .uploaded(uploaded) + .message(message) + .build(); + } + + /** + * 持锁执行单个证据位置动作的函数接口,允许统一保证锁释放。 + */ + @FunctionalInterface + private interface LockedAction { + + /** + * 执行可能读写证据和文件中心的业务动作。 + * + * @return 动作结果 + */ + T run(); + } + + /** + * 已校验图片的不可变内存快照,避免上传前后再次读取可变临时文件。 + * + * @param bytes 图片原始字节 + * @param hash SHA-256 哈希 + * @param width 图片宽度,单位像素 + * @param height 图片高度,单位像素 + * @param extension 由字节签名确定的扩展名 + * @param contentType 与扩展名一致的 MIME 类型 + */ + private record ImageSnapshot(byte[] bytes, String hash, int width, int height, String extension, String contentType) { + } +} 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 f2d2a2e1..04574e76 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 @@ -52,7 +52,6 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum; import com.bsd.pjcloud.hard.api.dto.hard.HandCancelActCodeDto; import com.bsd.pjcloud.hard.api.dto.hard.HardDoneHardQrCodeScanOrderDto; -import com.bsd.pjcloud.hard.api.dto.manager.HardManagerDto; import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto; import com.bsd.pjcloud.hard.api.dto.hard.HardCreateOrderDto; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; @@ -75,7 +74,6 @@ import com.bsd.pjcloud.hard.api.entity.address.HardUserMapAddressEntity; import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; import com.bsd.pjcloud.hard.api.entity.order.*; import com.bsd.pjcloud.hard.api.entity.spu.HardSpuEntity; -import com.bsd.pjcloud.hard.api.utils.HardUtil; import com.bsd.pjcloud.hard.api.vo.HardDoorFlowVo; import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; @@ -89,7 +87,6 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo; import com.bsd.pjcloud.hard.util.MchJobTimeUtils; import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper; -import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.*; @@ -183,6 +180,8 @@ public class HardOrderServiceImpl extends ServiceImplApp 可见性查询使用不关联展示表的专用统计语句,避免总数统计重复执行最新售后单等关联逻辑。

+ * + * @param page 分页参数 + * @param dto 订单筛选和结果补充选项 + * @param dataScope 后台数据范围;App 查询传 {@code null} + * @return 已完成展示信息装配的订单分页结果 + */ @Override public Page scopePageList(Page page, HardOrderDto dto, DataScope dataScope) { + if (dto.getAppViewerUserId() != null) { + Page list = appVisibilityPageList(page, dto, dataScope); + disposeList(list.getRecords(), dto); + return list; + } Page list = baseMapper.scopePageList(page, dto, dataScope); disposeList(list.getRecords(), dto); return list; } + /** + * App 订单先分页主键、再关联当前页详情,避免售后和父购物车关联参与全部历史订单的排序与分页。 + * + * @param page 原始订单分页参数 + * @param dto 已由接口层写入当前访问者和被监护人范围的查询参数 + * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null} + * @return 记录顺序与主键分页查询完全一致的订单页 + */ + private Page appVisibilityPageList(Page page, HardOrderDto dto, DataScope dataScope) { + Page idPage = new Page<>(page.getCurrent(), page.getSize(), page.searchCount()); + idPage.setCountId("appVisibilityCount"); + idPage.setMaxLimit(page.maxLimit()); + idPage.setOptimizeCountSql(page.optimizeCountSql()); + idPage.setOptimizeJoinOfCountSql(page.optimizeJoinOfCountSql()); + idPage.setOrders(page.orders()); + idPage = baseMapper.appVisibilityPageIds(idPage, dto, dataScope); + + List orderIds = idPage.getRecords(); + List orderedRecords = List.of(); + if (ObjectUtil.isNotEmpty(orderIds)) { + Map detailMap = CollectionUtils.convertMap(baseMapper.listAppPageDetails(orderIds), HardOrderVo::getId); + orderedRecords = orderIds.stream().map(detailMap::get).filter(Objects::nonNull).toList(); + } + + Page result = new Page<>(idPage.getCurrent(), idPage.getSize(), idPage.getTotal(), idPage.searchCount()); + result.setRecords(orderedRecords); + result.setOrders(page.orders()); + result.setMaxLimit(idPage.maxLimit()); + return result; + } + /** * 分页查询未识别订单 */ @@ -371,6 +414,18 @@ public class HardOrderServiceImpl extends ServiceImpl { + try { + hardOrderEvidenceImageService.registerOrderEvidence(order, dto.getEvidenceMetadataList(), dto.getAppVersion()); + } catch (Exception exception) { + // 终端本地队列仍会按 androidOrderCode 补交,服务端占位失败不得改变订单支付结果。 + log.warn("|{}|订单证据占位登记失败|orderId:{}|error:{}|", TAG, order.getId(), exception.getMessage()); + } + }; + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 主订单事务提交后使用独立事务登记证据占位,避免文件或证据表异常污染资金事务。 + */ + @Override + public void afterCommit() { + action.run(); + } + }); + } + /** * 创建设备二维码扫一扫订单 */ @@ -4536,6 +4636,12 @@ public class HardOrderServiceImpl extends ServiceImpl list, HardOrderDto dto) { if (ObjectUtil.isEmpty(list)) return; RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); @@ -4583,19 +4689,6 @@ public class HardOrderServiceImpl extends ServiceImpl deviceFuture = CompletableFuture.runAsync(() -> { - RequestContextHolder.setRequestAttributes(requestAttributes); - List hardIds = list.stream().map(HardOrderVo::getHardId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(hardIds)) { - HardManagerDto hardManagerDto = new HardManagerDto(); - hardManagerDto.setIds(hardIds); - List hardList = hardManagerMapper.list(hardManagerDto); - if (ObjectUtil.isNotEmpty(hardList)) { - HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId)); - } - } - }); // 钱包 CompletableFuture walletFuture = CompletableFuture.runAsync(() -> { RequestContextHolder.setRequestAttributes(requestAttributes); @@ -4613,11 +4706,18 @@ public class HardOrderServiceImpl extends ServiceImpl findCarListFuture = CompletableFuture.runAsync(() -> { if (dto.getIsFindCarList()) { + List parentCarIds = list.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); Map> carListsByParentId = new LinkedHashMap<>(); + if (!parentCarIds.isEmpty()) { + List carList = hardCarService.getByParentCarIds(parentCarIds); + for (HardCarEntity car : carList) { + carListsByParentId.computeIfAbsent(car.getParentCarId(), ignored -> new ArrayList<>()) + .add(BeanUtils.toBean(car, HardCarVo.class)); + } + } for (HardOrderVo v : list) { - List carList = hardCarService.getByParentCarId(v.getParentCarId()); - List carVoList = BeanUtils.toBean(carList, HardCarVo.class); - if (v.getParentCarId() != null) carListsByParentId.put(v.getParentCarId(), carVoList); + List carVoList = carListsByParentId.getOrDefault(v.getParentCarId(), new ArrayList<>()); v.setCarList(carVoList); } hardAfterSaleCarService.fillRefundQuantityStats(carListsByParentId); @@ -4642,6 +4742,6 @@ public class HardOrderServiceImpl extends ServiceImpl + + + + and ( + a.user_id = #{dto.appViewerUserId} + or ( + a.merchant_type = '1' + and a.sys_type = '2' + and a.order_type = '2' + and (a.appt_id is not null or a.appt_rule_id is not null) + and ( + exists ( + select 1 + from hard_parent_car visible_parent + where visible_parent.id = a.parent_car_id + and visible_parent.del_flag = '0' + and visible_parent.user_id = #{dto.appViewerUserId} + ) + + or a.user_id in + + #{v} + + + ) + ) + ) + + + + + + + and exists ( + select 1 + from hard_manager count_manager + where count_manager.id = a.hard_id + and count_manager.type = #{dto.hardTypeCode} + ) + + + and exists ( + select 1 + from hard_manager count_manager + where count_manager.id = a.hard_id + and count_manager.type in + + #{v} + + ) + + + and exists ( + select 1 + from hard_after_sale count_sale + where count_sale.id = ( + select max(latest_sale.id) + from hard_after_sale latest_sale + where latest_sale.order_id = a.id + and latest_sale.del_flag = '0' + ) + and count_sale.no = #{dto.afterSaleNo} + ) + + + m.name as hardName, @@ -234,26 +300,7 @@ left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0' - - and ( - a.user_id = #{dto.appViewerUserId} - or ( - a.merchant_type = '1' - and a.sys_type = '2' - and a.order_type = '2' - and (a.appt_id is not null or a.appt_rule_id is not null) - and ( - p.user_id = #{dto.appViewerUserId} - - or a.user_id in - - #{v} - - - ) - ) - ) - + and m.type = #{dto.hardTypeCode} @@ -267,7 +314,6 @@ and s.no = #{dto.afterSaleNo}
- group by a.id order by a.update_time desc, a.id desc @@ -282,6 +328,61 @@ + + + + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java index 7c3a1519..7ab76007 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java @@ -41,12 +41,15 @@ class HardCanteenProductApptExportMergeHandlerTest { rows.add(summaryRow(LocalDate.of(2026, 8, 23), "早餐")); rows.add(merchantRow(LocalDate.of(2026, 8, 23), "早餐")); addProductRows(rows, 130); + rows.add(statisticRow("早餐汇总商品")); rows.add(summaryRow(LocalDate.of(2026, 8, 23), "午餐")); rows.add(merchantRow(LocalDate.of(2026, 8, 23), "午餐")); addProductRows(rows, 2); + rows.add(statisticRow("午餐汇总商品")); rows.add(summaryRow(LocalDate.of(2026, 8, 24), "早餐")); rows.add(merchantRow(LocalDate.of(2026, 8, 24), "早餐")); addProductRows(rows, 5); + rows.add(statisticRow("次日早餐汇总商品")); EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class) .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()) @@ -55,14 +58,14 @@ class HardCanteenProductApptExportMergeHandlerTest { try (InputStream input = Files.newInputStream(output); Workbook workbook = new XSSFWorkbook(input)) { Set mergedRegions = workbook.getSheetAt(0).getMergedRegions().stream() .map(region -> region.formatAsString()).collect(Collectors.toSet()); - assertTrue(mergedRegions.contains("A3:A134")); - assertTrue(mergedRegions.contains("F3:F134")); - assertTrue(mergedRegions.contains("G4:G134")); - assertTrue(mergedRegions.contains("N4:N134")); - assertTrue(mergedRegions.contains("A135:A138")); - assertTrue(mergedRegions.contains("G136:G138")); - assertTrue(mergedRegions.contains("A139:A145")); - assertTrue(mergedRegions.contains("G140:G145")); + assertTrue(mergedRegions.contains("A3:A135")); + assertTrue(mergedRegions.contains("F3:F135")); + assertTrue(mergedRegions.contains("G4:G135")); + assertTrue(mergedRegions.contains("N4:N135")); + assertTrue(mergedRegions.contains("A136:A140")); + assertTrue(mergedRegions.contains("G137:G140")); + assertTrue(mergedRegions.contains("A141:A148")); + assertTrue(mergedRegions.contains("G142:G148")); assertEquals(10, workbook.getFontAt(workbook.getSheetAt(0).getRow(0).getCell(0) .getCellStyle().getFontIndex()).getFontHeightInPoints()); assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0)); @@ -70,19 +73,21 @@ class HardCanteenProductApptExportMergeHandlerTest { assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(12)); assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(14)); assertEquals(9 * 256, workbook.getSheetAt(0).getColumnWidth(15)); + assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(17)); + assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(18)); XSSFCellStyle firstGroupTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(2) .getCell(0).getCellStyle(); XSSFCellStyle firstGroupSideStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3) .getCell(0).getCellStyle(); XSSFCellStyle firstGroupRightStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3) - .getCell(16).getCellStyle(); - XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(134) + .getCell(19).getCellStyle(); + XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(135) .getCell(0).getCellStyle(); - XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(137) + XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(139) .getCell(0).getCellStyle(); - XSSFCellStyle secondDateTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(138) + XSSFCellStyle secondDateTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(140) .getCell(0).getCellStyle(); - XSSFCellStyle lastDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(144) + XSSFCellStyle lastDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(147) .getCell(0).getCellStyle(); assertEquals(BorderStyle.MEDIUM, firstGroupTopStyle.getBorderTop()); assertEquals(BorderStyle.MEDIUM, firstGroupSideStyle.getBorderLeft()); @@ -154,4 +159,18 @@ class HardCanteenProductApptExportMergeHandlerTest { rows.add(row); } } + + /** + * 构造商户部门块末尾的独立商品统计行,用于验证纵向合并范围包含统计区域。 + * + * @param productName 商品统计名称 + * @return 仅填写商品统计列的导出行 + */ + private HardCanteenProductApptExcelVo statisticRow(String productName) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setStatisticProductName(productName); + row.setStatisticProductCount(1L); + row.setStatisticProductTotalPrice(BigDecimal.ONE); + return row; + } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java index 67654c95..548d8f5b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java @@ -26,12 +26,12 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** - * 菜品预约导出服务测试,验证日期餐段和部门合计与套餐展开后的商品明细保持同一数量口径。 + * 菜品预约导出服务测试,验证日期餐段、部门合计及商品统计与套餐展开后的商品明细保持同一口径。 */ class HardCanteenApptTotalExportServiceImplTest { /** - * 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,而不是累计套餐份数。 + * 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,并在部门内合并同名单品。 */ @Test void shouldAggregateMealSegmentTotalsAcrossDepartmentsFromExpandedProductRows() { @@ -64,8 +64,8 @@ class HardCanteenApptTotalExportServiceImplTest { merchant.setUserDeptId(20L); merchant.setPayUserCount(1L); merchant.setTimeCount(1L); - merchant.setPayCount(1L); - merchant.setTotalPrice(300L); + merchant.setPayCount(3L); + merchant.setTotalPrice(500L); HardCanteenProductApptExportRecord product = new HardCanteenProductApptExportRecord(); product.setApptDate(date.toString()); @@ -77,6 +77,16 @@ class HardCanteenApptTotalExportServiceImplTest { product.setPayCount(1L); product.setTotalPrice(300L); + HardCanteenProductApptExportRecord sameNameProduct = new HardCanteenProductApptExportRecord(); + sameNameProduct.setApptDate(date.toString()); + sameNameProduct.setHourType("1"); + sameNameProduct.setMerchantId(10L); + sameNameProduct.setUserDeptId(20L); + sameNameProduct.setProductId(101L); + sameNameProduct.setProductName("主食"); + sameNameProduct.setPayCount(2L); + sameNameProduct.setTotalPrice(200L); + HardCanteenProductApptExportRecord secondMerchant = new HardCanteenProductApptExportRecord(); secondMerchant.setApptDate(date.toString()); secondMerchant.setHourType("1"); @@ -124,7 +134,7 @@ class HardCanteenApptTotalExportServiceImplTest { when(hardCarMapper.listApptExportSummary(any())).thenReturn(List.of(summary)); when(hardCarMapper.listApptExportMerchant(any())).thenReturn(List.of(merchant, secondMerchant)); - when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, secondProduct)); + when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, sameNameProduct, secondProduct)); when(remoteDailyMenuService.getCanteenList(date.toString(), 1, null, 10L)).thenReturn(R.ok(List.of(menu))); when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchantVo))); when(remoteDeptService.getDeptByDeptIds(List.of(20L, 30L))) @@ -134,16 +144,26 @@ class HardCanteenApptTotalExportServiceImplTest { List rows = ReflectionTestUtils.invokeMethod(service, "buildProductRows", null, date); - assertEquals(6, rows.size()); - assertEquals(7L, rows.get(0).getSummaryPayCount()); - assertEquals("8.00", rows.get(0).getSummaryTotalPrice().toPlainString()); - assertEquals(3L, rows.get(1).getMerchantPayCount()); - assertEquals("3.00", rows.get(1).getMerchantTotalPrice().toPlainString()); + assertEquals(10, rows.size()); + assertEquals(9L, rows.get(0).getSummaryPayCount()); + assertEquals("10.00", rows.get(0).getSummaryTotalPrice().toPlainString()); + assertEquals(5L, rows.get(1).getMerchantPayCount()); + assertEquals("5.00", rows.get(1).getMerchantTotalPrice().toPlainString()); assertEquals(1L, rows.get(2).getProductCount()); assertEquals(2L, rows.get(3).getProductCount()); - assertEquals(4L, rows.get(4).getMerchantPayCount()); - assertEquals("5.00", rows.get(4).getMerchantTotalPrice().toPlainString()); - assertEquals(4L, rows.get(5).getProductCount()); + assertEquals(2L, rows.get(4).getProductCount()); + assertEquals("主食", rows.get(5).getStatisticProductName()); + assertEquals(3L, rows.get(5).getStatisticProductCount()); + assertEquals("3.00", rows.get(5).getStatisticProductTotalPrice().toPlainString()); + assertEquals("配菜", rows.get(6).getStatisticProductName()); + assertEquals(2L, rows.get(6).getStatisticProductCount()); + assertEquals("2.00", rows.get(6).getStatisticProductTotalPrice().toPlainString()); + assertEquals(4L, rows.get(7).getMerchantPayCount()); + assertEquals("5.00", rows.get(7).getMerchantTotalPrice().toPlainString()); + assertEquals(4L, rows.get(8).getProductCount()); + assertEquals("单品", rows.get(9).getStatisticProductName()); + assertEquals(4L, rows.get(9).getStatisticProductCount()); + assertEquals("5.00", rows.get(9).getStatisticProductTotalPrice().toPlainString()); assertEquals("早餐", rows.get(0).getSummaryHourType()); } } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java index bf97a3c2..f5eb95df 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserFaceService.java @@ -1,12 +1,15 @@ package com.bsd.pjcloud.admin.api.feign; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.feign.annotation.NoToken; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import java.util.List; @@ -25,4 +28,15 @@ public interface RemoteUserFaceService { @NoToken @PostMapping("/user-face/inner/status-count") R> listStatusCounts(@RequestBody List userIds); + + /** + * 按用户和虹软样本编号读取审核通过的人脸原图地址,供订单证据服务复制订单专属快照。 + * + * @param userId 识别用户主键 + * @param sampleId 虹软样本编号 + * @return 受信人脸图片来源;不存在或未审核通过时为空 + */ + @NoToken + @GetMapping("/user-face/inner/evidence-source") + R getEvidenceSource(@RequestParam Long userId, @RequestParam String sampleId); } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java new file mode 100644 index 00000000..c70843df --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceEvidenceSourceVo.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 供 Hardware 订单证据服务使用的受信人脸原图来源,不包含特征数据和审核流程内部字段。 + */ +@Data +@Schema(description = "人脸证据原图来源") +public class SysUserFaceEvidenceSourceVo { + + /** + * 人脸所属用户主键。 + */ + @Schema(description = "用户主键") + private Long userId; + + /** + * 虹软样本编号。 + */ + @Schema(description = "样本编号") + private String sampleId; + + /** + * 审核通过的人脸图片地址。 + */ + @Schema(description = "人脸图片地址") + private String faceImg; + + /** + * 人脸来源最后更新时间,用于证据审计和图片版本判断。 + */ + @Schema(description = "来源更新时间") + private LocalDateTime sourceUpdatedAt; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java index b6e22ff5..b2d0d574 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java @@ -16,6 +16,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; import com.bsd.pjcloud.admin.service.SysUserFaceService; import com.bsd.pjcloud.common.core.util.R; @@ -77,6 +78,16 @@ public class SysUserFaceController { public R> listStatusCounts(@RequestBody List userIds) { return R.ok(sysUserFaceService.listStatusCounts(userIds)); } + + /** + * Hardware 内部复制订单参考人脸时读取审核通过的人脸来源。 + */ + @Inner + @Operation(summary = "查询订单证据参考人脸来源") + @GetMapping("/inner/evidence-source") + public R evidenceSource(@RequestParam Long userId, @RequestParam String sampleId) { + return R.ok(sysUserFaceService.evidenceSource(userId, sampleId)); + } /* ************************************* end 远程调用 end ************************************* */ /* ************************************* start app start ************************************* */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java index def07295..d088a570 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserFaceMapper.java @@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -23,6 +24,28 @@ import java.util.List; @Mapper public interface SysUserFaceMapper extends PjcloudBaseMapper { + /** + * 查询指定用户审核通过的人脸来源,供订单证据服务复制不可变参考图。 + * + * @param userId 用户主键 + * @param sampleId 虹软样本编号 + * @param processStatus 审核通过状态编码 + * @return 受信图片来源;不存在时为空 + */ + default SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId, String processStatus) { + SysUserFaceEntity entity = selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(SysUserFaceEntity::getUserId, userId) + .eq(SysUserFaceEntity::getAutoCode, sampleId) + .eq(SysUserFaceEntity::getProcessStatus, processStatus)); + if (entity == null) return null; + SysUserFaceEvidenceSourceVo source = new SysUserFaceEvidenceSourceVo(); + source.setUserId(entity.getUserId()); + source.setSampleId(entity.getAutoCode()); + source.setFaceImg(entity.getFaceImg()); + source.setSourceUpdatedAt(entity.getUpdateTime()); + return source; + } + Page scopePageList(Page page, @Param("pa") SysUserFaceDto dto, DataScope dataScope); List groupConcatFaces(@Param("pa") SysUserFaceDto dto); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java index e0868db9..85acfc2a 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; @@ -57,6 +58,15 @@ public interface SysUserFaceService extends IService { */ List listStatusCounts(List userIds); + /** + * 读取审核通过人脸的受信原图来源,供订单证据服务复制订单快照。 + * + * @param userId 用户主键 + * @param sampleId 虹软样本编号 + * @return 原图来源;不存在时为空 + */ + SysUserFaceEvidenceSourceVo evidenceSource(Long userId, String sampleId); + /** * 分页查询当前用户的有效被监护人,或显式指定的本人/有效被监护人人脸审核记录。 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index a0709552..62d2cf93 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -19,6 +19,7 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncRecordVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo; import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo; @@ -263,6 +264,15 @@ public class SysUserFaceServiceImpl extends ServiceImpl Date: Sun, 23 Aug 2026 19:12:15 +0800 Subject: [PATCH 09/13] 11 --- .../order/HardOrderEvidenceMetadataDto.java | 3 ++ .../HardOrderEvidenceImageServiceImpl.java | 29 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java index 7d2f2895..585bbd7b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java @@ -1,5 +1,6 @@ package com.bsd.pjcloud.hard.api.dto.order; +import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; @@ -70,6 +71,7 @@ public class HardOrderEvidenceMetadataDto { /** * 识别时终端人脸快照对应的后台更新时间;旧终端允许为空,新终端用于识别同地址内容替换。 */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @Schema(description = "参考人脸来源版本") private LocalDateTime referenceImageVersion; @@ -93,6 +95,7 @@ public class HardOrderEvidenceMetadataDto { /** * 终端实际完成识别的本地时间;为空时服务端仅保留订单创建时间。 */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @Schema(description = "终端采集时间") private LocalDateTime capturedAt; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java index 1533cc25..dd1f3583 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java @@ -363,8 +363,8 @@ public class HardOrderEvidenceImageServiceImpl extends ServiceImpl sourceResult = remoteUserFaceService.getEvidenceSource(dto.getUserId(), dto.getFaceSampleId()); if (sourceResult == null || !sourceResult.isOk()) { throw new RuntimeException(sourceResult == null || StrUtil.isBlank(sourceResult.getMsg()) @@ -418,15 +418,11 @@ public class HardOrderEvidenceImageServiceImpl extends ServiceImpl uploadResult = remoteFileService.innerUpload(uploadFile, - FILE_DIRECTORY_PREFIX + "/" + order.getId(), null, FILE_TYPE); + private void storeImage(HardOrderEvidenceImageEntity entity, HardOrderEntity order, ImageSnapshot image, String originalName) { + MultipartFile uploadFile = new MockMultipartFile("file", originalName, image.contentType(), image.bytes()); + R uploadResult = remoteFileService.innerUpload(uploadFile, FILE_DIRECTORY_PREFIX + "/" + order.getId(), null, FILE_TYPE); if (uploadResult == null || !uploadResult.isOk() || !(uploadResult.getData() instanceof Map storage)) { - throw new RuntimeException(uploadResult == null || StrUtil.isBlank(uploadResult.getMsg()) - ? "订单证据写入文件中心失败" : uploadResult.getMsg()); + throw new RuntimeException(uploadResult == null || StrUtil.isBlank(uploadResult.getMsg()) ? "订单证据写入文件中心失败" : uploadResult.getMsg()); } entity.setSysFileId(numberValue(storage.get("id"))); entity.setStorageFileName(requiredString(storage.get("fileName"), "文件中心未返回对象名")); @@ -645,13 +641,14 @@ public class HardOrderEvidenceImageServiceImpl extends ServiceImpl Date: Sun, 23 Aug 2026 20:40:22 +0800 Subject: [PATCH 10/13] 11 --- ...60823_add_hard_order_verification_time.sql | 16 +++++ .../hard/api/dto/order/HardOrderDto.java | 5 +- .../order/HardOrderEvidenceOrderRefDto.java | 45 ++++++++++++++ .../dto/order/HardOrderEvidenceSubmitDto.java | 10 +-- .../dto/order/HardOrderEvidenceUploadDto.java | 11 +--- .../api/entity/order/HardOrderEntity.java | 6 ++ .../order/HardOrderEvidenceImageEntity.java | 4 +- .../vo/order/HardCanteenUserApptOrderVo.java | 2 +- .../HardOrderEvidenceImageController.java | 10 +-- .../HardOrderEvidenceImageService.java | 7 ++- .../impl/VerificationRecordServiceImpl.java | 29 ++++++++- ...HardCanteenApptTotalExportServiceImpl.java | 2 +- .../HardCanteenApptTotalServiceImpl.java | 2 +- .../HardOrderEvidenceImageServiceImpl.java | 61 +++++++++++++++---- .../impl/order/HardOrderServiceImpl.java | 34 ++++++++++- .../mapper/order/HardOrderMapper.xml | 10 +++ 16 files changed, 207 insertions(+), 47 deletions(-) create mode 100644 db/20260823_add_hard_order_verification_time.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java diff --git a/db/20260823_add_hard_order_verification_time.sql b/db/20260823_add_hard_order_verification_time.sql new file mode 100644 index 00000000..344c3c96 --- /dev/null +++ b/db/20260823_add_hard_order_verification_time.sql @@ -0,0 +1,16 @@ +-- 硬件订单新增独立核销时间。 +-- 历史二维码核销订单只能使用最后更新时间近似回填;外卖等非核销完成状态不参与回填。 +-- 新核销订单由业务事务写入准确时间,后续订单更新不再改变该值。 + +ALTER TABLE `pjcloudx_hardware`.`hard_order` + ADD COLUMN `verification_time` datetime DEFAULT NULL + COMMENT '订单实际核销完成时间,后续订单更新不得覆盖' AFTER `refund_status`, + ADD KEY `idx_hard_order_verification_time` + (`tenant_id`, `verification_time`, `del_flag`); + +UPDATE `pjcloudx_hardware`.`hard_order` +SET `verification_time` = `update_time` +WHERE ((`order_status` = '20' AND `order_type` IN ('1', '2', '3', '4')) + OR `pay_type` IN ('5', '6', '50')) + AND `verification_time` IS NULL + AND `update_time` IS NOT NULL; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index e7ba33ec..80487abf 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -38,9 +38,12 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private LocalDateTime[] createTimeList; - /** 更新时间范围采用左闭右开区间,供按订单实际状态变更时间查询。 */ + /** 更新时间范围采用左闭右开区间,供兼容旧调用方按订单状态变更时间查询。 */ private LocalDateTime[] updateTimeList; + /** 订单核销时间闭区间,仅按订单实际核销完成时间查询。 */ + private LocalDateTime[] verificationTimeList; + private LocalDateTime[] payTimeList; private LocalDateTime[] refundTimeList; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java new file mode 100644 index 00000000..510a7e4e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 终端订单识别证据的订单定位参数。 + * + *

消费机现场下单继续使用终端订单号对账;预约核销订单不是当前终端创建的,核销成功后改用 + * 服务端订单 ID 定位。两种方式都必须结合请求头中的设备 ID 校验订单归属。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "订单识别证据的订单定位参数") +public abstract class HardOrderEvidenceOrderRefDto extends HardOrderEvidenceMetadataDto { + + /** + * 终端本地稳定订单号,用于消费机现场下单后对账;预约核销证据允许为空。 + */ + @Size(max = 128, message = "终端订单号不能超过128个字符") + @Schema(description = "终端订单号,与服务端订单ID二选一") + private String androidOrderCode; + + /** + * 已存在的服务端订单主键,仅用于当前设备已经成功核销的预约订单;现场下单证据允许为空。 + */ + @Positive(message = "服务端订单ID必须大于0") + @Schema(description = "服务端订单ID,与终端订单号二选一") + private Long orderId; + + /** + * 校验至少提供一种订单定位信息,防止无条件查询或写入订单证据。 + * + * @return 提供终端订单号或服务端订单 ID 时返回 {@code true} + */ + @AssertTrue(message = "终端订单号和服务端订单ID不能同时为空") + public boolean isOrderReferencePresent() { + return StrUtil.isNotBlank(androidOrderCode) || orderId != null; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java index 99cd0168..343b0978 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java @@ -12,15 +12,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @Schema(description = "订单证据元数据提交请求") -public class HardOrderEvidenceSubmitDto extends HardOrderEvidenceMetadataDto { - - /** - * 终端本地稳定订单号,用于接口响应丢失后的服务端订单对账。 - */ - @NotBlank(message = "终端订单号不能为空") - @Size(max = 128, message = "终端订单号不能超过128个字符") - @Schema(description = "终端订单号") - private String androidOrderCode; +public class HardOrderEvidenceSubmitDto extends HardOrderEvidenceOrderRefDto { /** * 终端请求写入的目标状态,仅允许 PENDING 或 UNAVAILABLE;现场图片上传成功由服务端改为 AVAILABLE。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java index 1b02f844..4b8f3363 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java @@ -1,7 +1,6 @@ package com.bsd.pjcloud.hard.api.dto.order; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Data; import lombok.EqualsAndHashCode; @@ -12,15 +11,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @Schema(description = "订单现场证据上传请求") -public class HardOrderEvidenceUploadDto extends HardOrderEvidenceMetadataDto { - - /** - * 终端本地稳定订单号,服务端通过该编号解析真实订单主键。 - */ - @NotBlank(message = "终端订单号不能为空") - @Size(max = 128, message = "终端订单号不能超过128个字符") - @Schema(description = "终端订单号") - private String androidOrderCode; +public class HardOrderEvidenceUploadDto extends HardOrderEvidenceOrderRefDto { /** * 产生识别证据的终端应用版本,用于问题追溯。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java index 7b73513d..d124723d 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java @@ -235,6 +235,12 @@ public class HardOrderEntity extends Model { @Schema(description = "退款状态") private String refundStatus; + /** + * 订单核销完成时间;仅在订单实际完成核销时写入,避免被后续退款等订单更新覆盖。 + */ + @Schema(description = "订单核销时间") + private LocalDateTime verificationTime; + /** * 订单取消时间(退款订单) */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java index 77bd85aa..e7e6c59b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java @@ -39,9 +39,9 @@ public class HardOrderEvidenceImageEntity extends Model resolve(@RequestHeader("DEVICE-ID") Long hardId, - @RequestParam String androidOrderCode) { - return R.ok(hardOrderEvidenceImageService.resolve(hardId, androidOrderCode)); + @RequestParam(required = false) String androidOrderCode, + @RequestParam(required = false) Long orderId) { + return R.ok(hardOrderEvidenceImageService.resolve(hardId, androidOrderCode, orderId)); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java index f46577a8..2312cabb 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java @@ -19,13 +19,14 @@ import java.util.List; public interface HardOrderEvidenceImageService extends IService { /** - * 按终端订单号对账服务端订单和已登记证据。 + * 按终端订单号或服务端订单 ID 对账订单和已登记证据。 * * @param hardId 请求头中的当前设备主键 - * @param androidOrderCode 终端本地稳定订单号 + * @param androidOrderCode 终端本地稳定订单号,预约核销证据允许为空 + * @param orderId 服务端订单主键,现场下单证据允许为空 * @return 服务端订单及证据状态 */ - HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode); + HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode, Long orderId); /** * 提交证据元数据、触发参考图复制或报告永久缺失。 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java index c4a17493..b9364e4d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java @@ -22,15 +22,28 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Objects; +/** 终端预约核销记录查询服务,统一使用订单独立核销时间完成筛选和展示。 */ @Service @RequiredArgsConstructor public class VerificationRecordServiceImpl implements VerificationRecordService { + /** 当前接口支持的预约核销类型协议值。 */ private static final String TYPE_RESERVATION = "RESERVATION"; + /** 设备服务,用于校验请求设备存在且与查询设备一致。 */ private final HardManagerService hardManagerService; + + /** 订单服务,用于按设备、订单类型、状态和核销时间分页查询。 */ private final HardOrderService hardOrderService; + /** + * 分页查询指定设备在日期范围内完成的预约核销记录。 + * + * @param page 分页参数,页码最小为 1、每页最大为 50 + * @param hardId 当前请求头中的设备主键 + * @param dto 核销类型、设备和日期范围查询条件 + * @return 已按核销时间倒序排列的终端核销记录分页 + */ @Override public Page page(Page page, Long hardId, VerificationRecordQueryDto dto) { if (!Objects.equals(hardId, dto.getHardId())) { @@ -57,7 +70,7 @@ public class VerificationRecordServiceImpl implements VerificationRecordService query.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType())); // 活动核销历史上复用了预约订单类型,支付方式范围需明确排除 ACT_CANCEL。 query.setExcludeOrderPayTypeCodes(List.of(HardPayTypeEnum.ACT_CANCEL.getType())); - query.setUpdateTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay()}); + query.setVerificationTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.atTime(23, 59, 59)}); long current = Math.max(1, page.getCurrent()); long size = Math.min(50, Math.max(1, page.getSize())); @@ -71,6 +84,12 @@ public class VerificationRecordServiceImpl implements VerificationRecordService return result; } + /** + * 将订单查询结果转换为终端核销记录展示对象。 + * + * @param order 已完成核销的预约订单 + * @return 终端核销记录 + */ private static VerificationRecordVo convert(HardOrderVo order) { VerificationRecordVo vo = new VerificationRecordVo(); vo.setOrderId(order.getId()); @@ -87,7 +106,15 @@ public class VerificationRecordServiceImpl implements VerificationRecordService return vo; } + /** + * 解析核销记录的展示时间;新数据使用独立核销时间,旧数据按更新时间、支付时间、创建时间依次降级。 + * + * @param order 已核销订单 + * @return 可供终端展示的核销时间,历史数据全部缺失时返回 {@code null} + */ private static LocalDateTime resolveVerificationTime(HardOrderVo order) { + if (order.getVerificationTime() != null) return order.getVerificationTime(); + // 历史订单在新增独立字段前没有核销时间,兼容返回最近更新时间,避免旧记录在终端列表中变为空。 if (order.getUpdateTime() != null) return order.getUpdateTime(); if (order.getPayTime() != null) return order.getPayTime(); return order.getCreateTime(); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java index 8a8698af..8a09f42f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java @@ -885,7 +885,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); detail.setTotalPrice(order.getTotalPrice()); detail.setOrderStatus(order.getOrderStatus()); - detail.setUpdateTime(order.getUpdateTime()); + detail.setUpdateTime(order.getVerificationTime() != null ? order.getVerificationTime() : order.getUpdateTime()); detail.setUpdateBy(order.getUpdateBy()); return detail; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 414e6cf0..86004770 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -773,7 +773,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); detail.setTotalPrice(order.getTotalPrice()); detail.setOrderStatus(order.getOrderStatus()); - detail.setUpdateTime(order.getUpdateTime()); + detail.setUpdateTime(order.getVerificationTime() != null ? order.getVerificationTime() : order.getUpdateTime()); detail.setUpdateBy(order.getUpdateBy()); return detail; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java index dd1f3583..f54f3ba1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderEvidenceImageServiceImpl.java @@ -101,6 +101,11 @@ public class HardOrderEvidenceImageServiceImpl extends ServiceImpl预约订单只有在核销成功后才会把 {@code hard_id} 更新为当前设备,因此终端即使提前知道 + * 服务端订单 ID,也不能在核销前或其他设备上写入证据。

+ * + * @param hardId 当前设备主键 + * @param androidOrderCode 终端本地订单号,允许为空 + * @param orderId 服务端订单主键,允许为空 + * @param requirePaid 是否要求订单处于已支付、已退款或已核销状态 + * @return 属于当前设备的订单 */ - private HardOrderEntity requireOrder(Long hardId, String androidOrderCode, boolean requirePaid) { + private HardOrderEntity requireOrder(Long hardId, String androidOrderCode, Long orderId, boolean requirePaid) { if (hardId == null) throw new RuntimeException("DEVICE-ID不能为空"); - if (StrUtil.isBlank(androidOrderCode)) throw new RuntimeException("终端订单号不能为空"); + if (StrUtil.isBlank(androidOrderCode) && orderId == null) { + throw new RuntimeException("终端订单号和服务端订单ID不能同时为空"); + } HardManagerEntity hard = hardManagerMapper.selectById(hardId); if (hard == null) throw new RuntimeException("当前租户下设备不存在"); - HardOrderEntity order = hardOrderMapper.selectOne(new LambdaQueryWrapper() + LambdaQueryWrapper query = new LambdaQueryWrapper() .eq(HardOrderEntity::getHardId, hardId) - .eq(HardOrderEntity::getAndroidOrderCode, androidOrderCode)); + .eq(orderId != null, HardOrderEntity::getId, orderId) + .eq(StrUtil.isNotBlank(androidOrderCode), HardOrderEntity::getAndroidOrderCode, androidOrderCode); + HardOrderEntity order = hardOrderMapper.selectOne(query); if (order == null) throw new RuntimeException("订单不存在或不属于当前设备"); if (requirePaid && !HardOrderStatusEnum.getPayPrice().contains(order.getOrderStatus())) { throw new RuntimeException("订单尚未确认支付成功,暂不能保存识别证据"); @@ -332,13 +355,29 @@ public class HardOrderEvidenceImageServiceImpl extends ServiceImpl + + and a.verification_time between #{dto.verificationTimeList[0]} and #{dto.verificationTimeList[1]} + and a.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]} @@ -315,6 +319,9 @@ + + order by a.verification_time desc, a.id desc + order by a.update_time desc, a.id desc @@ -338,6 +345,9 @@ + + order by a.verification_time desc, a.id desc + order by a.update_time desc, a.id desc From 8d31f20cec280ea51346c5460ef6f7eda004dffa Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 24 Aug 2026 10:29:04 +0800 Subject: [PATCH 11/13] =?UTF-8?q?feat:=E4=BC=98=E5=8C=96=E4=B8=80=E4=B8=8B?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0260824_optimize_sys_user_page_indexes.sql | 111 +++++++++++++++++ .../pjcloud/admin/mapper/SysPostMapper.java | 10 ++ .../pjcloud/admin/mapper/SysRoleMapper.java | 10 ++ .../admin/mapper/vo/UserPostPageRow.java | 45 +++++++ .../admin/mapper/vo/UserRolePageRow.java | 48 ++++++++ .../service/impl/SysUserServiceImpl.java | 113 +++++++++++++----- .../main/resources/mapper/SysPostMapper.xml | 21 ++++ .../main/resources/mapper/SysRoleMapper.xml | 22 ++++ .../main/resources/mapper/SysUserMapper.xml | 29 +++-- 9 files changed, 366 insertions(+), 43 deletions(-) create mode 100644 db/20260824_optimize_sys_user_page_indexes.sql create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java diff --git a/db/20260824_optimize_sys_user_page_indexes.sql b/db/20260824_optimize_sys_user_page_indexes.sql new file mode 100644 index 00000000..9718c431 --- /dev/null +++ b/db/20260824_optimize_sys_user_page_indexes.sql @@ -0,0 +1,111 @@ +-- 系统用户分页查询索引优化。 +-- +-- 适用接口:POST /admin/user/page、POST /admin/user/select。 +-- 执行影响:仅新增普通索引,不修改字段、约束和历史数据;建索引期间会消耗 IO,建议在业务低峰期执行。 +-- 执行前建议:先备份数据库,并使用文末查询核对生产库已有索引及 EXPLAIN 计划。 + +-- 用户分页的默认排序索引:覆盖租户、逻辑删除和 create_time + user_id 稳定倒序分页。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user' + AND index_name = 'idx_sys_user_tenant_del_created' + ), + 'SELECT ''idx_sys_user_tenant_del_created already exists'' AS message', + 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_created` (`tenant_id`, `del_flag`, `create_time`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 部门数据权限和用户状态分页索引:先缩小部门及审核状态范围,再读取稳定排序列。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user' + AND index_name = 'idx_sys_user_tenant_del_dept_status_created' + ), + 'SELECT ''idx_sys_user_tenant_del_dept_status_created already exists'' AS message', + 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_dept_status_created` (`tenant_id`, `del_flag`, `dept_id`, `process_status`, `create_time`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 角色反向筛选索引:现有主键通常为 user_id + role_id,本索引用于从角色快速定位用户。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_role' + AND index_name = 'idx_sys_user_role_role_user' + ), + 'SELECT ''idx_sys_user_role_role_user already exists'' AS message', + 'ALTER TABLE `sys_user_role` ADD INDEX `idx_sys_user_role_role_user` (`role_id`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 当前页人脸聚合及有无人脸 EXISTS 筛选索引,避免按用户查询时回表扫描租户全部人脸。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_face' + AND index_name = 'idx_sys_user_face_tenant_user_del' + ), + 'SELECT ''idx_sys_user_face_tenant_user_del already exists'' AS message', + 'ALTER TABLE `sys_user_face` ADD INDEX `idx_sys_user_face_tenant_user_del` (`tenant_id`, `user_id`, `del_flag`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 身份类型反向筛选索引:支持 identity_type 条件先定位用户,用户 ID 同时用于半连接。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_identity' + AND index_name = 'idx_sys_user_identity_type_user' + ), + 'SELECT ''idx_sys_user_identity_type_user already exists'' AS message', + 'ALTER TABLE `sys_user_identity` ADD INDEX `idx_sys_user_identity_type_user` (`identity_type`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 数据权限计算会读取当前租户有效部门并在内存构建子树,本索引降低部门全集读取成本。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_dept' + AND index_name = 'idx_sys_dept_tenant_del_parent' + ), + 'SELECT ''idx_sys_dept_tenant_del_parent already exists'' AS message', + 'ALTER TABLE `sys_dept` ADD INDEX `idx_sys_dept_tenant_del_parent` (`tenant_id`, `del_flag`, `parent_id`, `dept_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 执行后核对新增索引;确认无重复或同前缀冗余索引后,再按生产变更流程决定是否清理旧索引。 +SELECT table_name, index_name, seq_in_index, column_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name IN ('sys_user', 'sys_user_role', 'sys_user_face', 'sys_user_identity', 'sys_dept') +ORDER BY table_name, index_name, seq_in_index; + +-- 建议在执行索引后更新统计信息,并用真实租户、部门和状态参数执行 EXPLAIN ANALYZE。 +-- ANALYZE TABLE sys_user, sys_user_role, sys_user_face, sys_user_identity, sys_dept; diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java index f875cb1c..c020ea5d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java @@ -18,8 +18,10 @@ package com.bsd.pjcloud.admin.mapper; import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.mapper.vo.UserPostPageRow; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -39,4 +41,12 @@ public interface SysPostMapper extends PjcloudBaseMapper { */ List listPostsByUserId(Long userId); + /** + * 批量查询当前分页用户的岗位,返回用户 ID 与岗位资料的扁平行,避免逐用户嵌套查询。 + * + * @param userIds 当前页用户主键列表;为空时不应调用 + * @return 按用户 ID、岗位排序值和岗位 ID 升序返回岗位资料,未分配岗位的用户不产生记录 + */ + List listPostsByUserIds(@Param("userIds") List userIds); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java index fd852b90..7632019e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java @@ -20,8 +20,10 @@ package com.bsd.pjcloud.admin.mapper; import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.mapper.vo.UserRolePageRow; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -43,4 +45,12 @@ public interface SysRoleMapper extends PjcloudBaseMapper { */ List listRolesByUserId(Long userId); + /** + * 批量查询当前分页用户的角色,返回用户 ID 与角色资料的扁平行,避免逐用户嵌套查询。 + * + * @param userIds 当前页用户主键列表;为空时不应调用 + * @return 按用户 ID、角色 ID 升序返回角色资料,未分配角色的用户不产生记录 + */ + List listRolesByUserIds(@Param("userIds") List userIds); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java new file mode 100644 index 00000000..2bf0a0e1 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2025, pjcloud All rights reserved. + */ + +package com.bsd.pjcloud.admin.mapper.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户分页岗位批量查询的扁平结果行。 + * + *

该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。

+ */ +@Data +public class UserPostPageRow { + + /** 当前用户主键,来源于 sys_user_post.user_id。 */ + private Long userId; + + /** 岗位主键,来源于 sys_post.post_id。 */ + private Long postId; + + /** 岗位编码,来源于 sys_post.post_code。 */ + private String postCode; + + /** 岗位名称,来源于 sys_post.post_name。 */ + private String postName; + + /** 岗位排序值,来源于 sys_post.post_sort。 */ + private Integer postSort; + + /** 岗位删除标记,来源于 sys_post.del_flag。 */ + private String delFlag; + + /** 岗位创建时间,来源于 sys_post.create_time。 */ + private LocalDateTime createTime; + + /** 岗位更新时间,来源于 sys_post.update_time。 */ + private LocalDateTime updateTime; + + /** 岗位备注,来源于 sys_post.remark。 */ + private String remark; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java new file mode 100644 index 00000000..299d9367 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2025, pjcloud All rights reserved. + */ + +package com.bsd.pjcloud.admin.mapper.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户分页角色批量查询的扁平结果行。 + * + *

该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。

+ */ +@Data +public class UserRolePageRow { + + /** 当前用户主键,来源于 sys_user_role.user_id。 */ + private Long userId; + + /** 角色主键,来源于 sys_role.role_id。 */ + private Long roleId; + + /** 角色名称,来源于 sys_role.role_name。 */ + private String roleName; + + /** 角色编码,来源于 sys_role.role_code。 */ + private String roleCode; + + /** 角色描述,来源于 sys_role.role_desc。 */ + private String roleDesc; + + /** 数据权限类型,来源于 sys_role.ds_type。 */ + private Integer dsType; + + /** 自定义数据权限范围,来源于 sys_role.ds_scope。 */ + private String dsScope; + + /** 角色创建时间,来源于 sys_role.create_time。 */ + private LocalDateTime createTime; + + /** 角色更新时间,来源于 sys_role.update_time。 */ + private LocalDateTime updateTime; + + /** 角色删除标记,来源于 sys_role.del_flag。 */ + private String delFlag; +} 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 3bebaef5..7996b942 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 @@ -51,8 +51,12 @@ import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; import com.bsd.pjcloud.admin.handler.FaceChangeEvent; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.mapper.SysUserMapper; +import com.bsd.pjcloud.admin.mapper.SysPostMapper; +import com.bsd.pjcloud.admin.mapper.SysRoleMapper; import com.bsd.pjcloud.admin.mapper.SysUserPostMapper; import com.bsd.pjcloud.admin.mapper.SysUserRoleMapper; +import com.bsd.pjcloud.admin.mapper.vo.UserPostPageRow; +import com.bsd.pjcloud.admin.mapper.vo.UserRolePageRow; import com.bsd.pjcloud.admin.service.*; import com.bsd.pjcloud.admin.utils.flow.FlowUtil; import com.bsd.pjcloud.admin.utils.flow.dto.UserApplyFlowDto; @@ -141,6 +145,12 @@ public class SysUserServiceImpl extends ServiceImpl impl private final SysUserPostMapper sysUserPostMapper; + /** 角色批量查询 Mapper,用于按当前分页用户一次性回填角色。 */ + private final SysRoleMapper sysRoleMapper; + + /** 岗位批量查询 Mapper,用于按当前分页用户一次性回填岗位。 */ + private final SysPostMapper sysPostMapper; + private final CacheManager cacheManager; private final StringRedisTemplate redisTemplate; @@ -338,29 +348,18 @@ public class SysUserServiceImpl extends ServiceImpl impl IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); // 分页结果统一补齐身份及扩展资料,保证管理端列表与详情接口使用同一套身份语义。 sysUserIdentityService.fillUserIdentities(list.getRecords()); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + // 人脸聚合必须限定为当前页,禁止每次分页扫描并传输当前租户的全部人脸。 + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } - // 审批流程 - if (userDTO.getIsFindCheckUser()) { - List processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(processInstanceIds)) { - List taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds); - if (ObjectUtil.isNotEmpty(taskList)) { - Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); - CollectionUtils.convertList(list.getRecords(), v -> { - TaskDto task = taskMap.get(v.getProcessId()); - if (ObjectUtil.isNotEmpty(task)) { - v.setIsCheckUser(true); - } - return v; - }); - } - } + if (Boolean.TRUE.equals(userDTO.getIsFindCheckUser())) { + fillPendingApprovalFlags(list.getRecords()); } } return list; @@ -408,6 +407,8 @@ public class SysUserServiceImpl extends ServiceImpl impl } IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { + List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); sysUserIdentityService.fillUserIdentities(list.getRecords()); } return list; @@ -418,32 +419,80 @@ public class SysUserServiceImpl extends ServiceImpl impl IPage list = baseMapper.getUserVosPage(page, userDTO, null); if (ObjectUtil.isNotEmpty(list.getRecords())) { List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } - // 审批流程 - if (userDTO.getIsFindCheckUser()) { - List processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(processInstanceIds)) { - List taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds); - if (ObjectUtil.isNotEmpty(taskList)) { - Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); - CollectionUtils.convertList(list.getRecords(), v -> { - TaskDto task = taskMap.get(v.getProcessId()); - if (ObjectUtil.isNotEmpty(task)) { - v.setIsCheckUser(true); - } - return v; - }); - } - } + if (Boolean.TRUE.equals(userDTO.getIsFindCheckUser())) { + fillPendingApprovalFlags(list.getRecords()); } } return list; } + /** + * 按当前页用户批量回填角色和岗位,将原先随分页条数线性增长的嵌套查询固定为两次查询。 + * + * @param userVos 当前页用户展示对象,方法会原位设置角色和岗位集合 + * @param userIds 当前页去重后的用户主键;为空时不访问数据库 + */ + private void fillUserRolesAndPosts(List userVos, List userIds) { + if (CollUtil.isEmpty(userVos) || CollUtil.isEmpty(userIds)) { + return; + } + + Map> rolesByUserId = new HashMap<>(); + for (UserRolePageRow row : sysRoleMapper.listRolesByUserIds(userIds)) { + SysRole role = new SysRole(); + BeanUtils.copyProperties(row, role); + rolesByUserId.computeIfAbsent(row.getUserId(), ignored -> new ArrayList<>()).add(role); + } + + Map> postsByUserId = new HashMap<>(); + for (UserPostPageRow row : sysPostMapper.listPostsByUserIds(userIds)) { + SysPost post = new SysPost(); + BeanUtils.copyProperties(row, post); + postsByUserId.computeIfAbsent(row.getUserId(), ignored -> new ArrayList<>()).add(post); + } + + for (UserVO userVo : userVos) { + userVo.setRoleList(new ArrayList<>(rolesByUserId.getOrDefault(userVo.getUserId(), Collections.emptyList()))); + userVo.setPostList(new ArrayList<>(postsByUserId.getOrDefault(userVo.getUserId(), Collections.emptyList()))); + } + } + + /** + * 查询当前操作人可处理的审核中用户流程,并回填列表的可审批标记。 + * + *

用户业务状态不是“审核中”时不可能存在待处理任务,因此不把已通过、已拒绝或已取消流程发送到远程流程服务。

+ * + * @param userVos 当前页用户展示对象;无认证操作人或无审核中流程时不调用远程服务 + */ + private void fillPendingApprovalFlags(List userVos) { + PjcloudUser operator = SecurityUtils.getUser(); + if (operator == null) { + return; + } + List processInstanceIds = userVos.stream() + .filter(userVo -> ObjectUtil.equals(userVo.getProcessStatus(), TaskCheckStatusEnum.PADDING.getCode())) + .map(UserVO::getProcessId) + .filter(ObjectUtil::isNotEmpty) + .distinct() + .toList(); + if (CollUtil.isEmpty(processInstanceIds)) { + return; + } + List taskList = flowUtil.queryProcessInstanceList(operator.getId().toString(), processInstanceIds); + if (CollUtil.isEmpty(taskList)) { + return; + } + Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); + userVos.forEach(userVo -> userVo.setIsCheckUser(taskMap.containsKey(userVo.getProcessId()))); + } + /** * 通过ID查询用户信息 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml index 4dc219f5..b66b155c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml @@ -50,4 +50,25 @@ and up.user_id = #{userId} + + + diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml index ca8c1a82..3e37c1e7 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml @@ -50,4 +50,26 @@ AND sys_role.del_flag = '0' and sys_user_role.user_id = #{userId} + + + 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 bc0634f3..edd7d48b 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 @@ -208,7 +208,11 @@ u.user_id = #{id} and u.del_flag = '0' - SELECT u.user_id, u.process_id, @@ -224,14 +228,14 @@ u.wx_pc_openid, u.apple_openid, u.qq_openid, - u.gitee_login, - u.osc_id, + u.gitee_login gitee_open_id, + u.osc_id osc_open_id, u.wx_cp_userid, u.wx_ding_userid, u.dept_id, u.create_by, - u.create_time ucreate_time, - u.update_time uupdate_time, + u.create_time create_time, + u.update_time update_time, u.del_flag, u.lock_flag, u.password_expire_flag, @@ -248,7 +252,6 @@ FROM sys_user u LEFT JOIN sys_dept d ON d.dept_id = u.dept_id - LEFT JOIN sys_user_role urole ON urole.user_id = u.user_id u.del_flag = '0' @@ -293,10 +296,15 @@
- and urole.role_id IN - - #{v} - + AND EXISTS ( + SELECT 1 + FROM sys_user_role user_role + WHERE user_role.user_id = u.user_id + AND user_role.role_id IN + + #{v} + + ) @@ -329,7 +337,6 @@ - group by u.user_id ORDER BY u.create_time DESC, u.user_id DESC From d1cd86dfa412ca8e217872cc834ebdd782fc24ed Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 24 Aug 2026 18:39:59 +0800 Subject: [PATCH 12/13] =?UTF-8?q?feat=EF=BC=9A=E9=87=8D=E6=9E=84"=E9=A3=9F?= =?UTF-8?q?=E5=A0=82=E8=8F=9C=E5=93=81=E8=B4=AD=E4=B9=B0=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?"+"=E9=A3=9F=E5=A0=82=E8=8F=9C=E5=93=81=E9=83=A8=E9=97=A8?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1"=E8=8F=9C=E5=8D=95=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E6=A0=B8=E9=94=80=E8=AE=A2=E5=8D=95+?= =?UTF-8?q?=E7=BA=BF=E4=B8=8B=E8=B4=AD=E4=B9=B0=E8=AE=A2=E5=8D=95=20feat?= =?UTF-8?q?=EF=BC=9A=E5=A2=9E=E5=8A=A0=E6=9C=BA=E5=99=A8=E7=9A=84=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=8F=9C=E8=B0=B1=E5=BA=93=E5=AD=98=E6=89=A3=E6=AC=BE?= =?UTF-8?q?=20feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E8=AE=A2=E5=8D=95"=E9=80=80?= =?UTF-8?q?=E5=9B=9E=E5=88=B0=E6=A0=B8=E9=94=80=E5=89=8D"=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260824_add_third_payment_task.sql | 59 + .../order/HardCanteenStatisticsLevelEnum.java | 44 + .../constant/order/HardSaleSourceEnum.java | 57 + .../hard/api/dto/order/HardCarDto.java | 6 + .../hard/api/dto/order/HardOrderDto.java | 10 + .../vo/order/HardCanteenProductTotalVo.java | 22 + .../pjcloud/hard/api/vo/order/HardCarVo.java | 6 + .../hard/api/vo/order/HardOrderVo.java | 6 + .../excel/HardCanteenDeptTotalExcelVo.java | 54 + .../excel/HardCanteenProductTotalExcelVo.java | 46 + .../order/HardCanteenDeptTotalController.java | 23 +- .../order/HardCanteenTotalController.java | 40 + .../controller/order/HardOrderController.java | 14 + .../hard/mapper/order/HardCarMapper.java | 57 + .../hard/service/HardBroadcastService.java | 9 +- .../hard/service/HardCanteenTotalService.java | 28 +- .../hard/service/HardOrderService.java | 8 + .../impl/HardBroadcastServiceImpl.java | 67 + .../impl/order/HardAfterSaleServiceImpl.java | 65 +- .../order/HardCanteenTotalServiceImpl.java | 549 +++++++- .../impl/order/HardOrderServiceImpl.java | 227 +++- .../resources/mapper/order/HardCarMapper.xml | 386 ++++++ .../mapper/order/HardOrderMapper.xml | 40 + .../HardCanteenTotalServiceImplTest.java | 246 ++++ .../HardOrderServiceImplInventoryTest.java | 206 +++ ...rdOrderServiceImplPaymentCallbackTest.java | 57 + .../HardOrderServiceImplVerificationTest.java | 87 +- .../feign/RemoteThirdPaymentTaskService.java | 26 + .../pay/ThirdPaymentTaskStatusEnum.java | 35 + .../pay/ThirdPaymentTaskTypeEnum.java | 66 + .../dto/appplus/ThirdMiniRemotePayDto.java | 5 + .../dto/pay/ThirdPaymentTaskDispatchDto.java | 26 + .../pay/ThirdPaymentTaskAttemptEntity.java | 50 + .../entity/pay/ThirdPaymentTaskEntity.java | 82 ++ .../wechat/ThirdMiniWechatRefundQueryVo.java | 5 + .../thirdparty/vo/pay/ThirdPayOrderVo.java | 97 ++ .../vo/pay/ThirdPaymentTaskDispatchVo.java | 22 + .../thirdparty/config/AppPlusPayConfig.java | 23 + .../pay/ThirdPayOrderController.java | 50 +- .../pay/ThirdPaymentTaskController.java | 67 + .../job/ThirdAggregateOrderPayJob.java | 7 +- .../thirdparty/job/ThirdAppPlusPayJob.java | 364 ++++- .../pay/ThirdPaymentTaskAttemptMapper.java | 12 + .../mapper/pay/ThirdPaymentTaskMapper.java | 154 +++ .../service/ThirdAppPlusAliPayService.java | 56 + .../service/ThirdAppPlusService.java | 7 + .../service/ThirdAppPlusWechatFzService.java | 14 + .../service/ThirdAppPlusWechatService.java | 24 +- .../service/ThirdPayPollingCoordinator.java | 81 ++ .../ThirdPaymentOrderReservationService.java | 73 + .../service/ThirdPaymentTaskEnqueuer.java | 135 ++ .../service/ThirdPaymentTaskService.java | 32 + .../ThirdAggregateOrderPayServiceImpl.java | 72 +- .../ThirdAppPlusAliPayServiceImpl.java | 1190 ++++++++++++++++- .../impl/appplus/ThirdAppPlusServiceImpl.java | 454 +++++-- ...ThirdAppPlusWechatMicropayServiceImpl.java | 32 +- .../ThirdAppPlusWechatServiceImpl.java | 328 ++++- .../fz/ThirdAppPlusWechatFzServiceImpl.java | 346 ++++- .../ThirdAppPlusWechatFzLifecycle.java | 25 +- .../impl/pay/ThirdPayOrderServiceImpl.java | 439 +++++- .../impl/pay/ThirdPaymentTaskServiceImpl.java | 580 ++++++++ .../resources/mapper/ThirdPayOrderMapper.xml | 5 +- .../mapper/ThirdPaymentTaskMapper.xml | 324 +++++ .../job/ThirdAppPlusPayJobTest.java | 84 +- ...ThirdPaymentTaskMapperSqlContractTest.java | 85 ++ ...irdPaymentOrderReservationServiceTest.java | 70 + .../service/ThirdPaymentTaskEnqueuerTest.java | 75 ++ .../ThirdAppPlusAliPayServiceImplTest.java | 372 ++++++ ...irdAppPlusServiceImplOrderCodePayTest.java | 207 +++ .../ThirdAppPlusWechatFzServiceImplTest.java | 199 +++ ...pPlusWechatServiceImplPayCallbackTest.java | 2 + ...irdAppPlusWechatServiceImplRefundTest.java | 276 ++++ .../pay/ThirdPayOrderServiceImplTest.java | 204 +++ pjcloud-visual/pjcloud-daemon-quartz/pom.xml | 6 + .../config/ThirdPaymentQuartzInitializer.java | 96 ++ .../quartz/job/ThirdPaymentDispatchJob.java | 58 + .../src/main/resources/quartz-config.yml | 8 +- .../ThirdPaymentQuartzInitializerTest.java | 59 + .../api/entity/mch/MchPaySettingEntity.java | 15 +- .../api/feign/RemoteCodeUseService.java | 10 + .../controller/WalletCodeUseController.java | 12 + .../mch/MchPaySettingController.java | 2 +- .../wallet/service/MchPaySettingService.java | 45 +- .../wallet/service/WalletCodeUseService.java | 10 +- .../service/WalletTransactionService.java | 11 + .../impl/WalletCodeUseServiceImpl.java | 30 +- .../impl/WalletTransactionServiceImpl.java | 34 + .../impl/mch/MchPaySettingServiceImpl.java | 85 +- .../WalletRechargePackageServiceImpl.java | 10 +- .../impl/WalletCodeUseServiceImplTest.java | 92 ++ .../WalletTransactionServiceImplTest.java | 75 ++ .../mch/MchPaySettingServiceImplTest.java | 123 ++ 92 files changed, 9628 insertions(+), 454 deletions(-) create mode 100644 db/20260824_add_third_payment_task.sql create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImplTest.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplPaymentCallbackTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdPaymentTaskService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskStatusEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/pay/ThirdPaymentTaskTypeEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPaymentTaskDispatchDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskAttemptEntity.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdPaymentTaskEntity.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPaymentTaskDispatchVo.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPaymentTaskController.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskAttemptMapper.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapper.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuer.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImplTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql new file mode 100644 index 00000000..7fc7ded9 --- /dev/null +++ b/db/20260824_add_third_payment_task.sql @@ -0,0 +1,59 @@ +-- thirdparty 支付任务事实表与执行尝试表。 +-- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。 +CREATE TABLE IF NOT EXISTS `third_payment_task` +( + `id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `task_type` varchar(48) NOT NULL COMMENT '任务类型编码', + `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键', + `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键', + `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键', + `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键', + `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号', + `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号', + `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号', + `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号', + `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED', + `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间', + `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先', + `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌', + `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间', + `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数', + `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态', + `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码', + `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要', + `finished_time` datetime DEFAULT NULL COMMENT '任务终态时间', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime NOT NULL COMMENT '更新时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`), + KEY `idx_third_payment_task_due` (`status`, `next_run_time`, `priority`, `id`), + KEY `idx_third_payment_task_lease` (`status`, `lease_expire_time`), + KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`), + KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实'; + +CREATE TABLE IF NOT EXISTS `third_payment_task_attempt` +( + `id` bigint NOT NULL COMMENT '尝试记录主键', + `task_id` bigint NOT NULL COMMENT '任务主键', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `attempt_no` int NOT NULL COMMENT '任务内尝试序号', + `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL', + `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识', + `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌', + `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照', + `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照', + `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态', + `error_code` varchar(128) DEFAULT NULL COMMENT '错误码', + `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要', + `started_time` datetime NOT NULL COMMENT '开始时间', + `finished_time` datetime DEFAULT NULL COMMENT '结束时间', + PRIMARY KEY (`id`), + KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`), + KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试'; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java new file mode 100644 index 00000000..673352bb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 食堂销售统计展示层级协议。 + * + *

该编码仅用于接口返回和管理端组织多维统计行,不对应数据库字段;调用方不得将名称作为稳定判断条件。

+ */ +@Getter +@AllArgsConstructor +public enum HardCanteenStatisticsLevelEnum { + + /** 按自然日汇总全部餐段和当前来源筛选范围。 */ + DATE_TOTAL("DATE_TOTAL", "当日合计"), + + /** 按自然日和餐段汇总当前来源筛选范围。 */ + HOUR_TOTAL("HOUR_TOTAL", "餐段合计"), + + /** 按自然日拆分来源,供当日合计行横向比较。 */ + DATE_SOURCE("DATE_SOURCE", "当日来源"), + + /** 按自然日、餐段拆分来源,供餐段合计行横向比较。 */ + HOUR_SOURCE("HOUR_SOURCE", "餐段来源"); + + /** 前后端接口使用的稳定层级编码,不写入数据库。 */ + private final String code; + + /** 层级的中文展示名称,仅用于界面说明。 */ + private final String description; + + /** + * 根据接口层级编码查找枚举。 + * + * @param code 管理端统计接口返回的层级编码;为空或未知时不匹配 + * @return 匹配的层级枚举,未匹配时返回 {@code null} + */ + public static HardCanteenStatisticsLevelEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(value -> value.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java new file mode 100644 index 00000000..293faed0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java @@ -0,0 +1,57 @@ +package com.bsd.pjcloud.hard.api.constant.order; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.List; + +/** + * 食堂销售统计来源。 + * + *

编码只用于统计查询和管理端展示,不写入订单表;来源由订单类型、预约关联、订单状态以及对应业务时间计算得到。

+ */ +@Getter +public enum HardSaleSourceEnum { + + /** 订单类型 1 的线下购买,统计时间取订单支付时间。 */ + OFFLINE_PURCHASE("OFFLINE_PURCHASE", "线下购买"), + + /** 订单类型 2 且已完成核销的预约,统计时间取订单核销时间。 */ + RESERVATION_VERIFIED("RESERVATION_VERIFIED", "预约核销"); + + /** 统计接口和前端标签页使用的来源编码。 */ + private final String code; + + /** 来源中文名称。 */ + private final String description; + + /** + * 创建稳定的统计来源协议项;编码只用于接口筛选,不对应数据库持久化值。 + * + * @param code 前后端共同使用的来源编码 + * @param description 管理端与导出使用的中文名称 + */ + HardSaleSourceEnum(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 返回全部可筛选来源编码。 + * + * @return 来源编码列表,顺序与管理端标签页一致 + */ + public static List getCodes() { + return Arrays.stream(values()).map(HardSaleSourceEnum::getCode).toList(); + } + + /** + * 按统计来源编码解析枚举。 + * + * @param code 统计接口传入的来源编码 + * @return 匹配的来源;为空或未知编码返回 {@code null} + */ + public static HardSaleSourceEnum valueOfCode(String code) { + return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java index 21b88f3e..d5699377 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java @@ -37,6 +37,12 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf /** 预计使用日期范围。 */ private LocalDateTime[] apptUseDateTimeList; + /** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */ + private LocalDateTime[] statisticsDateTimeList; + + /** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */ + private List saleSourceCodes; + /** * 前端树搜索 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index 80487abf..0a2829e9 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -46,6 +46,16 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private LocalDateTime[] payTimeList; + /** + * 食堂销售统计日期范围,线下来源按支付时间、预约来源按核销时间过滤;不落库。 + */ + private LocalDateTime[] statisticsDateTimeList; + + /** + * 食堂销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示同时查询两种来源。 + */ + private List saleSourceCodes; + private LocalDateTime[] refundTimeList; /** 预计使用日期范围。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java index 3543aa62..d5af79b1 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java @@ -42,9 +42,31 @@ public class HardCanteenProductTotalVo { @Schema(description = "购买次数") private Long timeCount; + /** 统计来源编码,来源于 HardSaleSourceEnum,不对应数据库字段。 */ + private String saleSource; + + /** 统计来源名称,用于管理端和导出展示,不对应数据库字段。 */ + private String saleSourceName; + + /** + * 统计展示层级,取值来自 HardCanteenStatisticsLevelEnum,不对应数据库字段。 + * 空值表示兼容旧统计接口返回结构。 + */ + private String statisticsLevel; + + /** 统计展示层级中文名称,用于管理端说明当前行为日期合计或餐段合计。 */ + private String statisticsLevelName; + + /** + * 当前合计行的来源拆分结果;管理端用它在同一行比较线下购买与预约核销。 + * 空集合表示当前筛选范围没有来源明细或属于旧统计接口。 + */ + private List sourceRows; + @Schema(description = "用餐部门总数") private Integer deptCount; + /** 菜品统计中承载餐段合计行,部门统计中承载部门餐段行;空值表示无下一级统计。 */ private List children; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java index 0ba972bd..daa2fca5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java @@ -9,6 +9,12 @@ import lombok.Data; @Data public class HardCarVo extends HardCarEntity { + /** 统计明细来源编码,来源于 HardSaleSourceEnum,不对应购物车持久化字段。 */ + private String saleSource; + + /** 统计明细来源名称,用于管理端展示,不对应购物车持久化字段。 */ + private String saleSourceName; + /** 超市商品 SPU ID,仅超市统计和展示场景赋值。 */ private Long spuId; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 753f2b7c..ffe257dd 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -13,6 +13,12 @@ import java.util.List; @Data public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface { + /** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */ + private String saleSource; + + /** 统计明细来源名称,用于管理端明确区分支付和核销来源。 */ + private String saleSourceName; + /** * 用户相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java new file mode 100644 index 00000000..44132e02 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java @@ -0,0 +1,54 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 食堂菜品部门销售统计导出行,按日期、食堂、来源、部门和餐段拆分。 + */ +@Data +@ColumnWidth(20) +public class HardCanteenDeptTotalExcelVo { + + /** 业务发生日期;线下取支付日期,预约取核销日期。 */ + @ExcelProperty("日期") + private LocalDate statisticsDate; + + /** 当前用户商户权限范围内的食堂名称。 */ + @ExcelProperty("食堂") + private String merchantName; + + /** 销售来源中文名称,取值为线下购买或预约核销。 */ + @ExcelProperty("来源") + private String saleSourceName; + + /** 下单用户所属部门名称;无法解析时为空。 */ + @ExcelProperty("部门") + private String userDeptName; + + /** 餐段字典名称,来源于 daily_menu。 */ + @ExcelProperty("餐段") + private String hourTypeName; + + /** 当前部门餐段内去重后的交易用户数量,单位为人。 */ + @ExcelProperty("交易人数") + private Long payUserCount; + + /** 当前部门餐段内去重后的订单数量,单位为次。 */ + @ExcelProperty("交易次数") + private Long timeCount; + + /** 退款扣减口径下的有效商品数量,单位为份。 */ + @ExcelProperty("商品数量") + private Long payCount; + + /** 有效商品总金额,单位由分转换为元。 */ + @ExcelProperty("总价(元)") + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java new file mode 100644 index 00000000..8716e2d0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 食堂菜品销售统计导出行,按统计日期、餐段和销售来源拆分。 + */ +@Data +@ColumnWidth(20) +public class HardCanteenProductTotalExcelVo { + + /** 业务发生日期;线下取支付日期,预约取核销日期。 */ + @ExcelProperty("日期") + private LocalDate statisticsDate; + + /** 餐段字典名称,来源于 daily_menu。 */ + @ExcelProperty("餐段") + private String hourTypeName; + + /** 销售来源中文名称,取值为线下购买或预约核销。 */ + @ExcelProperty("来源") + private String saleSourceName; + + /** 来源内去重后的交易用户数量,单位为人。 */ + @ExcelProperty("交易人数") + private Long payUserCount; + + /** 来源内去重后的订单数量,单位为次。 */ + @ExcelProperty("交易次数") + private Long timeCount; + + /** 退款扣减口径下的有效商品数量,单位为份。 */ + @ExcelProperty("商品数量") + private Long payCount; + + /** 有效商品总金额,单位由分转换为元。 */ + @ExcelProperty("总价(元)") + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java index 4df49ca7..0b509485 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java @@ -4,18 +4,20 @@ import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; -import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; -import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -44,8 +46,23 @@ public class HardCanteenDeptTotalController { PjcloudUser user = SecurityUtils.getUser(); dto.setTotalIsFindChildren(true); - dto.setApptIdOrApptRuleIdNotNull(true); return R.ok(canteenTotalService.listByDept(dto, user, dataScope)); } + /** + * 导出食堂部门销售统计,筛选和商户范围与部门统计主表一致。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 按日期、食堂、来源、部门和餐段拆分的 Excel 行 + */ + @Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计") + @SysLog("食堂菜品部门统计导出") + @PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')") + @ResponseExcel(name = "食堂菜品部门统计") + @PostMapping("/export-dept") + public List exportDept(@RequestBody HardOrderDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + return canteenTotalService.exportDept(dto, user, null); + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java index 7c926661..d0ae0980 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java @@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; @@ -21,6 +23,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -32,6 +35,7 @@ import lombok.Data; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -138,9 +142,45 @@ public class HardCanteenTotalController { return R.ok(canteenTotalService.productTotal(page, dto, null)); } + /** + * 导出食堂菜品销售统计,筛选范围与菜品统计主表完全一致。 + * + * @param dto 来源统计日期、来源、食堂、餐段和部门筛选 + * @return 按日期、餐段和来源拆分的 Excel 行 + */ + @Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计") + @SysLog("食堂菜品销售统计导出") + @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')") + @ResponseExcel(name = "食堂菜品销售统计") + @PostMapping("/export-product") + public List exportProduct(@RequestBody HardOrderDto dto) { + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List mchList = remoteMchService.scopeList(mchManageDto).getData(); + if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) { + dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + } + return canteenTotalService.exportProduct(dto, null); + } + + /** + * 查询菜品销售明细;新版来源统计补充当前用户可见食堂范围,旧查询仍沿用原接口行为。 + * + * @param page 分页参数 + * @param dto 商品名称、统计日期、来源、食堂、餐段和部门筛选 + * @return 按商品聚合的销售明细分页结果 + */ @Operation(summary = "菜品出售统计", description = "菜品出售统计") @PostMapping("/proudct-query") public R> proudctQuery(@ParameterObject Page page, @RequestBody HardCarDto dto) { + if (dto.getStatisticsDateTimeList() != null && ObjectUtil.isEmpty(dto.getMchIds())) { + MchManageDto mchManageDto = new MchManageDto(); + mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List mchList = remoteMchService.scopeList(mchManageDto).getData(); + if (ObjectUtil.isNotEmpty(mchList)) { + dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + } + } return R.ok(canteenTotalService.proudctQuery(page, dto)); } 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 7bbedc38..b9936a98 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 @@ -538,6 +538,20 @@ public class HardOrderController { return R.ok(hardwareOrderService.handCancelCode(body)); } + /** + * 将工作人员误核销的线上预订订单恢复到待核销状态,沿用手动核销权限控制。 + * + * @param orderId 已核销的线上预订订单主键 + * @return 订单及预约二维码均恢复成功时返回 {@code true} + */ + @Operation(summary = "admin退回线上预订核销", description = "将已核销的线上预订订单退回到待核销状态") + @PutMapping("/reserve-verification-rollback/{orderId}") + @Idempotent(expireTime = 1, key = "'/hard-order/reserve-verification-rollback' + #orderId ", info = "正在退回核销状态,请勿重复操作") + @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_cancel')") + public R rollbackReserveVerification(@PathVariable Long orderId) { + return R.ok(hardwareOrderService.rollbackReserveVerification(orderId)); + } + @Operation(summary = "分页查询", description = "分页查询") @PostMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java index b7ec5a8b..6fcabb89 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java @@ -36,6 +36,37 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page productTotal(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 按业务发生日期、餐段和来源分页统计菜品销售。 + * + * @param page 分页参数 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 来源独立分组的菜品销售统计页 + */ + Page productTotalStats(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 查询菜品多维统计行且不应用数据库分页。 + * + *

同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重; + * 服务层按完整自然日分页,导出仅选择餐段来源层。

+ * + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 按日期、餐段和来源层级排序的多维统计行 + */ + List listProductTotalStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + + /** + * 按当前主表行口径查询部门汇总,仅供新版菜品统计展开行使用。 + * + * @param dto 单日、餐段、来源、食堂和用户部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 按用户部门拆分且在当前来源范围内独立去重的销售统计行 + */ + List productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); /** 按预约日期和餐段查询导出所需的日期餐段总计。 */ @@ -49,6 +80,15 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page proudctQuery(Page page, @Param("dto") HardCarDto dto); + /** + * 按来源和业务发生时间分页查询菜品汇总明细。 + * + * @param page 分页参数 + * @param dto 单日、来源、食堂、餐段、部门和商品名称筛选 + * @return 按商品聚合的来源明细页 + */ + Page proudctQueryStats(Page page, @Param("dto") HardCarDto dto); + /** * 菜品出售商户统计 */ @@ -56,8 +96,25 @@ public interface HardCarMapper extends PjcloudBaseMapper { List listByDept(@Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 按日期和食堂查询部门统计合计行及其来源拆分行。 + * + * @param dto 统计日期、来源、食堂、餐段和用户部门筛选 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 日期食堂合计行和日期食堂来源行,服务层据此装配主从层级 + */ + List listByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope); + List listByHourType(@Param("dto") HardOrderDto dto); + /** + * 按日期、部门和餐段查询展开合计行及其来源拆分行。 + * + * @param dto 单日、食堂、来源、餐段和用户部门筛选 + * @return 部门餐段合计行和部门餐段来源行,服务层据此装配主从层级 + */ + List listByHourTypeStats(@Param("dto") HardOrderDto dto); + /** * 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java index 162427d9..42cab3c3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBroadcastService.java @@ -24,8 +24,15 @@ public interface HardBroadcastService extends IService { */ void doneBroadcast(HardOrderEntity orderdetail, UserInfo userInfo); + /** + * 将误核销预约订单的取餐广播恢复为待处理,并通知当前商户的取餐设备重新展示。 + * + * @param orderdetail 已由订单服务校验为线上预订且正在回退的订单 + */ + void rollbackDoneBroadcast(HardOrderEntity orderdetail); + /** * android完成一轮广播 */ void androidDoneBroadcast(HardAdBroadcastDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java index 7dec6c5f..45518495 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java @@ -15,6 +15,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; import java.util.List; import java.util.Map; @@ -42,7 +44,12 @@ public interface HardCanteenTotalService { List productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope); /** - * 食堂菜品购买统计 + * 分页查询食堂菜品购买统计。 + * + * @param page 分页参数;来源统计模式下每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单部门数据范围;为空时不追加数据范围条件 + * @return 当日合计和餐段合计行,来源统计模式下每行包含对应来源明细 */ Page productTotal(Page page, HardOrderDto dto, DataScope dataScope); @@ -60,4 +67,23 @@ public interface HardCanteenTotalService { * 食堂菜品购买部门统计 */ List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope); + + /** + * 按页面相同筛选条件导出菜品销售统计。 + * + * @param dto 来源统计请求 + * @param dataScope 部门数据权限;为空时沿用控制器既有范围 + * @return 按日期、餐段和来源拆分的导出行 + */ + List exportProduct(HardOrderDto dto, DataScope dataScope); + + /** + * 按页面相同筛选条件导出部门销售统计。 + * + * @param dto 来源统计请求 + * @param user 当前登录用户,用于沿用商户范围查询 + * @param dataScope 部门数据权限;为空时沿用控制器既有范围 + * @return 按日期、食堂、来源、部门和餐段拆分的导出行 + */ + List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index 91874f94..d5d02cba 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -299,6 +299,14 @@ public interface HardOrderService extends IService { */ Boolean handCancelCode(HandCancelCodeDto dto); + /** + * 将误核销的线上预订订单恢复到待核销状态,并同步恢复对应预约二维码。 + * + * @param orderId 已核销的线上预订订单主键 + * @return 订单及二维码均恢复成功时返回 {@code true} + */ + Boolean rollbackReserveVerification(Long orderId); + /** * 核销二维码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java index f7cfbfe0..0d70dd8a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java @@ -214,6 +214,73 @@ public class HardBroadcastServiceImpl extends ServiceImpl() + .eq(HardBroadcastEntity::getBizId, orderdetail.getId()) + .orderByDesc(HardBroadcastEntity::getCreateTime) + .orderByDesc(HardBroadcastEntity::getId) + .last("limit 1")); + if (ObjectUtil.isEmpty(broadcastDetail)) return; + baseMapper.update(null, new LambdaUpdateWrapper() + .set(HardBroadcastEntity::getIsBroadcast, YesNoEnum.YES.getCode()) + .set(HardBroadcastEntity::getIsDone, YesNoEnum.NO.getCode()) + .set(HardBroadcastEntity::getIsDoneTime, null) + .eq(HardBroadcastEntity::getBizId, String.valueOf(orderdetail.getId()))); + + UserInfo userInfo; + try { + R userResult = remoteUserService.getInnerByUserId(orderdetail.getUserId()); + userInfo = userResult == null ? null : userResult.getData(); + } catch (Exception exception) { + log.warn("|{}|恢复预约广播时查询用户失败,orderId:|{}|,errorType:|{}|", TAG, + orderdetail.getId(), exception.getClass().getSimpleName()); + return; + } + if (userInfo == null || userInfo.getSysUser() == null) return; + List hardList = hardManagerMapper.selectList(new LambdaQueryWrapper() + .eq(HardManagerEntity::getMerchantId, orderdetail.getMerchantId()) + .eq(HardManagerEntity::getIsTakeFood, YesNoEnum.YES.getCode())); + if (ObjectUtil.isEmpty(hardList)) return; + for (HardManagerEntity hard : hardList) { + try { + String topic = hard.getMainTopic().replaceAll("/#", ""); + HardTakeFoodBroadcastMsg msg = new HardTakeFoodBroadcastMsg(); + msg.setTopic(topic); + msg.setMchDeptId(hard.getDeptId()); + msg.setHardwareId(hard.getId()); + msg.setAction(MqttActionEnum.TAKE_FOOD_BROADCAST); + msg.setUserId(String.valueOf(userInfo.getSysUser().getUserId())); + msg.setUsername(userInfo.getSysUser().getName()); + msg.setUserMobile(userInfo.getSysUser().getPhone()); + msg.setId(String.valueOf(broadcastDetail.getId())); + msg.setOrderId(String.valueOf(orderdetail.getId())); + msg.setTitle(broadcastDetail.getTitle()); + msg.setContent(broadcastDetail.getContent()); + msg.setBroadcastMaxCount(broadcastDetail.getBroadcastMaxCount()); + msg.setProductDesc(orderdetail.getProductDesc()); + msg.setPayPrice(orderdetail.getPayPrice()); + msg.setHourType(orderdetail.getHourType()); + msg.setIsDone(YesNoEnum.NO.getCode()); + remoteMqttEventService.sendTakeFoodBroadcast(msg); + } catch (Exception exception) { + log.warn("|{}|恢复预约广播实时通知失败,orderId:|{}|,hardId:|{}|,errorType:|{}|", TAG, + orderdetail.getId(), hard.getId(), exception.getClass().getSimpleName()); + } + } + } + /** * android完成一轮广播 */ 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 ead9a8d6..98ca45dd 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 @@ -23,6 +23,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; @@ -656,12 +657,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl carList = dto.getCarList().stream().map((item) -> HardCarEntity.builder() .productId(item.getProductId()) @@ -1813,18 +1816,38 @@ public class HardAfterSaleServiceImpl extends ServiceImpl售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品, * 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离, * 使重复回调不会重复增加库存。

* - * @param order 原每日菜谱预约订单 + * @param order 原每日菜谱预约订单或机器收银订单 * @param afterSaleId 本次售后单 ID,不能为空 */ private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) { if (afterSaleId == null) { - throw new RuntimeException("每日菜谱退款库存缺少售后单 ID"); + throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); } List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); if (ObjectUtil.isEmpty(refundCars)) { @@ -2072,13 +2095,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - List list = hardCarMapper.productTotalByDept(dto, dataScope); + List list; + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + list = hardCarMapper.productTotalByDeptStats(dto, dataScope); + } else { + list = hardCarMapper.productTotalByDept(dto, dataScope); + } + fillSaleSourceFields(list); hardCanteenProductTotalVoSetDeptData(list); return list; } /** - * 食堂菜品购买统计 + * 分页查询食堂菜品购买统计。 + * + *

新版来源统计先读取日期、餐段和来源的完整多维结果,再按自然日分页装配为“当日合计 -> 餐段合计”, + * 每条合计行携带对应来源明细,避免来源行占据主表分组后无法查看整体数据。旧调用方未传统计日期时仍使用原 SQL。

+ * + * @param page 分页参数;新版统计的每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param dataScope 订单部门数据范围;为空时不追加数据权限条件 + * @return 按自然日分页且包含餐段和来源明细的统计结果 */ @Override public Page productTotal(Page page, HardOrderDto dto, DataScope dataScope) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - Page pageList = hardCarMapper.productTotal(page, dto, null); + Page pageList; + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + List rows = hardCarMapper.listProductTotalStats(dto, dataScope); + boolean includeSourceRows = dto.getSaleSourceCodes().size() > 1; + pageList = assembleProductStatisticsPage(page, rows, ObjectUtil.isNotEmpty(dto.getHourType()), includeSourceRows); + } else { + pageList = hardCarMapper.productTotal(page, dto, dataScope); + } if (ObjectUtil.isNotEmpty(pageList)) { for (HardCanteenProductTotalVo v : pageList.getRecords()) { - if (ObjectUtil.isEmpty(v.getCreateTime())) continue; - LocalDate localDate = v.getCreateTime().toLocalDate(); - v.setUniqueCode(localDate + "_" + v.getHourType()); + fillSaleSourceFields(v); + if (ObjectUtil.isNotEmpty(v.getCreateTime())) { + LocalDate localDate = v.getCreateTime().toLocalDate(); + v.setUniqueCode(localDate + "_" + ObjectUtil.defaultIfNull(v.getStatisticsLevel(), "LEGACY") + "_" + + ObjectUtil.defaultIfNull(v.getHourType(), "ALL")); + } } } return pageList; } + /** + * 将 Mapper 的多维统计行装配为按自然日分页的主表结果。 + * + *

交易人数等合计值直接使用对应数据库分组结果,禁止通过来源行相加生成;来源行只作为横向对比明细挂到合计行。 + * 选择具体餐段时隐藏内容相同的当日合计,避免页面出现两条重复统计。

+ * + * @param requestedPage 调用方请求的页码和每页自然日数量 + * @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行 + * @param hourTypeFiltered 是否已经按单一餐段筛选 + * @param includeSourceRows 是否挂载来源拆分行;仅“全部”来源视图需要展示,单来源视图禁用以避免重复行 + * @return 仅包含当前页自然日的合计行,每行附带来源对比数据 + */ + private Page assembleProductStatisticsPage(Page requestedPage, + List rows, + boolean hourTypeFiltered, + boolean includeSourceRows) { + Page result = new Page<>(requestedPage.getCurrent(), requestedPage.getSize()); + if (ObjectUtil.isEmpty(rows)) { + result.setTotal(0); + result.setRecords(List.of()); + return result; + } + + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + sourceRows.forEach(this::fillStatisticsLevelName); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::statisticsScopeKey, LinkedHashMap::new, Collectors.toList())); + + List totalRows = rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .filter(row -> !hourTypeFiltered + || ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_TOTAL.getCode())) + .toList(); + List statisticsDates = totalRows.stream() + .map(HardCanteenProductTotalVo::getCreateTime) + .filter(ObjectUtil::isNotEmpty) + .map(LocalDateTime::toLocalDate) + .distinct() + .toList(); + + long fromIndex = Math.min((requestedPage.getCurrent() - 1) * requestedPage.getSize(), statisticsDates.size()); + long toIndex = Math.min(fromIndex + requestedPage.getSize(), statisticsDates.size()); + Set currentDates = new HashSet<>(statisticsDates.subList((int) fromIndex, (int) toIndex)); + List currentRows = totalRows.stream() + .filter(row -> row.getCreateTime() != null && currentDates.contains(row.getCreateTime().toLocalDate())) + .peek(row -> { + HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel()); + row.setStatisticsLevelName(level == null ? null : level.getDescription()); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(statisticsScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + result.setTotal(statisticsDates.size()); + result.setRecords(currentRows); + return result; + } + + /** + * 构造当日或餐段统计的来源挂载键。 + * + * @param row 合计行或来源行;日期为空时返回稳定空日期片段 + * @return “日期_餐段”键,当日层级的餐段片段固定为 ALL + */ + private String statisticsScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL"); + } + /** * 菜品出售统计 */ @Override public Page proudctQuery(Page page, HardCarDto dto) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); + if (isSourceStatistics(dto)) { + normalizeSaleSources(dto); + Page result = hardCarMapper.proudctQueryStats(page, dto); + fillSaleSourceFields(result == null ? null : result.getRecords()); + return result; + } return hardCarMapper.proudctQuery(page, dto); } @@ -219,7 +337,16 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 食堂菜品购买部门统计 + * 查询食堂菜品部门统计。 + * + *

新版来源统计以“日期 + 食堂”的跨来源合计作为主行,并将来源拆分挂载到主行;展开后再以 + * “部门 + 餐段”的跨来源合计为主行并挂载来源拆分。所有合计均来自数据库独立聚合,避免通过 + * 来源行相加导致交易人数重复。未传统计日期范围时继续使用旧查询结构。

+ * + * @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细 + * @param user 当前登录用户,保留给既有商户范围契约 + * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 + * @return 食堂部门统计主行;新版主行附带来源拆分和部门餐段明细 */ @Override public List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { @@ -231,30 +358,56 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { List mchList = remoteMchService.scopeList(mchListDto).getData(); if (ObjectUtil.isEmpty(mchList)) return null; dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); - List list = hardCarMapper.listByDept(dto, dataScope); + boolean sourceStatistics = isSourceStatistics(dto); + if (sourceStatistics) normalizeSaleSources(dto); + List mapperRows = sourceStatistics + ? hardCarMapper.listByDeptStats(dto, dataScope) + : hardCarMapper.listByDept(dto, dataScope); + boolean includeSourceRows = sourceStatistics && dto.getSaleSourceCodes().size() > 1; + List list = sourceStatistics + ? assembleDepartmentMainRows(mapperRows, includeSourceRows) + : mapperRows; + fillSaleSourceFields(list); if (ObjectUtil.isNotEmpty(list) && ObjectUtil.isNotEmpty(dto.getTotalIsFindChildren()) && dto.getTotalIsFindChildren()) { // 限制最大线程数为10 int threadCount = Math.min(list.size(), 10); - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + ExecutorService executorService = threadCount > 0 ? Executors.newFixedThreadPool(threadCount) : null; try { List> futures = list.stream() .map(v -> CompletableFuture.runAsync(() -> { // 查询时间段 HardOrderDto deptQuery = BeanUtil.toBean(dto, HardOrderDto.class); deptQuery.setMerchantId(v.getMchId()); - List deptList = hardCarMapper.listByHourType(deptQuery); + if (sourceStatistics) { + deptQuery.setCreateTimeStr(v.getCreateTime() == null ? null : v.getCreateTime().toLocalDate().toString()); + deptQuery.setOrderApptUseDateStr(null); + } + List deptMapperRows = sourceStatistics + ? hardCarMapper.listByHourTypeStats(deptQuery) + : hardCarMapper.listByHourType(deptQuery); + List deptList = sourceStatistics + ? assembleDepartmentChildRows(deptMapperRows, v.getMchId(), includeSourceRows) + : deptMapperRows; for (HardCanteenProductTotalVo j : deptList) { if (ObjectUtil.isNotEmpty(v.getMchId())) j.setMchId(v.getMchId()); + fillSaleSourceFields(j); } - v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size()); + if (!sourceStatistics) { + v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size()); + } v.setChildren(deptList); + if (sourceStatistics && includeSourceRows) { + for (HardCanteenProductTotalVo sourceRow : v.getSourceRows()) { + sourceRow.setChildren(departmentSourceChildren(deptList, sourceRow.getSaleSource())); + } + } }, executorService)) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } finally { - executorService.shutdown(); + if (executorService != null) executorService.shutdown(); } } @@ -262,9 +415,381 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { hardCanteenProductTotalVoSetMchData(list); // 添加部门信息 hardCanteenProductTotalVoSetDeptData(list); + if (sourceStatistics) inheritDepartmentRowContext(list); return list; } + /** + * 将部门统计 Mapper 主行装配为“日期 + 食堂合计 -> 来源拆分”的展示结构。 + * + * @param rows Mapper 返回的日期食堂合计行和日期食堂来源行 + * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 + * @return 日期食堂合计行列表,顺序沿用 Mapper 的日期和食堂排序 + */ + private List assembleDepartmentMainRows(List rows, + boolean includeSourceRows) { + if (ObjectUtil.isEmpty(rows)) return List.of(); + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + sourceRows.forEach(this::fillStatisticsLevelName); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::departmentMainScopeKey, LinkedHashMap::new, Collectors.toList())); + return rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .peek(row -> { + fillStatisticsLevelName(row); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(departmentMainScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + } + + /** + * 将部门展开 Mapper 行装配为“部门 + 餐段合计 -> 来源拆分”的展示结构。 + * + * @param rows Mapper 返回的部门餐段合计行和部门餐段来源行 + * @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围 + * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 + * @return 部门餐段合计行列表 + */ + private List assembleDepartmentChildRows(List rows, + Long merchantId, + boolean includeSourceRows) { + if (ObjectUtil.isEmpty(rows)) return List.of(); + rows.forEach(row -> row.setMchId(merchantId)); + List sourceRows = rows.stream() + .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) + .toList(); + fillSaleSourceFields(sourceRows); + Map> sourceRowsByScope = sourceRows.stream() + .collect(Collectors.groupingBy(this::departmentChildScopeKey, LinkedHashMap::new, Collectors.toList())); + return rows.stream() + .filter(row -> ObjectUtil.isEmpty(row.getSaleSource())) + .peek(row -> { + fillStatisticsLevelName(row); + row.setSourceRows(includeSourceRows + ? sourceRowsByScope.getOrDefault(departmentChildScopeKey(row), List.of()) + : List.of()); + }) + .toList(); + } + + /** + * 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。 + * + * @param totalRows 部门餐段合计行,每行可能挂载来源拆分 + * @param saleSource 主表来源行对应的稳定来源编码 + * @return 指定来源的部门餐段行;没有匹配数据时返回空列表 + */ + private List departmentSourceChildren(List totalRows, + String saleSource) { + if (ObjectUtil.isEmpty(totalRows) || ObjectUtil.isEmpty(saleSource)) return List.of(); + return totalRows.stream() + .flatMap(row -> row.getSourceRows().stream()) + .filter(row -> ObjectUtil.equals(row.getSaleSource(), saleSource)) + .toList(); + } + + /** + * 构造部门主表合计行和来源行的挂载键。 + * + * @param row 日期食堂统计行 + * @return “日期_食堂”稳定键;空值使用占位片段避免空指针 + */ + private String departmentMainScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getMchId(), -1L); + } + + /** + * 构造部门展开合计行和来源行的挂载键。 + * + * @param row 部门餐段统计行 + * @return “日期_部门_餐段”稳定键;空值使用占位片段避免空指针 + */ + private String departmentChildScopeKey(HardCanteenProductTotalVo row) { + String statisticsDate = row == null || row.getCreateTime() == null + ? "UNKNOWN" : row.getCreateTime().toLocalDate().toString(); + return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getUserDeptId(), -1L) + + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL"); + } + + /** + * 补充统计层级中文名称,来源编码为空时仍保留合计层级语义。 + * + * @param row Mapper 返回的统计行;层级为空时保持兼容旧接口 + */ + private void fillStatisticsLevelName(HardCanteenProductTotalVo row) { + if (row == null || ObjectUtil.isEmpty(row.getStatisticsLevel())) return; + HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel()); + if (level != null) row.setStatisticsLevelName(level.getDescription()); + } + + /** + * 递归将合计行的食堂、部门、日期和餐段上下文补充到来源拆分行。 + * + *

来源行来自独立聚合 SQL,只携带分组键;页面把它展开为可直接操作的普通行,因此必须继承名称和 + * 查询范围,保证购买人员与菜品明细继续使用当前食堂、部门和餐段。

+ * + * @param rows 已完成食堂和部门名称填充的合计行列表 + */ + private void inheritDepartmentRowContext(List rows) { + if (ObjectUtil.isEmpty(rows)) return; + for (HardCanteenProductTotalVo row : rows) { + inheritDepartmentRowContext(row, row.getChildren()); + inheritDepartmentRowContext(row, row.getSourceRows()); + } + } + + /** + * 将父级业务维度复制到缺少对应值的子级统计行,并继续处理其下级数据。 + * + * @param parent 提供食堂、部门、日期和餐段上下文的父级统计行 + * @param children 待补充上下文的合计行或来源拆分行 + */ + private void inheritDepartmentRowContext(HardCanteenProductTotalVo parent, + List children) { + if (parent == null || ObjectUtil.isEmpty(children)) return; + for (HardCanteenProductTotalVo child : children) { + if (child.getMchId() == null) child.setMchId(parent.getMchId()); + if (ObjectUtil.isEmpty(child.getMchName())) child.setMchName(parent.getMchName()); + if (child.getUserDeptId() == null) child.setUserDeptId(parent.getUserDeptId()); + if (ObjectUtil.isEmpty(child.getUserDeptName())) child.setUserDeptName(parent.getUserDeptName()); + if (child.getCreateTime() == null) child.setCreateTime(parent.getCreateTime()); + if (ObjectUtil.isEmpty(child.getHourType())) child.setHourType(parent.getHourType()); + inheritDepartmentRowContext(child, child.getChildren()); + inheritDepartmentRowContext(child, child.getSourceRows()); + } + } + + /** + * 导出菜品统计,直接复用来源统计 SQL,确保导出与主表的业务发生时间口径一致。 + * + * @param dto 来源统计请求 + * @param dataScope 部门数据权限 + * @return Excel 导出行 + */ + @Override + public List exportProduct(HardOrderDto dto, DataScope dataScope) { + if (!isSourceStatistics(dto)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + normalizeSaleSources(dto); + List rows = hardCarMapper.listProductTotalStats(dto, dataScope).stream() + .filter(row -> ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_SOURCE.getCode())) + .toList(); + fillSaleSourceFields(rows); + Map hourTypeNames = getHourTypeNames(); + if (ObjectUtil.isEmpty(rows)) return List.of(); + return rows.stream().map(row -> { + HardCanteenProductTotalExcelVo excel = new HardCanteenProductTotalExcelVo(); + excel.setStatisticsDate(row.getCreateTime() == null ? null : row.getCreateTime().toLocalDate()); + excel.setHourTypeName(hourTypeNames.getOrDefault(row.getHourType(), row.getHourType())); + excel.setSaleSourceName(row.getSaleSourceName()); + excel.setPayUserCount(row.getPayUserCount()); + excel.setTimeCount(row.getTimeCount()); + excel.setPayCount(row.getPayCount()); + excel.setTotalPrice(centsToYuan(row.getTotalPrice())); + return excel; + }).toList(); + } + + /** + * 导出部门统计,复用部门主表及子行装配结果后扁平化为 Excel 行。 + * + * @param dto 来源统计请求 + * @param user 当前登录用户 + * @param dataScope 部门数据权限 + * @return Excel 导出行 + */ + @Override + public List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { + if (!isSourceStatistics(dto)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + dto.setTotalIsFindChildren(true); + List rows = listByDept(dto, user, dataScope); + if (ObjectUtil.isEmpty(rows)) return List.of(); + Map hourTypeNames = getHourTypeNames(); + List result = new ArrayList<>(); + for (HardCanteenProductTotalVo parent : rows) { + if (ObjectUtil.isEmpty(parent.getChildren())) continue; + for (HardCanteenProductTotalVo child : parent.getChildren()) { + List exportRows = ObjectUtil.isNotEmpty(child.getSourceRows()) + ? child.getSourceRows() : List.of(child); + for (HardCanteenProductTotalVo exportRow : exportRows) { + HardCanteenDeptTotalExcelVo excel = new HardCanteenDeptTotalExcelVo(); + excel.setStatisticsDate(parent.getCreateTime() == null ? null : parent.getCreateTime().toLocalDate()); + excel.setMerchantName(parent.getMchName()); + excel.setSaleSourceName(resolveDepartmentExportSourceName(exportRow, dto)); + excel.setUserDeptName(exportRow.getUserDeptName()); + excel.setHourTypeName(hourTypeNames.getOrDefault(exportRow.getHourType(), exportRow.getHourType())); + excel.setPayUserCount(exportRow.getPayUserCount()); + excel.setTimeCount(exportRow.getTimeCount()); + excel.setPayCount(exportRow.getPayCount()); + excel.setTotalPrice(centsToYuan(exportRow.getTotalPrice())); + result.add(excel); + } + } + } + return result; + } + + /** + * 解析部门导出的具体销售来源名称。 + * + *

多来源导出使用来源拆分行自身名称;单来源查询为避免页面重复行不会挂载来源拆分, + * 此时使用已经校验过的请求来源补全名称。

+ * + * @param row 待导出的部门餐段统计行 + * @param dto 已完成来源标准化的统计请求 + * @return 可追溯的具体来源中文名称;来源异常时返回空值 + */ + private String resolveDepartmentExportSourceName(HardCanteenProductTotalVo row, HardOrderDto dto) { + if (row != null && ObjectUtil.isNotEmpty(row.getSaleSourceName())) return row.getSaleSourceName(); + if (dto == null || ObjectUtil.isEmpty(dto.getSaleSourceCodes()) || dto.getSaleSourceCodes().size() != 1) return null; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(dto.getSaleSourceCodes().get(0)); + return source == null ? null : source.getDescription(); + } + + /** + * 读取餐段字典并生成编码到名称的映射;远程数据为空时导出保留原编码。 + * + * @return 餐段编码与中文名称映射 + */ + private Map getHourTypeNames() { + try { + List items = remoteDictService.getDictByType("daily_menu").getData(); + if (ObjectUtil.isEmpty(items)) return Map.of(); + return items.stream().filter(item -> ObjectUtil.isNotEmpty(item.getItemValue()) && ObjectUtil.isNotEmpty(item.getLabel())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left)); + } catch (Exception exception) { + log.warn("{}:读取餐段字典失败,导出将保留餐段编码", TAG, exception); + return Map.of(); + } + } + + /** + * 将持久化使用的分转换为导出展示使用的元,空金额按零处理。 + * + * @param cents 金额,单位为分 + * @return 保留两位小数的元金额 + */ + private BigDecimal centsToYuan(Long cents) { + return BigDecimal.valueOf(ObjectUtil.defaultIfNull(cents, 0L)).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 判断是否启用来源统计查询。未传统计日期时保留旧 SQL,兼容历史页面和其他服务调用方。 + * + * @param dto 统计请求 + * @return 日期范围完整时启用新版来源统计 + */ + private boolean isSourceStatistics(HardOrderDto dto) { + return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList()); + } + + /** + * 判断菜品明细请求是否启用来源统计查询。 + * + * @param dto 菜品明细请求 + * @return 日期范围存在且有效时返回 true + */ + private boolean isSourceStatistics(HardCarDto dto) { + return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList()); + } + + /** + * 校验统计日期闭区间;未传时表示兼容旧查询,传入后必须包含有效的开始和结束时间。 + * + * @param range 统计日期时间范围 + * @return 未传返回 false,合法范围返回 true + */ + private boolean validateStatisticsDateRange(LocalDateTime[] range) { + if (range == null) return false; + if (range.length != 2 || range[0] == null || range[1] == null) { + throw new IllegalArgumentException("统计日期范围必须包含开始和结束时间"); + } + if (range[1].isBefore(range[0])) { + throw new IllegalArgumentException("统计结束时间不能早于开始时间"); + } + return true; + } + + /** + * 校验来源编码并补全空来源为全部来源,避免未知编码导致动态 UNION 生成空 SQL。 + * + * @param dto 统计请求 + */ + private void normalizeSaleSources(HardOrderDto dto) { + dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes())); + } + + /** + * 校验菜品明细来源并将空来源补全为全部来源。 + * + * @param dto 菜品明细请求 + */ + private void normalizeSaleSources(HardCarDto dto) { + dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes())); + } + + /** + * 统一校验来源编码并去重,保证 Mapper 只接收稳定的来源协议值。 + * + * @param sources 调用方传入的来源编码 + * @return 非空且去重后的来源编码 + */ + private List normalizeSaleSources(List sources) { + if (ObjectUtil.isEmpty(sources)) { + return HardSaleSourceEnum.getCodes(); + } + Set supported = new HashSet<>(HardSaleSourceEnum.getCodes()); + if (sources.stream().anyMatch(source -> !supported.contains(source))) { + throw new IllegalArgumentException("销售来源编码不正确"); + } + return sources.stream().distinct().toList(); + } + + /** + * 为统计主表、展开行或菜品明细批量补充来源中文名称。 + * + * @param rows Mapper 返回的来源统计行;为空时不处理 + */ + private void fillSaleSourceFields(List rows) { + if (ObjectUtil.isEmpty(rows)) return; + for (Object row : rows) { + if (row instanceof HardCanteenProductTotalVo total) fillSaleSourceFields(total); + if (row instanceof HardCarVo car) fillSaleSourceFields(car); + } + } + + /** + * 为菜品统计主表或展开行补充来源名称。 + * + * @param row 单条菜品统计行;来源为空时保留兼容旧查询的空值 + */ + private void fillSaleSourceFields(HardCanteenProductTotalVo row) { + if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource()); + if (source != null) row.setSaleSourceName(source.getDescription()); + } + + /** + * 为菜品汇总明细补充来源名称。 + * + * @param row 单条菜品明细;来源为空时保留兼容旧查询的空值 + */ + private void fillSaleSourceFields(HardCarVo row) { + if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return; + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource()); + if (source != null) row.setSaleSourceName(source.getDescription()); + } + /** * 为HardCanteenProductTotalVo增加部门信息 */ 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 35896c07..a70c072a 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 @@ -43,6 +43,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum; +import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum; import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; @@ -178,6 +179,9 @@ public class HardOrderServiceImpl extends ServiceImpl已支付 if (ObjectUtil.equals(dto.getStatus(), HardUpdateShopStatusDto.ChangeShopStatus.PAY_SUCCESS)) { if (ObjectUtil.notEqual(order.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) { + if (isShopAlreadyPaid(order.getOrderStatus())) { + return true; + } log.error("|更改商城订单状态|将订单状态从`CREATE(1)`修改到`PAY(5)`错误订单并不是`CREATE(1)`状态"); return false; } @@ -2884,6 +2924,18 @@ public class HardOrderServiceImpl extends ServiceImpl() + .set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()) + .set(HardOrderEntity::getVerificationTime, null) + .set(HardOrderEntity::getHardId, null) + .set(HardOrderEntity::getAndroidOrderCode, null) + .eq(HardOrderEntity::getId, order.getId()) + .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CANCEL.getType())); + if (updated != 1) { + throw new RuntimeException("订单状态已变化,退回核销状态失败"); + } + + if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) { + stopPendingMealVerifiedOutbox(order); + hardBroadcastService.rollbackDoneBroadcast(order); + } + // Wallet 是独立远程事务,放在所有 Hardware 本地写操作之后,减少本地失败后二维码已提前恢复的不一致窗口。 + R codeResult = remoteCodeUseService.rollbackReservationCode(order.getId()); + if (codeResult == null || !codeResult.isOk() || !Boolean.TRUE.equals(codeResult.getData())) { + throw new RuntimeException(codeResult == null + ? "恢复预约核销码无响应" + : StrUtil.blankToDefault(codeResult.getMsg(), "恢复预约核销码失败")); + } + return true; + } + + /** + * 校验订单是否允许从已核销恢复到待核销。退款处理中禁止回退;食堂预约还必须至少保留一份 + * 可履约商品,避免把已经全额退款的历史订单重新暴露给消费机。 + * + * @param order 已持有数据库行锁的订单快照 + */ + private void validateReservationCanRollback(HardOrderEntity order) { + if (ObjectUtil.notEqual(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) { + throw new RuntimeException("仅线上预订订单支持退回到核销前"); + } + if (!List.of(HardTypeEnum.CANTEEN.getType(), HardTypeEnum.SUPERMARKET.getType()).contains(order.getSysType())) { + throw new RuntimeException("当前业务类型不支持退回到核销前"); + } + if (ObjectUtil.notEqual(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())) { + throw new RuntimeException("仅已核销订单支持退回到核销前"); + } + if (!isReservationRefundStatusFulfillable(order.getRefundStatus())) { + throw new RuntimeException("该预约订单正在退款,不能退回到核销前"); + } + if (ObjectUtil.notEqual(order.getSysType(), HardTypeEnum.CANTEEN.getType())) return; + List cars = hardCarService.getByOrderId(order.getId()); + boolean hasFulfillableProduct = ObjectUtil.isNotEmpty(cars) + && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); + if (!hasFulfillableProduct) { + throw new RuntimeException("该预约订单商品已全部退款,不能退回到核销前"); + } + } + + /** + * 停止尚未完成的核销消息投递。已经成功发送的消息属于历史事实无法撤回,继续保留成功状态; + * 再次正确核销时会重新启用未成功记录并刷新业务发生时间。 + * + * @param order 正在退回核销状态的食堂预约订单 + */ + private void stopPendingMealVerifiedOutbox(HardOrderEntity order) { + hardMessageOutboxMapper.update(null, new LambdaUpdateWrapper() + .set(HardMessageOutboxEntity::getStatus, "FAILED") + .set(HardMessageOutboxEntity::getNextRetryTime, null) + .set(HardMessageOutboxEntity::getLastError, MEAL_VERIFICATION_ROLLBACK_OUTBOX_REASON) + .eq(HardMessageOutboxEntity::getEventCode, AppMessageEventEnum.MEAL_VERIFIED.getCode()) + .eq(HardMessageOutboxEntity::getOrderId, order.getId()) + .in(HardMessageOutboxEntity::getStatus, "PENDING", "PROCESSING", "RETRY")); + } + /** * 校验食堂预约订单是否仍可履约,确保旧终端直接调用通用核销接口时不能绕过退款状态与剩余数量。 * @@ -3788,6 +3924,28 @@ public class HardOrderServiceImpl extends ServiceImpl() + .eq(HardMessageOutboxEntity::getEventCode, AppMessageEventEnum.MEAL_VERIFIED.getCode()) + .eq(HardMessageOutboxEntity::getOrderId, order.getId()) + .last("limit 1")); + if (existing != null) { + if (ObjectUtil.equals(existing.getStatus(), "SUCCESS")) return; + int updated = hardMessageOutboxMapper.update(null, new LambdaUpdateWrapper() + .set(HardMessageOutboxEntity::getSourceMchId, order.getMerchantId()) + .set(HardMessageOutboxEntity::getSourceMchName, resolveMessageMerchantName(order.getMerchantId())) + .set(HardMessageOutboxEntity::getMealName, StrUtil.blankToDefault(order.getProductDesc(), "预约用餐")) + .set(HardMessageOutboxEntity::getOccurredTime, verificationTime) + .set(HardMessageOutboxEntity::getStatus, "PENDING") + .set(HardMessageOutboxEntity::getAttemptCount, 0) + .set(HardMessageOutboxEntity::getNextRetryTime, null) + .set(HardMessageOutboxEntity::getProcessedTime, null) + .set(HardMessageOutboxEntity::getLastError, null) + .eq(HardMessageOutboxEntity::getId, existing.getId())); + if (updated != 1) { + throw new IllegalStateException("重新启用用餐核销消息Outbox失败"); + } + return; + } HardMessageOutboxEntity outbox = new HardMessageOutboxEntity(); outbox.setTenantId(order.getTenantId()); outbox.setEventCode(AppMessageEventEnum.MEAL_VERIFIED.getCode()); @@ -3995,28 +4153,29 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, - YesNoEnum isReduce, Long afterSaleId) { + private void updateCanteenSpuOrderInventory(HardOrderEntity order, List refundCarList, + YesNoEnum isReduce, Long afterSaleId, boolean validateOnly) { if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { - throw new RuntimeException("每日菜谱退款库存缺少售后单 ID"); + throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); } List carList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(carList)) { - throw new RuntimeException("每日菜谱库存操作失败,购物车不存在"); + throw new RuntimeException("食堂菜品库存操作失败,购物车不存在"); } if (ObjectUtil.isNotEmpty(refundCarList)) { carList = filterRefundInventoryCars(carList, refundCarList); } if (ObjectUtil.isEmpty(carList)) { - throw new RuntimeException("每日菜谱库存操作失败,退款商品不存在"); + throw new RuntimeException("食堂菜品库存操作失败,退款商品不存在"); } int factor = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? -1 : 1; @@ -4028,8 +4187,8 @@ public class HardOrderServiceImpl extends ServiceImpl> productResult = remoteCanteenSpuService .getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet())); if (productResult == null || !productResult.isOk() || productResult.getData() == null) { - throw new RuntimeException(productResult == null ? "查询每日菜谱库存无响应" - : StrUtil.blankToDefault(productResult.getMsg(), "查询每日菜谱库存失败")); + throw new RuntimeException(productResult == null ? "查询食堂菜品库存无响应" + : StrUtil.blankToDefault(productResult.getMsg(), "查询食堂菜品库存失败")); } Map productMap = productResult.getData().stream() .filter(Objects::nonNull) @@ -4039,7 +4198,7 @@ public class HardOrderServiceImpl extends ServiceImpl entry : stockChanges.entrySet()) { CanteenSpuEntity product = productMap.get(entry.getKey()); if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) { - throw new RuntimeException("每日菜谱商品不存在或不属于当前商户"); + throw new RuntimeException("食堂菜品不存在或不属于当前商户"); } int productType = product.getSpuType() == null ? 1 : product.getSpuType(); if (productType != 1 && productType != 2) { @@ -4050,25 +4209,28 @@ public class HardOrderServiceImpl extends ServiceImpl stockChanges, - Map inventorySnapshot, - String inventoryBizId) { + private void registerCanteenSpuInventoryRollback(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { throw new RuntimeException("库存事务状态异常,请稍后重试"); } @@ -4086,9 +4248,9 @@ public class HardOrderServiceImpl extends ServiceImpl stockChanges, - Map inventorySnapshot, - String inventoryBizId, - String operation) { + private void applyCanteenSpuInventoryChange(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId, + String operation) { CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO(); dto.setBizId(inventoryBizId); dto.setOperation(operation); @@ -4118,8 +4280,8 @@ public class HardOrderServiceImpl extends ServiceImpl result = remoteCanteenSpuService.updateSkuStockIdempotent(dto); if (result == null || !result.isOk()) { - throw new RuntimeException(result == null ? "每日菜谱库存服务无响应" - : StrUtil.blankToDefault(result.getMsg(), "每日菜谱库存操作失败")); + throw new RuntimeException(result == null ? "食堂菜品库存服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "食堂菜品库存操作失败")); } } @@ -4136,7 +4298,7 @@ public class HardOrderServiceImpl extends ServiceImpl carList = hardCarService.getByParentCarId(order.getParentCarId()); if (ObjectUtil.isEmpty(carList)) throw new RuntimeException(CarErrorMsg + "错误,购物车不存在"); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 9a586fb9..ed7348bd 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -112,6 +112,138 @@
+ + + ( + select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date, + o.pay_time as stat_time, + o.id as order_id, + o.user_id, + o.merchant_id as mch_id, + o.user_dept_id, + o.dept_id, + o.merchant_dept_id, + o.hour_type, + a.product_id, + a.product_name, + a.product_pic, + a.product_price, + a.product_count, + a.total_price, + 'OFFLINE_PURCHASE' as sale_source + from hard_car a + inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' + inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' + and o.merchant_type = '1' and o.order_type = '1' + and o.hour_type is not null and o.hour_type not in ('-1') + where a.del_flag = '0' + and o.pay_time is not null + and o.order_status in ('5', '20') + union all + select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date, + o.verification_time as stat_time, + o.id as order_id, + o.user_id, + o.merchant_id as mch_id, + o.user_dept_id, + o.dept_id, + o.merchant_dept_id, + o.hour_type, + a.product_id, + a.product_name, + a.product_pic, + a.product_price, + a.product_count, + a.total_price, + 'RESERVATION_VERIFIED' as sale_source + from + inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' + inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' + and o.merchant_type = '1' and o.order_type = '2' + and (o.appt_id is not null or o.appt_rule_id is not null) + and o.order_status = '20' + and o.verification_time is not null + and o.hour_type is not null and o.hour_type not in ('-1') + ) sale_stats + + + + + + and sale_stats.stat_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + and sale_stats.sale_source in + + #{v} + + + + and sale_stats.stat_date = #{dto.createTimeStr} + + + and sale_stats.stat_date = #{dto.orderApptUseDateStr} + + + and sale_stats.mch_id = #{dto.merchantId} + + + and sale_stats.mch_id in + + #{v} + + + + and sale_stats.hour_type = #{dto.hourType} + + + + + and sale_stats.dept_id in + + + and sale_stats.dept_id not in + + + + #{v} + + + + + + and sale_stats.user_dept_id in + + + and sale_stats.user_dept_id not in + + + + #{v} + + + + + + + + + and sale_stats.dept_id in + + #{v} + + + + and sale_stats.merchant_dept_id in + + #{v} + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml new file mode 100644 index 00000000..61e63c34 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml @@ -0,0 +1,324 @@ + + + + + + + INSERT IGNORE INTO third_payment_task + (id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, aggregate_session_id, + pay_no, refund_no, fz_no, aggregate_no, status, next_run_time, priority, attempt_count, + create_time, update_time, del_flag) + VALUES + (#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{aggregateSessionId}, + #{payNo}, #{refundNo}, #{fzNo}, #{aggregateNo}, #{status}, #{nextRunTime}, #{priority}, + #{attemptCount}, #{createTime}, #{updateTime}, #{delFlag}) + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{now}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, last_error_code = 'LEASE_EXPIRED', + last_error_message = 'Worker租约到期,等待Quartz重新分发', update_time = #{now} + WHERE task_type = #{taskType} AND status = 'PROCESSING' AND lease_expire_time <= #{now} + AND del_flag = '0' + + + + + + UPDATE third_payment_task + SET status = 'PROCESSING', lease_owner = #{owner}, lease_token = #{token}, + lease_expire_time = #{expireTime}, attempt_count = attempt_count + 1, update_time = #{now} + WHERE id = #{id} AND status IN ('READY', 'RETRY', 'ALERT') + AND next_run_time <= #{now} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{nextRunTime}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, last_error_code = 'WORKER_BUSY', last_error_message = #{message}, + update_time = NOW() + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'SUCCESS', last_external_status = #{externalStatus}, last_error_code = NULL, + last_error_message = NULL, finished_time = #{now}, lease_owner = NULL, lease_token = NULL, + lease_expire_time = NULL, update_time = #{now} + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = #{status}, last_external_status = #{externalStatus}, last_error_code = NULL, + last_error_message = NULL, finished_time = #{now}, update_time = #{now} + WHERE tenant_id = #{tenantId} AND task_key = #{taskKey} + AND status IN ('READY', 'RETRY', 'ALERT') AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = #{status}, next_run_time = #{nextRunTime}, last_external_status = #{externalStatus}, + last_error_code = #{errorCode}, last_error_message = #{errorMessage}, lease_owner = NULL, + lease_token = NULL, lease_expire_time = NULL, update_time = NOW() + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + UPDATE third_payment_task + SET status = 'FAILED', last_error_code = #{errorCode}, last_error_message = #{errorMessage}, + finished_time = #{now}, lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL, + update_time = #{now} + WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token} AND del_flag = '0' + + + + + + UPDATE third_payment_task + SET status = 'RETRY', next_run_time = #{nextRunTime}, finished_time = NULL, + lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL, + last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime} + WHERE id = #{id} AND status IN ('FAILED', 'ALERT', 'CANCELLED') AND del_flag = '0' + + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java index d846fc43..0f1d3589 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJobTest.java @@ -2,9 +2,15 @@ package com.bsd.pjcloud.thirdparty.job; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService; +import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator; import org.junit.jupiter.api.AfterEach; @@ -14,6 +20,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.List; @@ -41,6 +48,24 @@ class ThirdAppPlusPayJobTest { @Mock private ThirdAppPlusService thirdAppPlusService; + /** + * 分账订单服务 Mock,用于验证最终分账标记的重启恢复。 + */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + + /** + * 微信分账服务 Mock,补齐被测任务的构造依赖。 + */ + @Mock + private ThirdAppPlusWechatFzService thirdAppPlusWechatFzService; + + /** + * 支付宝直付通服务 Mock,补齐被测任务的构造依赖。 + */ + @Mock + private ThirdAppPlusAliPayService thirdAppPlusAliPayService; + /** * 自适应轮询协调器 Mock,用于控制本轮是否允许访问数据库。 */ @@ -112,7 +137,64 @@ class ThirdAppPlusPayJobTest { job.closeHistoricalOrderCodeOrders(); - verify(thirdPayOrderService).update(any(Wrapper.class)); + verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); verifyNoInteractions(thirdAppPlusService); } + + /** + * 验证 App 待支付订单不受一小时创建窗口限制,并在仍未支付时更新时间轮转到批次队尾。 + */ + @Test + void handleAppPaymentOrdersShouldRecoverHistoricalOrderAndRotatePendingRecord() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(102L); + order.setTenantId(66L); + order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + order.setCreateTime(LocalDateTime.now().minusHours(2)); + order.setUpdateTime(LocalDateTime.now().minusHours(2)); + when(pollingCoordinator.tryStartAppPaymentPolling()).thenReturn(true); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order)); + when(thirdPayOrderService.getById(order.getId())).thenReturn(order); + + job.handleAppPaymentOrders(); + + verify(thirdAppPlusService).queryPayResult(order); + org.mockito.ArgumentCaptor updateCaptor = + org.mockito.ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNotNull(updateCaptor.getValue().getUpdateTime()); + verify(pollingCoordinator).finishAppPaymentPolling(true); + Assertions.assertNull(TenantContextHolder.getTenantId()); + } + + /** + * 验证服务在分账终态落库后退出,下一轮仍能把最终抽佣金额和成功标记补写到支付订单。 + */ + @Test + void reconcileSuccessfulFzOrdersShouldRecoverInterruptedFinalMarker() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(101L); + order.setTenantId(66L); + order.setPayNo("pay-101"); + order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setIsFzSuccess(YesNoEnum.NO.getCode()); + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(order.getId()); + fzOrder.setPayNo(order.getPayNo()); + fzOrder.setPrice(123); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order)); + when(thirdPayOrderService.getById(order.getId())).thenReturn(order); + when(thirdFzOrderService.getByPayNo(order.getPayNo())).thenReturn(fzOrder); + + ReflectionTestUtils.invokeMethod(job, "reconcileSuccessfulFzOrders"); + + org.mockito.ArgumentCaptor updateCaptor = + org.mockito.ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(123, updateCaptor.getValue().getFzPrice()); + Assertions.assertEquals(YesNoEnum.YES.getCode(), updateCaptor.getValue().getIsFzSuccess()); + Assertions.assertNull(TenantContextHolder.getTenantId()); + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java new file mode 100644 index 00000000..2d130447 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java @@ -0,0 +1,85 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * 支付任务 Mapper 原子条件契约测试,防止维护 SQL 时移除跨实例安全所需的状态或租约约束。 + */ +class ThirdPaymentTaskMapperSqlContractTest { + + /** MyBatis 支付任务映射资源路径。 */ + private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml"; + + /** 测试启动时读取的 Mapper XML。 */ + private static String mapperXml; + + /** + * 从测试类路径读取本轮实际参与编译的 Mapper XML。 + * + * @throws IOException 资源读取失败时抛出 + */ + @BeforeAll + static void loadMapperXml() throws IOException { + try (InputStream input = ThirdPaymentTaskMapperSqlContractTest.class.getClassLoader() + .getResourceAsStream(MAPPER_RESOURCE)) { + Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径"); + mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * 验证领取动作必须同时受可领取原状态和到期时间约束,多个 thirdparty 实例只能有一个 CAS 成功。 + */ + @Test + void claimShouldKeepOriginalStatusAndDueTimeConditions() { + String sql = statement("update", "claim"); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("status IN ('READY', 'RETRY', 'ALERT')")), + () -> Assertions.assertTrue(sql.contains("next_run_time <= #{now}"))); + } + + /** + * 验证完成、重试、失败和队列释放都校验当前租约 Token,过期 Worker 不能覆盖新 Worker。 + */ + @Test + void resultUpdatesShouldRequireCurrentLeaseToken() { + for (String id : new String[]{"release", "complete", "retry", "fail"}) { + String sql = statement("update", id); + Assertions.assertTrue(sql.contains("status = 'PROCESSING'"), id + " 必须限制原状态"); + Assertions.assertTrue(sql.contains("lease_token = #{token}"), id + " 必须限制当前租约令牌"); + } + } + + /** + * 验证仅恢复已过期的处理中租约,未到期 Worker 不会被 Quartz 下一轮提前抢占。 + */ + @Test + void recoveryShouldOnlyReleaseExpiredProcessingLeases() { + String sql = statement("update", "recoverExpiredLeases"); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("status = 'PROCESSING'")), + () -> Assertions.assertTrue(sql.contains("lease_expire_time <= #{now}")), + () -> Assertions.assertTrue(sql.contains("status = 'RETRY'"))); + } + + /** + * 提取指定 MyBatis 语句片段。 + * + * @param element XML 元素名,例如 update + * @param id MyBatis 语句 ID + * @return 对应语句 XML 片段 + */ + private static String statement(String element, String id) { + String startToken = "<" + element + " id=\"" + id + "\">"; + int start = mapperXml.indexOf(startToken); + int end = mapperXml.indexOf("", start); + Assertions.assertTrue(start >= 0 && end > start, "缺少 MyBatis 语句:" + id); + return mapperXml.substring(start, end); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java new file mode 100644 index 00000000..17b1898d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentOrderReservationServiceTest.java @@ -0,0 +1,70 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +/** + * 支付订单预留事务测试,验证调用渠道前订单与首任务在同一事务方法中按顺序持久化。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPaymentOrderReservationServiceTest { + + /** 支付订单服务 Mock。 */ + @Mock + private ThirdPayOrderService payOrderService; + + /** 支付任务写入器 Mock。 */ + @Mock + private ThirdPaymentTaskEnqueuer taskEnqueuer; + + /** 支付任务 Mapper Mock,用于补齐渠道同步响应收口依赖。 */ + @Mock + private ThirdPaymentTaskMapper taskMapper; + + /** 被测支付订单预留服务。 */ + @InjectMocks + private ThirdPaymentOrderReservationService reservationService; + + /** + * 验证订单保存成功后才写入 PAY_CREATE,并确认事务在外部渠道调用前以独立事务提交。 + * + * @throws Exception 反射读取方法声明失败时抛出 + */ + @Test + void reserveAppPayShouldPersistOrderAndCreateTaskInRequiresNewTransaction() throws Exception { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(101L); + order.setTenantId(9L); + order.setPayNo("pay-1001"); + when(payOrderService.save(order)).thenReturn(true); + + reservationService.reserveAppPay(order); + + InOrder inOrder = inOrder(payOrderService, taskEnqueuer); + inOrder.verify(payOrderService).save(order); + inOrder.verify(taskEnqueuer).enqueue(9L, ThirdPaymentTaskTypeEnum.PAY_CREATE, "PAY_CREATE:pay-1001", + 101L, "pay-1001", null, null, null); + Method method = ThirdPaymentOrderReservationService.class.getDeclaredMethod("reserveAppPay", ThirdPayOrderEntity.class); + Transactional transactional = method.getAnnotation(Transactional.class); + Assertions.assertAll( + () -> Assertions.assertNotNull(transactional), + () -> Assertions.assertEquals(Propagation.REQUIRES_NEW, transactional.propagation()), + () -> Assertions.assertTrue(Arrays.asList(transactional.rollbackFor()).contains(Exception.class))); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java new file mode 100644 index 00000000..ca01a067 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java @@ -0,0 +1,75 @@ +package com.bsd.pjcloud.thirdparty.service; + +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * 支付任务实时写入器测试,验证数据库唯一键依赖的任务键稳定性和非自增主键初始化。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPaymentTaskEnqueuerTest { + + /** 支付任务 Mapper Mock,用于捕获实际准备写入数据库的任务事实。 */ + @Mock + private ThirdPaymentTaskMapper taskMapper; + + /** 被测任务写入器。 */ + @InjectMocks + private ThirdPaymentTaskEnqueuer enqueuer; + + /** + * 验证同一支付号重复入队始终生成相同任务键,同时每次插入都具备可用的分布式主键。 + */ + @Test + void enqueuePayQueryShouldUseStableTaskKeyAndAssignedId() { + enqueuer.enqueuePayQuery(9L, 101L, "pay-1001"); + enqueuer.enqueuePayQuery(9L, 101L, "pay-1001"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper, times(2)).insertIgnore(captor.capture()); + List tasks = captor.getAllValues(); + Assertions.assertAll( + () -> Assertions.assertNotNull(tasks.get(0).getId()), + () -> Assertions.assertNotNull(tasks.get(1).getId()), + () -> Assertions.assertNotEquals(tasks.get(0).getId(), tasks.get(1).getId()), + () -> Assertions.assertEquals("PAY_QUERY:pay-1001", tasks.get(0).getTaskKey()), + () -> Assertions.assertEquals(tasks.get(0).getTaskKey(), tasks.get(1).getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode(), tasks.get(0).getTaskType()), + () -> Assertions.assertEquals("READY", tasks.get(0).getStatus()), + () -> Assertions.assertEquals(0, tasks.get(0).getAttemptCount()), + () -> Assertions.assertEquals("0", tasks.get(0).getDelFlag()), + () -> Assertions.assertNotNull(tasks.get(0).getNextRunTime())); + } + + /** + * 验证退分账查询任务键同时绑定原分账号和稳定退分账号,重试不会生成新的渠道单据。 + */ + @Test + void enqueueFzRefundQueryShouldKeepOriginalBusinessNumbers() { + enqueuer.enqueueFzRefundQuery(9L, 101L, 201L, "pay-1001", "fz-pay-1001", "fz-refund-pay-1001"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper).insertIgnore(captor.capture()); + ThirdPaymentTaskEntity task = captor.getValue(); + Assertions.assertAll( + () -> Assertions.assertEquals("FZ_REFUND_QUERY:fz-pay-1001:fz-refund-pay-1001", task.getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY.getCode(), task.getTaskType()), + () -> Assertions.assertEquals("pay-1001", task.getPayNo()), + () -> Assertions.assertEquals("fz-pay-1001", task.getFzNo()), + () -> Assertions.assertEquals(201L, task.getFzOrderId()), + () -> Assertions.assertEquals(110, task.getPriority())); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index 0cc2edd0..21cda0d0 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -4,21 +4,44 @@ import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.json.JSONUtil; import com.alipay.api.AlipayClient; +import com.alipay.api.domain.AlipayTradeAppPayModel; +import com.alipay.api.domain.AlipayTradeOrderSettleModel; +import com.alipay.api.domain.AlipayTradeOrderSettleQueryModel; import com.alipay.api.domain.AlipayTradePrecreateModel; +import com.alipay.api.domain.AlipayTradeRefundModel; +import com.alipay.api.domain.AlipayTradeSettleConfirmModel; +import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; +import com.alipay.api.domain.RoyaltyDetail; +import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; +import com.alipay.api.request.AlipayTradeOrderSettleRequest; +import com.alipay.api.request.AlipayTradeOrderSettleQueryRequest; import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.request.AlipayTradeRefundRequest; +import com.alipay.api.request.AlipayTradeSettleConfirmRequest; +import com.alipay.api.response.AlipayTradeAppPayResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; +import com.alipay.api.response.AlipayTradeOrderSettleResponse; +import com.alipay.api.response.AlipayTradeOrderSettleQueryResponse; import com.alipay.api.response.AlipayTradePrecreateResponse; +import com.alipay.api.response.AlipayTradeRefundResponse; +import com.alipay.api.response.AlipayTradeSettleConfirmResponse; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -28,19 +51,26 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** + * 支付宝订单码、App 直付通、结算抽佣及已分账退款的关键协议测试。 + */ @ExtendWith(MockitoExtension.class) class ThirdAppPlusAliPayServiceImplTest { @@ -50,6 +80,12 @@ class ThirdAppPlusAliPayServiceImplTest { @Mock private AlipayClient alipayClient; + /** + * 分账订单服务 Mock,用于保存支付宝确认结算、平台抽佣和退分账快照。 + */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + /** * 聚合会话服务 Mock,用于校验支付宝 JSAPI 回调必须存在严格 pay_no 绑定。 */ @@ -73,13 +109,19 @@ class ThirdAppPlusAliPayServiceImplTest { aliPayConfig.setAlipayPublicKey("test-public-key"); aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); + aliPayConfig.setRefundCallbackUrl("/third-app-pay/app/refund-callback"); + aliPayConfig.setRoyaltyTransIn("2088000000000001"); + aliPayConfig.setRoyaltyTransInType("userId"); + aliPayConfig.setRoyaltyTransInName("资安信平台"); appPlusPayConfig.setAliPayConfig(aliPayConfig); MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); + aliPaySetting.setSubMchid("smid-1001"); paySetting = new MchPaySettingVo(); paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); paySetting.setEnable(YesNoEnum.YES.getCode()); + paySetting.setFzRatio(10D); paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); payDto = new ThirdAppPlusPayDto(); @@ -91,6 +133,246 @@ class ThirdAppPlusAliPayServiceImplTest { payDto.setMchId(88L); service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient); ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); + ReflectionTestUtils.setField(service, "thirdFzOrderService", thirdFzOrderService); + } + + /** + * 验证 App 支付把可信 smid、金额和十五分钟有效期写入直付通请求,创建阶段不写支付时间。 + */ + @Test + void appPayShouldCreateDirectPayOrderWithoutMarkingPaymentSuccess() throws Exception { + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + LocalDateTime beforeCreate = LocalDateTime.now(); + + String result = service.appPay(payDto, paySetting); + + Assertions.assertEquals("signed-alipay-order", result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + AlipayTradeAppPayRequest request = requestCaptor.getValue(); + AlipayTradeAppPayModel model = (AlipayTradeAppPayModel) request.getBizModel(); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); + Assertions.assertEquals("12.34", model.getTotalAmount()); + LocalDateTime timeExpire = LocalDateTimeUtil.parse(model.getTimeExpire(), DatePattern.NORM_DATETIME_PATTERN); + Assertions.assertFalse(timeExpire.isBefore(beforeCreate.plusMinutes(14).plusSeconds(55))); + Assertions.assertFalse(timeExpire.isAfter(LocalDateTime.now().plusMinutes(15).plusSeconds(5))); + Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); + + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + ThirdPayOrderEntity savedOrder = orderCaptor.getValue(); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), savedOrder.getStatus()); + Assertions.assertEquals(88L, savedOrder.getMchId()); + Assertions.assertEquals(YesNoEnum.YES.getCode(), savedOrder.getEnableFz()); + Assertions.assertNull(savedOrder.getPayTime()); + } + + /** + * 验证未配置授权令牌时请求不产生空的 app_auth_token 公共参数。 + */ + @Test + void appPayShouldOmitBlankAppAuthToken() throws Exception { + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean( + paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + aliPaySetting.setAppAuthToken(null); + paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + + service.appPay(payDto, paySetting); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); + } + + /** + * 验证支付宝明确拒绝商户授权时结束直付通分账,不会降级调用普通平台抽佣接口。 + */ + @Test + void directPayShouldFailWithoutRoyaltyFallbackWhenAuthorizationIsRejected() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse rejected = new AlipayTradeSettleConfirmResponse(); + rejected.setCode("40004"); + rejected.setSubCode("isv.insufficient-isv-permissions"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(rejected); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + + verify(alipayClient).execute(any(AlipayTradeSettleConfirmRequest.class)); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_FAILED.getType(), fzOrder.getStatus()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证确认结算严格先于平台抽佣,并且只有两阶段均成功才写最终分账成功。 + */ + @Test + void directPayShouldConfirmSettlementBeforeRoyalty() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse confirmResponse = successSettleConfirmResponse(); + AlipayTradeOrderSettleResponse royaltyResponse = successRoyaltyResponse(); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(confirmResponse); + when(alipayClient.execute(any(AlipayTradeOrderSettleRequest.class))).thenReturn(royaltyResponse); + when(alipayClient.execute(any(AlipayTradeOrderSettleQueryRequest.class))).thenReturn(successRoyaltyQueryResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + Assertions.assertEquals("SUCCESS", service.confirmDirectPaySettlementStage(fzOrder, payOrder)); + Assertions.assertEquals("SUBMITTED", service.submitDirectPayRoyaltyStage(fzOrder, payOrder)); + Assertions.assertEquals("SUCCESS", service.queryDirectPayRoyaltyStage(fzOrder, payOrder)); + + InOrder inOrder = inOrder(alipayClient); + inOrder.verify(alipayClient).execute(any(AlipayTradeSettleConfirmRequest.class)); + inOrder.verify(alipayClient).execute(any(AlipayTradeOrderSettleRequest.class)); + inOrder.verify(alipayClient).execute(any(AlipayTradeOrderSettleQueryRequest.class)); + ArgumentCaptor queryCaptor = + ArgumentCaptor.forClass(AlipayTradeOrderSettleQueryRequest.class); + verify(alipayClient).execute(queryCaptor.capture()); + AlipayTradeOrderSettleQueryModel queryModel = + (AlipayTradeOrderSettleQueryModel) queryCaptor.getValue().getBizModel(); + Assertions.assertEquals("royalty_" + payOrder.getPayNo(), queryModel.getOutRequestNo()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType(), fzOrder.getStatus()); + Assertions.assertEquals("SUCCESS", JSONUtil.parseObj(fzOrder.getCreateRes()) + .getJSONObject("settleConfirm").getStr("status")); + Assertions.assertEquals("SUCCESS", JSONUtil.parseObj(fzOrder.getCreateRes()) + .getJSONObject("royalty").getStr("status")); + verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证确认结算遇到系统不确定结果后使用相同 out_request_no 重试。 + */ + @Test + void directPayRetryShouldKeepStableSettleRequestNo() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse processing = new AlipayTradeSettleConfirmResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))) + .thenReturn(processing, successSettleConfirmResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + service.confirmDirectPaySettlementStage(fzOrder, payOrder); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AlipayTradeSettleConfirmRequest.class); + verify(alipayClient, times(2)).execute(requestCaptor.capture()); + List requests = requestCaptor.getAllValues(); + String firstRequestNo = ((AlipayTradeSettleConfirmModel) requests.get(0).getBizModel()).getOutRequestNo(); + String secondRequestNo = ((AlipayTradeSettleConfirmModel) requests.get(1).getBizModel()).getOutRequestNo(); + Assertions.assertEquals("settle_" + payOrder.getPayNo(), firstRequestNo); + Assertions.assertEquals(firstRequestNo, secondRequestNo); + } + + /** + * 验证旧实例返回的处理中快照只能在数据库仍为待处理时写入,不能整行覆盖并发成功终态。 + */ + @Test + void staleDirectPayPollingShouldNotOverwriteSuccessfulFzOrder() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity staleFzOrder = pendingFzOrder(payOrder); + AlipayTradeSettleConfirmResponse processing = new AlipayTradeSettleConfirmResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))).thenReturn(processing); + // false 模拟另一实例已经把同一记录推进为 FZ_SUCCESS,当前旧快照的原状态条件不再成立。 + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(false); + + service.confirmDirectPaySettlementStage(staleFzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNull(updateCaptor.getValue().getStatus()); + verify(thirdFzOrderService, never()).updateById(any(ThirdFzOrderEntity.class)); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。 + */ + @Test + void refundShouldIncludeRoyaltyParametersAfterProfitSharing() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType()); + fzOrder.setPrice(123); + when(thirdFzOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(fzOrder); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + AlipayTradeRefundResponse response = new AlipayTradeRefundResponse(); + response.setCode("10000"); + response.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(response); + ThirdMiniWechatRefundDto refundDto = new ThirdMiniWechatRefundDto(); + refundDto.setAfterSaleId(77L); + refundDto.setRefundPrice(500); + + boolean result = service.refund(payOrder, refundDto); + + Assertions.assertFalse(result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel model = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertEquals("refund_77", model.getOutRequestNo()); + Assertions.assertEquals("5.00", model.getRefundAmount()); + Assertions.assertEquals(1, model.getRefundRoyaltyParameters().size()); + OpenApiRoyaltyDetailInfoPojo royalty = model.getRefundRoyaltyParameters().get(0); + Assertions.assertEquals("2088000000000001", royalty.getTransOut()); + Assertions.assertEquals("userId", royalty.getTransOutType()); + Assertions.assertEquals("0.49", royalty.getAmount()); + Assertions.assertTrue(fzOrder.getFzRefundReq().contains("2088000000000001")); + } + + /** + * 验证退款查询找不到原请求时使用已保存的相同退款号和参数重提,受理后继续等待查询终态。 + */ + @Test + void queryRefundShouldRetryMissingRequestWithOriginalIdempotencyKey() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_77"); + AlipayTradeFastpayRefundQueryResponse missing = new AlipayTradeFastpayRefundQueryResponse(); + missing.setCode("40004"); + missing.setSubCode("ACQ.REFUND_NOT_EXIST"); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(missing); + AlipayTradeRefundResponse accepted = new AlipayTradeRefundResponse(); + accepted.setCode("10000"); + accepted.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(accepted); + + AlipayTradeFastpayRefundQueryResponse result = service.queryRefund(payOrder); + + Assertions.assertNull(result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel retryModel = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertEquals(payOrder.getPayNo(), retryModel.getOutTradeNo()); + Assertions.assertEquals(payOrder.getRefundNo(), retryModel.getOutRequestNo()); + Assertions.assertEquals("5.00", retryModel.getRefundAmount()); + } + + /** + * 验证退款查询系统错误保持可重试,且不会在未确认请求缺失时重复提交退款。 + */ + @Test + void queryRefundShouldKeepSystemErrorRetryableWithoutResubmitting() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_78"); + AlipayTradeFastpayRefundQueryResponse processing = new AlipayTradeFastpayRefundQueryResponse(); + processing.setCode("40004"); + processing.setSubCode("ACQ.SYSTEM_ERROR"); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(processing); + + AlipayTradeFastpayRefundQueryResponse result = service.queryRefund(payOrder); + + Assertions.assertSame(processing, result); + verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); } @Test @@ -279,6 +561,96 @@ class ThirdAppPlusAliPayServiceImplTest { return order; } + /** + * 构造已确认支付成功且包含商户配置快照的直付通支付订单。 + * + * @return 可进入确认结算或退款流程的支付订单 + */ + private ThirdPayOrderEntity paidDirectPayOrder() { + ThirdPayOrderEntity order = unpaidOrder(); + order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + order.setThirdPayNo("202600000001"); + order.setPayTime(LocalDateTime.now().minusMinutes(1)); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting)); + return order; + } + + /** + * 构造包含稳定退款号和原始退款请求快照的退款中订单。 + * + * @param refundNo 退款幂等请求号 + * @return 可执行支付宝退款查询与安全重提的支付订单 + */ + private ThirdPayOrderEntity refundingOrderWithRequest(String refundNo) { + ThirdPayOrderEntity order = paidDirectPayOrder(); + order.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + order.setRefundNo(refundNo); + AlipayTradeRefundModel model = new AlipayTradeRefundModel(); + model.setOutTradeNo(order.getPayNo()); + model.setOutRequestNo(refundNo); + model.setRefundAmount("5.00"); + model.setRefundReason("测试退款"); + order.setRefundRep(JSONUtil.toJsonStr(model)); + return order; + } + + /** + * 构造使用稳定分账号的待处理分账记录。 + * + * @param payOrder 对应支付订单 + * @return 待确认结算的分账记录 + */ + private ThirdFzOrderEntity pendingFzOrder(ThirdPayOrderEntity payOrder) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(payOrder.getId()); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz_" + payOrder.getPayNo()); + fzOrder.setPrice(123); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + return fzOrder; + } + + /** + * 构造支付宝确认结算成功响应。 + * + * @return SDK 成功响应 + */ + private AlipayTradeSettleConfirmResponse successSettleConfirmResponse() { + AlipayTradeSettleConfirmResponse response = new AlipayTradeSettleConfirmResponse(); + response.setCode("10000"); + response.setMsg("Success"); + return response; + } + + /** + * 构造支付宝平台抽佣成功响应。 + * + * @return SDK 成功响应 + */ + private AlipayTradeOrderSettleResponse successRoyaltyResponse() { + AlipayTradeOrderSettleResponse response = new AlipayTradeOrderSettleResponse(); + response.setCode("10000"); + response.setMsg("Success"); + return response; + } + + /** + * 构造支付宝明确返回抽佣明细成功的查询响应。 + * @return SDK 抽佣查询成功响应 + */ + private AlipayTradeOrderSettleQueryResponse successRoyaltyQueryResponse() { + RoyaltyDetail detail = new RoyaltyDetail(); + detail.setState("SUCCESS"); + detail.setAmount("1.23"); + AlipayTradeOrderSettleQueryResponse response = new AlipayTradeOrderSettleQueryResponse(); + response.setCode("10000"); + response.setMsg("Success"); + response.setOutRequestNo("royalty_" + payDto.getOutTradeNo()); + response.setRoyaltyDetailList(List.of(detail)); + return response; + } + private Map successCallbackParams() { Map params = new HashMap<>(); params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index 53db7cc0..e7ec734d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -11,17 +11,20 @@ import com.bsd.pjcloud.app.api.feign.RemoteAppUserService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.*; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargePackageService; @@ -332,6 +335,7 @@ class ThirdAppPlusServiceImplOrderCodePayTest { .thenReturn(queryResponse("TRADE_FINISHED", "alipay-trade-no")); when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime()); + paidOrder.setIsBizCallback(YesNoEnum.YES.getCode()); when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder); service.queryPayResult(unpaidOrder); @@ -465,6 +469,158 @@ class ThirdAppPlusServiceImplOrderCodePayTest { Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); } + /** + * 验证普通微信 App 支付到期后调用 App 关单接口,渠道确认成功才推进本地关闭状态。 + */ + @Test + void queryPayResultShouldCloseExpiredWechatAppOrderAfterChannelConfirmation() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + expiredOrder.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType()); + com.wechat.pay.java.service.partnerpayments.app.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.app.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.app.model.Transaction.TradeStateEnum.NOTPAY); + when(thirdMiniAppWechatService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdMiniAppWechatService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(true); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppWechatService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdAppPlusWechatNativePayService, never()).close(any(), any()); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus()); + } + + /** + * 验证微信 App 关单网络结果未知时保持未支付,等待原任务号下一轮重试。 + */ + @Test + void queryPayResultShouldKeepExpiredWechatAppOrderUnpaidWhenCloseIsUnknown() { + ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1)); + expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + expiredOrder.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY.getType()); + com.wechat.pay.java.service.partnerpayments.app.model.Transaction transaction = + new com.wechat.pay.java.service.partnerpayments.app.model.Transaction(); + transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.app.model.Transaction.TradeStateEnum.NOTPAY); + when(thirdMiniAppWechatService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(transaction); + when(thirdMiniAppWechatService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())) + .thenReturn(false); + + service.queryPayResult(expiredOrder); + + verify(thirdMiniAppWechatService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证商城支付以硬件订单归属商户为真源,远程 DTO 商户不一致时拒绝创建支付。 + */ + @Test + void miniPayShouldRejectShopOrderMerchantMismatch() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(99L); + HardOrderEntity hardOrder = hardOrder(88L); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.miniPay(payRequest.getUserId(), payRequest)); + + Assertions.assertTrue(exception.getMessage().contains("收款商户不一致")); + verify(thirdMiniAppAliPayService, never()).appPay(any(), any()); + } + + /** + * 验证商城支付缺少直付通 smid 时在第三方下单前失败。 + */ + @Test + void miniPayShouldRejectMissingDirectPaySmid() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(88L); + HardOrderEntity hardOrder = hardOrder(88L); + MchManageVo shopMerchant = enabledAliPayMerchant(); + MchPaySettingVo setting = shopMerchant.getPaySettingList().get(0); + setting.setSettingJson("{}"); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + when(remoteMchService.getById(88L)).thenReturn(R.ok(shopMerchant)); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.miniPay(payRequest.getUserId(), payRequest)); + + Assertions.assertTrue(exception.getMessage().contains("二级商户标识未配置")); + verify(thirdMiniAppAliPayService, never()).appPay(any(), any()); + } + + /** + * 验证商城支付只把硬件订单持久化的收款商户写入第三方支付 DTO。 + */ + @Test + void miniPayShouldUseTrustedShopOrderMerchant() { + ThirdMiniRemotePayDto payRequest = shopPayRequest(88L); + HardOrderEntity hardOrder = hardOrder(88L); + MchManageVo shopMerchant = enabledAliPayMerchant(); + when(remoteHardOrderService.getByIds(List.of(hardOrder.getId()))).thenReturn(R.ok(List.of(hardOrder))); + when(remoteMchService.getById(88L)).thenReturn(R.ok(shopMerchant)); + when(thirdMiniAppAliPayService.appPay(any(), any())).thenReturn("signed-order"); + + service.miniPay(payRequest.getUserId(), payRequest); + + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdMiniAppAliPayService).appPay(payDtoCaptor.capture(), any()); + Assertions.assertEquals(88L, payDtoCaptor.getValue().getMchId()); + Assertions.assertEquals("smid-1001", payDtoCaptor.getValue().getSubMchid()); + Assertions.assertEquals(hardOrder.getPayPrice(), payDtoCaptor.getValue().getAmount()); + } + + /** + * 验证微信退款 PROCESSING 不提前推进本地订单终态。 + */ + @Test + void queryRefundResultShouldKeepWechatProcessingState() { + ThirdPayOrderEntity refundingOrder = refundingOrder(201L, "refund-processing"); + ThirdMiniWechatRefundQueryVo query = new ThirdMiniWechatRefundQueryVo(); + query.setRefund_status("PROCESSING"); + when(thirdMiniAppWechatService.refundQuery(refundingOrder.getPayNo())).thenReturn(query); + + service.queryRefundResult(refundingOrder); + + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证微信 SUCCESS、CLOSED、ABNORMAL 分别映射退款成功或失败终态。 + */ + @Test + void queryRefundResultShouldMapWechatTerminalStates() { + ThirdPayOrderEntity successOrder = refundingOrder(202L, "refund-success"); + ThirdPayOrderEntity closedOrder = refundingOrder(203L, "refund-closed"); + ThirdPayOrderEntity abnormalOrder = refundingOrder(204L, "refund-abnormal"); + ThirdMiniWechatRefundQueryVo success = new ThirdMiniWechatRefundQueryVo(); + success.setRefund_status("SUCCESS"); + ThirdMiniWechatRefundQueryVo closed = new ThirdMiniWechatRefundQueryVo(); + closed.setRefund_status("CLOSED"); + ThirdMiniWechatRefundQueryVo abnormal = new ThirdMiniWechatRefundQueryVo(); + abnormal.setRefund_status("ABNORMAL"); + when(thirdMiniAppWechatService.refundQuery(any())).thenReturn(success, closed, abnormal); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity latestSuccess = refundingOrder(successOrder.getId(), successOrder.getRefundNo()); + latestSuccess.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()); + latestSuccess.setIsBizCallback(YesNoEnum.YES.getCode()); + when(thirdPayOrderService.getById(successOrder.getId())).thenReturn(latestSuccess); + + service.queryRefundResult(successOrder); + service.queryRefundResult(closedOrder); + service.queryRefundResult(abnormalOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService, org.mockito.Mockito.times(3)).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), updateCaptor.getAllValues().get(0).getStatus()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(1).getStatus()); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(2).getStatus()); + } + private void mockNoExistingOrder() { when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(null); @@ -473,9 +629,11 @@ class ThirdAppPlusServiceImplOrderCodePayTest { private MchManageVo enabledAliPayMerchant() { MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); + aliPaySetting.setSubMchid("smid-1001"); MchPaySettingVo setting = new MchPaySettingVo(); setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); setting.setEnable(YesNoEnum.YES.getCode()); + setting.setFzRatio(10D); setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); MchManageVo result = new MchManageVo(); result.setId(88L); @@ -483,6 +641,55 @@ class ThirdAppPlusServiceImplOrderCodePayTest { return result; } + /** + * 构造服务端商城支付请求,mchId 只用于与硬件订单真源做一致性校验。 + * + * @param mchId 远程 DTO 携带的商户 ID + * @return 商城支付请求 + */ + private ThirdMiniRemotePayDto shopPayRequest(Long mchId) { + ThirdMiniRemotePayDto payRequest = new ThirdMiniRemotePayDto(); + payRequest.setUserId(10L); + payRequest.setMchId(mchId); + payRequest.setBizId("7001"); + payRequest.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + payRequest.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY); + payRequest.setAmount(1234); + return payRequest; + } + + /** + * 构造带可信用户、金额和收款商户的硬件商城订单。 + * + * @param mchId 数据库订单归属商户 ID + * @return 硬件商城订单 + */ + private HardOrderEntity hardOrder(Long mchId) { + HardOrderEntity hardOrder = new HardOrderEntity(); + hardOrder.setId(7001L); + hardOrder.setUserId(10L); + hardOrder.setMerchantId(mchId); + hardOrder.setPayPrice(1234); + return hardOrder; + } + + /** + * 构造微信退款中的本地支付订单。 + * + * @param id 支付订单主键 + * @param refundNo 稳定退款单号 + * @return 退款轮询订单 + */ + private ThirdPayOrderEntity refundingOrder(Long id, String refundNo) { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(id); + order.setPayNo("pay-" + id); + order.setRefundNo(refundNo); + order.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + order.setStatus(ThirdPayOrderStatusEnum.REFUNDING.getType()); + return order; + } + private MchManageVo enabledWechatMerchant() { MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); wechatPaySetting.setSubMchid("1900000109"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java new file mode 100644 index 00000000..ebb8df38 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java @@ -0,0 +1,199 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdFzOrderMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.ThirdAppPlusWechatFzServiceImpl; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.wechat.pay.java.service.profitsharing.ProfitsharingService; +import com.wechat.pay.java.service.profitsharing.model.OrderStatus; +import com.wechat.pay.java.service.profitsharing.model.OrdersEntity; +import com.wechat.pay.java.service.profitsharing.model.QueryOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.QueryReturnOrderRequest; +import com.wechat.pay.java.service.profitsharing.model.ReturnOrderStatus; +import com.wechat.pay.java.service.profitsharing.model.ReturnOrdersEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 微信分账与退分账数据库轮询状态映射测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusWechatFzServiceImplTest { + + /** 微信平台配置 Mock,仅补齐构造依赖。 */ + @Mock + private AppPlusPayConfig appPlusPayConfig; + + /** 微信分账 SDK 服务 Mock。 */ + @Mock + private ProfitsharingService profitsharingService; + + /** 分账订单服务 Mock,用于捕获带原状态条件的更新。 */ + @Mock + private ThirdFzOrderService fzOrderService; + + /** 支付订单服务 Mock,用于读取配置快照并更新最终抽佣标记。 */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** 支付订单 Mapper Mock,仅补齐构造依赖。 */ + @Mock + private ThirdPayOrderMapper thirdPayOrderMapper; + + /** 分账订单 Mapper Mock,仅补齐构造依赖。 */ + @Mock + private ThirdFzOrderMapper thirdFzOrderMapper; + + /** 支付任务写入器 Mock,用于补齐微信退分账实时恢复任务依赖。 */ + @Mock + private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + + /** 被测微信分账服务。 */ + @InjectMocks + private ThirdAppPlusWechatFzServiceImpl service; + + /** 公共支付订单快照。 */ + private ThirdPayOrderEntity payOrder; + + /** + * 准备微信子商户配置快照。 + */ + @BeforeEach + void setUp() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + payOrder = new ThirdPayOrderEntity(); + payOrder.setId(1L); + payOrder.setPayNo("pay-1001"); + payOrder.setThirdPayNo("wx-transaction-1001"); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + } + + /** + * 验证微信 PROCESSING 只刷新快照,不提前标记分账成功。 + */ + @Test + void pollFzOrderShouldKeepProcessingState() { + ThirdFzOrderEntity fzOrder = fzOrder(1L, ThirdFzOrderStatusEnum.FZ_UNPAID); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.PROCESSING); + when(thirdPayOrderService.getByPayNo(fzOrder.getPayNo())).thenReturn(payOrder); + when(profitsharingService.queryOrder(any(QueryOrderRequest.class))).thenReturn(response); + + service.pollFzOrder(fzOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertNull(updateCaptor.getValue().getStatus()); + Assertions.assertEquals("PROCESSING", JSONUtil.parseObj(updateCaptor.getValue().getCreateRes()) + .getJSONObject("royalty").getStr("status")); + verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); + } + + /** + * 验证微信 FINISHED 才同时推进分账记录和支付订单最终抽佣标记。 + */ + @Test + void pollFzOrderShouldMarkSuccessOnlyWhenFinished() { + ThirdFzOrderEntity fzOrder = fzOrder(2L, ThirdFzOrderStatusEnum.FZ_UNPAID); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.FINISHED); + when(thirdPayOrderService.getByPayNo(fzOrder.getPayNo())).thenReturn(payOrder); + when(profitsharingService.queryOrder(any(QueryOrderRequest.class))).thenReturn(response); + when(fzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollFzOrder(fzOrder); + + ArgumentCaptor fzUpdateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService).update(fzUpdateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_SUCCESS.getType(), fzUpdateCaptor.getValue().getStatus()); + ArgumentCaptor payUpdateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(payUpdateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(YesNoEnum.YES.getCode(), payUpdateCaptor.getValue().getIsFzSuccess()); + Assertions.assertEquals(fzOrder.getPrice(), payUpdateCaptor.getValue().getFzPrice()); + } + + /** + * 验证退分账 PROCESSING 保持原状态,SUCCESS 和 FAILED 分别进入对应终态。 + */ + @Test + void pollFzRefundOrderShouldMapWechatReturnStatuses() { + ThirdFzOrderEntity processing = fzOrder(11L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ThirdFzOrderEntity success = fzOrder(12L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ThirdFzOrderEntity failed = fzOrder(13L, ThirdFzOrderStatusEnum.FZ_REFUNDING); + ReturnOrdersEntity processingResponse = returnOrder(ReturnOrderStatus.PROCESSING); + ReturnOrdersEntity successResponse = returnOrder(ReturnOrderStatus.SUCCESS); + ReturnOrdersEntity failedResponse = returnOrder(ReturnOrderStatus.FAILED); + when(thirdPayOrderService.getByPayNo(any())).thenReturn(payOrder); + when(profitsharingService.queryReturnOrder(any(QueryReturnOrderRequest.class))) + .thenReturn(processingResponse, successResponse, failedResponse); + + service.pollFzRefundOrder(processing); + service.pollFzRefundOrder(success); + service.pollFzRefundOrder(failed); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(fzOrderService, org.mockito.Mockito.times(3)).update(updateCaptor.capture(), any(Wrapper.class)); + List updates = updateCaptor.getAllValues(); + Assertions.assertNull(updates.get(0).getStatus()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), updates.get(1).getStatus()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType(), updates.get(2).getStatus()); + } + + /** + * 构造稳定分账号和退分账号的分账记录。 + * + * @param id 分账记录主键 + * @param status 本地分账状态 + * @return 待轮询分账记录 + */ + private ThirdFzOrderEntity fzOrder(Long id, ThirdFzOrderStatusEnum status) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(id); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz-pay-1001"); + fzOrder.setFzRefundNo("fz-refund-pay-1001"); + fzOrder.setPrice(123); + fzOrder.setStatus(status.getType()); + return fzOrder; + } + + /** + * 构造指定微信退分账状态的查询响应。 + * + * @param status 微信退分账状态 + * @return 查询响应 + */ + private ReturnOrdersEntity returnOrder(ReturnOrderStatus status) { + ReturnOrdersEntity response = new ReturnOrdersEntity(); + response.setResult(status); + response.setOutOrderNo("fz-pay-1001"); + response.setOutReturnNo("fz-refund-pay-1001"); + return response; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java index a436cf11..59ae18b4 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java @@ -29,6 +29,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.Map; @@ -83,6 +84,7 @@ class ThirdAppPlusWechatServiceImplPayCallbackTest { @BeforeEach void setUp() { + ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig(); wechatConfig.setAppPlatformAppId("wx-service-app-id"); wechatConfig.setGzhAppId("wx-service-jsapi-appid"); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java new file mode 100644 index 00000000..379dd4bb --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java @@ -0,0 +1,276 @@ +package com.bsd.pjcloud.thirdparty.service.impl.appplus; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; +import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; +import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.core.notification.NotificationParser; +import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension; +import com.wechat.pay.java.service.partnerpayments.app.model.CloseOrderRequest; +import com.wechat.pay.java.service.partnerpayments.app.model.PrepayRequest; +import com.wechat.pay.java.service.partnerpayments.app.model.PrepayWithRequestPaymentResponse; +import com.wechat.pay.java.service.refund.RefundService; +import com.wechat.pay.java.service.refund.model.Amount; +import com.wechat.pay.java.service.refund.model.CreateRequest; +import com.wechat.pay.java.service.refund.model.QueryByOutRefundNoRequest; +import com.wechat.pay.java.service.refund.model.Refund; +import com.wechat.pay.java.service.refund.model.Status; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.OffsetDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 微信普通退款查单协议测试,确保服务商子商户快照和外部状态完整映射。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdAppPlusWechatServiceImplRefundTest { + + /** 微信支付应用配置 Mock。 */ + @Mock + private AppPlusPayConfig appPlusPayConfig; + + /** 微信小程序能力 Mock,仅补齐被测服务构造依赖。 */ + @Mock + private WxMaService wxMaService; + + /** 支付订单服务 Mock,提供退款中的订单快照。 */ + @Mock + private ThirdPayOrderService thirdPayOrderService; + + /** 微信服务商 App 支付服务 Mock,仅补齐构造依赖。 */ + @Mock + private AppServiceExtension appServiceExtension; + + /** 微信退款服务 Mock,用于捕获退款查询请求。 */ + @Mock + private RefundService refundService; + + /** 微信回调解析器 Mock,仅补齐构造依赖。 */ + @Mock + private NotificationParser notificationParser; + + /** 微信分账生命周期 Mock,仅补齐构造依赖。 */ + @Mock + private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + + /** 被测微信 App 支付服务。 */ + @InjectMocks + private ThirdAppPlusWechatServiceImpl service; + + /** + * 验证 SUCCESS、CLOSED、ABNORMAL、PROCESSING 均按微信原始编码返回上层状态机。 + */ + @Test + void refundQueryShouldMapAllWechatStatusesAndUseSnapshotSubMerchant() { + ThirdPayOrderEntity payOrder = refundingOrder(); + when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + + for (Status status : Status.values()) { + Refund refund = refund(status); + when(refundService.queryByOutRefundNo(any(QueryByOutRefundNoRequest.class))).thenReturn(refund); + + ThirdMiniWechatRefundQueryVo result = service.refundQuery(payOrder.getPayNo()); + + Assertions.assertNotNull(result); + Assertions.assertEquals(status.name(), result.getRefund_status()); + Assertions.assertEquals(500L, result.getRefund_fee()); + Assertions.assertEquals(1234L, result.getTotal_fee()); + } + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(QueryByOutRefundNoRequest.class); + verify(refundService, org.mockito.Mockito.times(Status.values().length)) + .queryByOutRefundNo(requestCaptor.capture()); + Assertions.assertTrue(requestCaptor.getAllValues().stream() + .allMatch(request -> "refund-pay-1001".equals(request.getOutRefundNo()) + && "wx-sub-1001".equals(request.getSubMchid()))); + } + + /** + * 验证微信退款创建同步返回只对 SUCCESS、CLOSED、ABNORMAL 推进终态,PROCESSING 继续等待回调或轮询。 + */ + @Test + void refundShouldApplySynchronousTerminalStatusWithoutAdvancingProcessing() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setRefundCallbackUrl("/refund-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + for (Status status : Status.values()) { + ThirdPayOrderEntity payOrder = paidRefundableOrder(); + when(refundService.create(any(CreateRequest.class))).thenReturn(refund(status)); + when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + ThirdMiniWechatRefundDto dto = new ThirdMiniWechatRefundDto(); + dto.setPayNo(payOrder.getPayNo()); + dto.setRefundPrice(500); + + service.refund(payOrder, dto); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService, org.mockito.Mockito.times(2)) + .update(updateCaptor.capture(), any(Wrapper.class)); + ThirdPayOrderEntity responseUpdate = updateCaptor.getAllValues().get(1); + if (status == Status.SUCCESS) { + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), responseUpdate.getStatus()); + } else if (status == Status.CLOSED || status == Status.ABNORMAL) { + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), responseUpdate.getStatus()); + } else { + Assertions.assertNull(responseUpdate.getStatus()); + } + clearInvocations(thirdPayOrderService, refundService); + } + } + + /** + * 验证非法微信分账比例在创建渠道支付前被拒绝,不会留下无法完成抽佣的已支付交易。 + */ + @Test + void appPayShouldRejectInvalidProfitSharingRatioBeforeCreatingOrder() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setFzRatio(101D); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(1000); + dto.setOutTradeNo("pay-invalid-ratio"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.appPay(dto, setting)); + + Assertions.assertTrue(exception.getMessage().contains("不超过100%")); + verifyNoInteractions(appServiceExtension); + verify(thirdPayOrderService, org.mockito.Mockito.never()).save(any(ThirdPayOrderEntity.class)); + } + + /** + * 验证微信 App 预下单把本地保存的十五分钟截止时间同步传给渠道,避免本地和微信订单生命周期分叉。 + */ + @Test + void appPayShouldSendSameExpireTimeToWechatAndPersistedOrder() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setAppPlatformAppId("wx-app-id"); + config.setMerchantId("wx-service-mch"); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setPayCallbackUrl("/pay-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse(); + response.setPrepayId("prepay-id"); + when(appServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), any())).thenReturn(response); + + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(1234); + dto.setOutTradeNo("pay-expire-1001"); + dto.setBizId("order-1001"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + dto.setMchId(88L); + + service.appPay(dto, setting); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PrepayRequest.class); + verify(appServiceExtension).prepayWithRequestPayment(requestCaptor.capture(), any()); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + OffsetDateTime channelExpireTime = OffsetDateTime.parse(requestCaptor.getValue().getTimeExpire()); + Assertions.assertEquals(orderCaptor.getValue().getExpireTime().withNano(0), channelExpireTime.toLocalDateTime()); + Assertions.assertTrue(orderCaptor.getValue().getExpireTime().isAfter(java.time.LocalDateTime.now().plusMinutes(14))); + } + + /** + * 验证微信 App 关单只使用订单配置快照中的子商户号,且 SDK 异常不能被误判为关单成功。 + */ + @Test + void closeShouldUseSnapshotSubMerchantAndKeepUnknownResultRetryable() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setMerchantId("wx-service-mch"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + ThirdPayOrderEntity order = refundingOrder(); + + Assertions.assertTrue(service.close(order.getPayNo(), order.getMchPaySettingJson())); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloseOrderRequest.class); + verify(appServiceExtension).closeOrder(requestCaptor.capture()); + Assertions.assertEquals(order.getPayNo(), requestCaptor.getValue().getOutTradeNo()); + Assertions.assertEquals("wx-service-mch", requestCaptor.getValue().getSpMchid()); + Assertions.assertEquals("wx-sub-1001", requestCaptor.getValue().getSubMchid()); + + doThrow(new RuntimeException("network")).when(appServiceExtension).closeOrder(any(CloseOrderRequest.class)); + Assertions.assertFalse(service.close(order.getPayNo(), order.getMchPaySettingJson())); + } + + /** + * 构造包含微信子商户快照的退款中订单。 + * + * @return 退款查询所需支付订单 + */ + private ThirdPayOrderEntity refundingOrder() { + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setId(1L); + payOrder.setPayNo("pay-1001"); + payOrder.setRefundNo("refund-pay-1001"); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + return payOrder; + } + + /** + * 构造可发起普通退款的已支付微信订单。 + * + * @return 包含金额、状态和子商户配置快照的支付订单 + */ + private ThirdPayOrderEntity paidRefundableOrder() { + ThirdPayOrderEntity payOrder = refundingOrder(); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + payOrder.setPrice(1234); + return payOrder; + } + + /** + * 构造指定状态的微信退款查询响应。 + * + * @param status 微信退款状态 + * @return 包含订单号和金额的退款响应 + */ + private Refund refund(Status status) { + Amount amount = new Amount(); + amount.setRefund(500L); + amount.setTotal(1234L); + Refund refund = new Refund(); + refund.setOutTradeNo("pay-1001"); + refund.setTransactionId("wx-transaction-1001"); + refund.setStatus(status); + refund.setAmount(amount); + return refund; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java new file mode 100644 index 00000000..ff87a0b9 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java @@ -0,0 +1,204 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskStatusEnum; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayOrderDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * 支付订单管理端投影测试,覆盖商户归属、直付通 smid、分账阶段和敏感配置脱敏。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPayOrderServiceImplTest { + + /** 支付订单 Mapper Mock,返回固定分页。 */ + @Mock + private ThirdPayOrderMapper thirdPayOrderMapper; + + /** 用户远程服务 Mock,补充支付用户名称。 */ + @Mock + private RemoteUserService remoteUserService; + + /** 商户远程服务 Mock,补充可信商户名称。 */ + @Mock + private RemoteMchService remoteMchService; + + /** 分账订单服务 Mock,返回结构化阶段快照。 */ + @Mock + private ThirdFzOrderService thirdFzOrderService; + + /** 支付任务 Mapper Mock,用于验证同一支付订单的运营处置任务选择规则。 */ + @Mock + private ThirdPaymentTaskMapper thirdPaymentTaskMapper; + + /** 被测支付订单服务。 */ + @InjectMocks + private ThirdPayOrderServiceImpl service; + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", thirdPayOrderMapper); + ReflectionTestUtils.setField(service, "thirdPaymentTaskMapper", thirdPaymentTaskMapper); + lenient().when(thirdPaymentTaskMapper.selectList(any(Wrapper.class))).thenReturn(List.of()); + } + + /** + * 支付宝直付通订单应独立展示确认结算与平台抽佣,且浏览器不能获得完整商户配置快照。 + */ + @Test + void scopePageShouldProjectDirectPayStagesAndRemoveSettingSnapshot() { + ThirdPayOrderVo order = alipayOrder(); + Page page = new Page<>(1, 10); + page.setRecords(List.of(order)); + when(thirdPayOrderMapper.scopePage(any(Page.class), any(ThirdPayOrderDto.class), isNull())).thenReturn(page); + + SysUser user = new SysUser(); + user.setUserId(order.getUserId()); + user.setName("测试用户"); + when(remoteUserService.getInnerUserById(List.of(order.getUserId()))).thenReturn(R.ok(List.of(user))); + MchManageVo merchant = new MchManageVo(); + merchant.setId(order.getMchId()); + merchant.setName("测试商户"); + when(remoteMchService.getByIds(List.of(order.getMchId()))).thenReturn(R.ok(List.of(merchant))); + when(thirdFzOrderService.list(any(Wrapper.class))).thenReturn(List.of(fzOrder(order.getPayNo()))); + + ThirdPayOrderVo result = service.scopePage(new Page<>(1, 10), new ThirdPayOrderDto(), null).getRecords().get(0); + + Assertions.assertEquals("测试商户", result.getMchName()); + Assertions.assertEquals("smid-1001", result.getSmid()); + Assertions.assertEquals("SUCCESS", result.getSettleConfirmStatus()); + Assertions.assertEquals("PROCESSING", result.getRoyaltyStatus()); + Assertions.assertEquals("NOT_STARTED", result.getFzRefundStatus()); + Assertions.assertNotNull(result.getSettleConfirmReq()); + Assertions.assertNotNull(result.getRoyaltyRes()); + Assertions.assertNull(result.getMchPaySettingJson()); + } + + /** + * 支付宝仅完成确认结算时,历史 createReq 不得被误投影为尚未发起的平台抽佣请求。 + */ + @Test + void directPayConfirmRequestShouldNotBeDisplayedAsRoyaltyRequest() { + ThirdPayOrderVo order = alipayOrder(); + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setPayNo(order.getPayNo()); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + fzOrder.setCreateReq(JSONUtil.createObj().set("outRequestNo", "settle_pay-1001").toString()); + fzOrder.setCreateRes(JSONUtil.createObj() + .set("provider", "ALIPAY") + .set("settleConfirm", JSONUtil.createObj() + .set("status", "SUCCESS") + .set("request", JSONUtil.createObj().set("outRequestNo", "settle_pay-1001"))) + .toString()); + + ReflectionTestUtils.invokeMethod(service, "fillProfitSharingStage", order, fzOrder); + + Assertions.assertEquals("SUCCESS", order.getSettleConfirmStatus()); + Assertions.assertEquals("NOT_STARTED", order.getRoyaltyStatus()); + Assertions.assertNull(order.getRoyaltyReq()); + } + + /** + * 验证同一支付单的告警任务优先于较新的成功任务展示,人工重试不会误选已完成任务。 + */ + @Test + void taskProjectionShouldPreferActionableAlertOverNewerSuccess() { + ThirdPayOrderVo order = alipayOrder(); + ThirdPaymentTaskEntity success = new ThirdPaymentTaskEntity(); + success.setId(101L); + success.setPayNo(order.getPayNo()); + success.setStatus(ThirdPaymentTaskStatusEnum.SUCCESS.getCode()); + ThirdPaymentTaskEntity alert = new ThirdPaymentTaskEntity(); + alert.setId(102L); + alert.setPayNo(order.getPayNo()); + alert.setStatus(ThirdPaymentTaskStatusEnum.ALERT.getCode()); + alert.setAttemptCount(15); + alert.setLastErrorMessage("渠道状态长期未收敛"); + when(thirdPaymentTaskMapper.selectList(any(Wrapper.class))).thenReturn(List.of(success, alert)); + + ReflectionTestUtils.invokeMethod(service, "fillTaskStates", List.of(order)); + + Assertions.assertEquals(alert.getId(), order.getTaskId()); + Assertions.assertEquals(ThirdPaymentTaskStatusEnum.ALERT.getCode(), order.getTaskStatus()); + Assertions.assertEquals(15, order.getTaskAttemptCount()); + } + + /** + * 构造带完整支付宝商户快照的支付订单,用于验证出参只保留 smid。 + * + * @return 支付宝直付通支付订单视图 + */ + private ThirdPayOrderVo alipayOrder() { + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); + aliPaySetting.setAppAuthToken("sensitive-auth-token"); + MchPaySettingEntity setting = new MchPaySettingEntity(); + setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + + ThirdPayOrderVo order = new ThirdPayOrderVo(); + order.setId(1L); + order.setPayNo("pay-1001"); + order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + order.setUserId(10L); + order.setMchId(20L); + order.setEnableFz(YesNoEnum.YES.getCode()); + order.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + return order; + } + + /** + * 构造确认结算成功但平台抽佣仍在处理中的分账快照。 + * + * @param payNo 对应支付订单号 + * @return 分账订单 + */ + private ThirdFzOrderEntity fzOrder(String payNo) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setPayNo(payNo); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType()); + fzOrder.setCreateRes(JSONUtil.createObj() + .set("provider", "ALIPAY") + .set("settleConfirm", JSONUtil.createObj() + .set("status", "SUCCESS") + .set("request", JSONUtil.createObj().set("outRequestNo", "settle_pay-1001"))) + .set("royalty", JSONUtil.createObj() + .set("status", "PROCESSING") + .set("response", JSONUtil.createObj().set("subCode", "SYSTEM_ERROR"))) + .toString()); + return fzOrder; + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml index d7f33fbf..1bb34e64 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml +++ b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml @@ -15,6 +15,12 @@ 基于quartz后台跑批定时任务模块 + + + com.bsd + pjcloud-thirdparty-api + 5.7.0 + com.bsd pjcloud-daemon-quartz-api diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java new file mode 100644 index 00000000..66c52c9c --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializer.java @@ -0,0 +1,96 @@ +package com.bsd.pjcloud.daemon.quartz.config; + +import com.bsd.pjcloud.daemon.quartz.job.ThirdPaymentDispatchJob; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * 代码托管的支付任务触发器,不受后台 {@code sys_job} 增删、暂停或频率修改影响。 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ThirdPaymentQuartzInitializer implements InitializingBean { + /** 支付固定任务所属 Quartz 分组。 */ + private static final String GROUP = "THIRD_PAYMENT_SYSTEM"; + /** 每轮默认最大领取数量。 */ + private static final int DEFAULT_BATCH_SIZE = 100; + /** Quartz调度器。 */ + private final Scheduler scheduler; + + /** + * 支付任务与固定触发间隔;同一 JobKey 在 Quartz 集群中同一时刻只会由一个节点执行。 + */ + private static final List SCHEDULES = List.of( + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CREATE, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_QUERY, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_CREATE, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_CREATE, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_ROYALTY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_ROYALTY_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.BIZ_CALLBACK, 10), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION, 5), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CLOSE, 300), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, 300), + new PaymentSchedule(ThirdPaymentTaskTypeEnum.RECONCILE, 300)); + + /** + * 幂等注册固定任务。多副本同时启动时,Quartz数据库唯一键裁决创建竞争。 + * @throws SchedulerException Quartz持久化失败 + */ + @Override + public void afterPropertiesSet() throws SchedulerException { + for (PaymentSchedule schedule : SCHEDULES) { + register(schedule); + } + } + + /** + * 创建或覆盖一个代码托管 Job,并重置其固定频率。 + * @param schedule 任务类型和秒级间隔 + * @throws SchedulerException Quartz持久化失败 + */ + private void register(PaymentSchedule schedule) throws SchedulerException { + String name = "third-payment-" + schedule.taskType().getCode().toLowerCase().replace('_', '-'); + JobKey jobKey = JobKey.jobKey(name, GROUP); + TriggerKey triggerKey = TriggerKey.triggerKey(name + "-trigger", GROUP); + JobDetail job = JobBuilder.newJob(ThirdPaymentDispatchJob.class).withIdentity(jobKey) + .usingJobData(ThirdPaymentDispatchJob.TASK_TYPE_KEY, schedule.taskType().getCode()) + .usingJobData(ThirdPaymentDispatchJob.BATCH_SIZE_KEY, DEFAULT_BATCH_SIZE) + .requestRecovery(true).storeDurably(true).build(); + SimpleScheduleBuilder simpleSchedule = SimpleScheduleBuilder.simpleSchedule() + .withIntervalInSeconds(schedule.intervalSeconds()).repeatForever() + .withMisfireHandlingInstructionNextWithRemainingCount(); + Trigger trigger = TriggerBuilder.newTrigger().withIdentity(triggerKey).forJob(jobKey) + .startNow().withSchedule(simpleSchedule).build(); + try { + scheduler.addJob(job, true); + if (scheduler.checkExists(triggerKey)) { + scheduler.rescheduleJob(triggerKey, trigger); + } else { + scheduler.scheduleJob(trigger); + } + } catch (ObjectAlreadyExistsException exception) { + // 多节点同时首次部署时另一个实例已完成注册,读取共享QRTZ表即可。 + log.info("支付Quartz任务已由其他集群节点初始化,jobKey:{}", jobKey); + } + } + + /** + * 固定任务频率配置。 + * @param taskType 支付任务类型 + * @param intervalSeconds 触发间隔,单位秒 + */ + private record PaymentSchedule(ThirdPaymentTaskTypeEnum taskType, int intervalSeconds) { + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java new file mode 100644 index 00000000..90143324 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/job/ThirdPaymentDispatchJob.java @@ -0,0 +1,58 @@ +package com.bsd.pjcloud.daemon.quartz.job; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdPaymentTaskService; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +/** + * 支付任务固定 Quartz Job,只同步确认 thirdparty 接受分发,不直接访问支付 SDK。 + */ +@Slf4j +@DisallowConcurrentExecution +public class ThirdPaymentDispatchJob implements Job { + /** JobDataMap中的任务类型键。 */ + public static final String TASK_TYPE_KEY = "taskType"; + /** JobDataMap中的批量大小键。 */ + public static final String BATCH_SIZE_KEY = "batchSize"; + /** thirdparty内部任务分发 Feign 客户端,由 Quartz JobFactory 注入。 */ + @Autowired + private RemoteThirdPaymentTaskService paymentTaskService; + + /** + * 调用服务发现后的 thirdparty 内部接口;业务响应失败会让 Quartz 记录本次执行失败。 + * @param context Quartz触发上下文 + * @throws JobExecutionException thirdparty未接受或调用异常 + */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + String taskType = context.getMergedJobDataMap().getString(TASK_TYPE_KEY); + int batchSize = context.getMergedJobDataMap().getIntValue(BATCH_SIZE_KEY); + ThirdPaymentTaskDispatchDto dto = new ThirdPaymentTaskDispatchDto(); + dto.setTaskType(taskType); + dto.setBatchSize(batchSize); + dto.setTriggerSource("QUARTZ"); + dto.setTriggerTime(LocalDateTime.now()); + try { + R result = paymentTaskService.dispatch(dto); + if (result == null || !result.isOk() || result.getData() == null) { + throw new JobExecutionException("thirdparty未接受支付任务分发:" + taskType); + } + ThirdPaymentTaskDispatchVo data = result.getData(); + if (data.isBusy() || data.getBacklogCount() > 0) { + log.warn("支付任务分发存在积压,taskType:{},claimed:{},backlog:{},busy:{}", + taskType, data.getClaimedCount(), data.getBacklogCount(), data.isBusy()); + } + } catch (JobExecutionException exception) { + throw exception; + } catch (Exception exception) { + throw new JobExecutionException("调用thirdparty支付任务分发失败:" + taskType, exception, false); + } + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml index 446f6315..6a25742b 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/resources/quartz-config.yml @@ -11,12 +11,14 @@ spring: class: org.springframework.scheduling.quartz.LocalDataSourceJobStore # 数据库存储 driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate # 数据库代理 tablePrefix: QRTZ_ # 表前缀 - isClustered: false # 集群 - clusterCheckinInterval: 30000 # 集群检查间隔 + isClustered: true # 支付任务只由Quartz集群触发,生产实例必须共享同一套QRTZ表 + clusterCheckinInterval: 10000 # 集群心跳,节点失联后由存活实例接管 + misfireThreshold: 60000 # 允许一分钟短暂抖动,恢复后按misfire策略继续触发 + acquireTriggersWithinLock: true # 多节点批量获取Trigger时在数据库锁内完成 useProperties: false threadPool: class: org.quartz.simpl.SimpleThreadPool # 线程池 - threadCount: 3 # 线程数量 + threadCount: 8 # 支付固定触发器与既有通用任务共享,外部支付调用不在Quartz线程执行 threadPriority: 5 # 线程优先级 threadsInheritContextClassLoaderOfInitializingThread: true # 是否继承类加载器 job-store-type: jdbc # 持久化到数据库 diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java new file mode 100644 index 00000000..8eb8576f --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/config/ThirdPaymentQuartzInitializerTest.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.daemon.quartz.config; + +import com.bsd.pjcloud.daemon.quartz.job.ThirdPaymentDispatchJob; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; +import org.junit.jupiter.api.Test; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SimpleTrigger; +import org.quartz.Trigger; +import org.quartz.TriggerKey; +import org.quartz.impl.StdSchedulerFactory; +import org.quartz.impl.matchers.GroupMatcher; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 支付固定 Quartz 触发器初始化测试,验证服务重复启动不会产生重复调度记录。 + */ +class ThirdPaymentQuartzInitializerTest { + + /** + * 使用内存 JobStore 连续初始化两次,确认 Job、Trigger、任务参数和固定频率均保持稳定。 + * + * @throws Exception Quartz 调度器创建、查询或关闭失败 + */ + @Test + void shouldRegisterManagedPaymentSchedulesIdempotently() throws Exception { + Properties properties = new Properties(); + properties.setProperty("org.quartz.scheduler.instanceName", "thirdPaymentInitializerTest"); + properties.setProperty("org.quartz.threadPool.threadCount", "1"); + properties.setProperty("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore"); + Scheduler scheduler = new StdSchedulerFactory(properties).getScheduler(); + try { + ThirdPaymentQuartzInitializer initializer = new ThirdPaymentQuartzInitializer(scheduler); + initializer.afterPropertiesSet(); + initializer.afterPropertiesSet(); + + String group = "THIRD_PAYMENT_SYSTEM"; + assertThat(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group))).hasSize(15); + assertThat(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(group))).hasSize(15); + + JobKey jobKey = JobKey.jobKey("third-payment-pay-create", group); + assertThat(scheduler.getJobDetail(jobKey).getJobClass()).isEqualTo(ThirdPaymentDispatchJob.class); + assertThat(scheduler.getJobDetail(jobKey).getJobDataMap().getString(ThirdPaymentDispatchJob.TASK_TYPE_KEY)) + .isEqualTo(ThirdPaymentTaskTypeEnum.PAY_CREATE.getCode()); + assertThat(scheduler.getJobDetail(jobKey).getJobDataMap().getInt(ThirdPaymentDispatchJob.BATCH_SIZE_KEY)) + .isEqualTo(100); + + TriggerKey triggerKey = TriggerKey.triggerKey("third-payment-pay-create-trigger", group); + Trigger trigger = scheduler.getTrigger(triggerKey); + assertThat(trigger).isInstanceOf(SimpleTrigger.class); + assertThat(((SimpleTrigger) trigger).getRepeatInterval()).isEqualTo(5_000L); + } finally { + scheduler.shutdown(true); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java index b6c96de5..7d87b4e0 100755 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java @@ -57,9 +57,9 @@ public class MchPaySettingEntity extends Model { private String settingJson; /** - * 分账比例(最高100%即分给对方商户的%) + * 平台抽佣比例,单位为百分比,取值范围为大于0且不超过100。 */ - @Schema(description = "分账比例(最高100%即分给对方商户的%)") + @Schema(description = "平台抽佣比例,单位为百分比,取值范围为(0,100]") private Double fzRatio; /** @@ -117,14 +117,19 @@ public class MchPaySettingEntity extends Model { @Data static public class AliPaySetting { /** - * 子商户号 + * 支付宝直付通二级商户标识,保存支付宝分配的 smid/ip_role_id,不是平台应用商户号。 */ private String subMchid; /** - * 子商户授权token + * 支付宝第三方代调用授权令牌;无需代调用授权的账号允许为空。 */ private String appAuthToken; + + /** + * 平台支付宝命名账号键,来源于现有支付配置 JSON;为空时使用平台默认账号。 + */ + private String accountKey; } -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java index bc68fab3..bd7a9293 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteCodeUseService.java @@ -37,6 +37,16 @@ public interface RemoteCodeUseService { @GetMapping("/wallet-code-use/cancel-code") R cancelCode(@RequestParam Long id); + /** + * 将预约订单二维码恢复为未核销状态,供订单核销纠错时再次使用。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @NoToken + @GetMapping("/wallet-code-use/rollback-reservation-code") + R rollbackReservationCode(@RequestParam Long id); + /** * 二维码是否核销 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java index 2d209ba7..eb61a54d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletCodeUseController.java @@ -45,6 +45,18 @@ public class WalletCodeUseController { return R.ok(walletCodeUseService.cancelCode(id)); } + /** + * 恢复预约订单二维码的未核销状态,仅供订单服务处理后台核销纠错。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @Inner + @GetMapping("/rollback-reservation-code") + public R rollbackReservationCode(@RequestParam Long id) { + return R.ok(walletCodeUseService.rollbackReservationCode(id)); + } + /** * 是否核销 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java index 7f4d700f..6847a892 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java @@ -68,7 +68,7 @@ public class MchPaySettingController { @GetMapping("/{id}") @PreAuthorize("@pms.hasPermission('wallet_mchPaySetting_view')") public R getById(@PathVariable("id") Long id) { - return R.ok(mchPaySettingService.getById(id)); + return R.ok(mchPaySettingService.getSafeById(id)); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java index c0fc4933..b332028a 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java @@ -12,12 +12,44 @@ import org.springframework.cache.annotation.CacheEvict; import java.util.List; +/** + * 商户支付配置服务,负责配置维护、数据权限查询以及客户端敏感信息脱敏。 + */ public interface MchPaySettingService extends IService { + /** + * 按数据权限分页查询可展示的支付配置,并移除客户端不需要的支付宝授权令牌。 + * + * @param page 分页参数 + * @param dto 支付配置筛选条件 + * @param dataScope 当前管理端数据权限 + * @return 已脱敏的支付配置分页 + */ Page scopePageList(Page page, MchPaySettingDto dto, DataScope dataScope); + /** + * 查询服务内部使用的完整支付配置,保留支付调用所需的授权令牌。 + * + * @param dto 支付配置筛选条件 + * @param dataScope 可选数据权限 + * @return 服务内部支付配置列表 + */ List pageList(MchPaySettingDto dto, DataScope dataScope); + /** + * 按主键获取管理端可编辑配置,支付宝授权令牌不会返回浏览器。 + * + * @param id 支付配置主键 + * @return 已脱敏的支付配置;不存在时返回 {@code null} + */ + MchPaySettingEntity getSafeById(Long id); + + /** + * 新增商户支付配置并通知第三方支付服务刷新商户快照。 + * + * @param dto 新增配置 + * @return 新增记录主键 + */ @CacheEvict( value = CacheConstants.WALLET_VIEWS_CANTEEN_MCH, allEntries = true, @@ -25,6 +57,12 @@ public interface MchPaySettingService extends IService { ) Long create(MchPaySettingDto dto); + /** + * 修改支付配置;支付宝授权令牌留空时保留已存值,返回结果不包含完整令牌。 + * + * @param dto 修改配置 + * @return 已脱敏的修改结果 + */ @CacheEvict( value = CacheConstants.WALLET_VIEWS_CANTEEN_MCH, condition = "#dto.getMchId() != null", @@ -34,7 +72,10 @@ public interface MchPaySettingService extends IService { MchPaySettingEntity edit(MchPaySettingDto dto); /** - * 用户可以支付的方式(目前微信、支付宝) + * 查询用户可用的微信、支付宝支付方式,返回客户端前移除支付宝授权令牌。 + * + * @param user 当前登录用户 + * @return 已脱敏的可用支付配置 */ List getUserPaySettingList(PjcloudUser user); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java index b5fb4d16..d7080fc0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletCodeUseService.java @@ -27,6 +27,14 @@ public interface WalletCodeUseService extends IService { */ Boolean cancelCode(Long id); + /** + * 恢复预约订单二维码的未核销状态,不允许影响钱包码、活动码等其他业务二维码。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + Boolean rollbackReservationCode(Long id); + /** * 二维码是否核销 */ @@ -41,4 +49,4 @@ public interface WalletCodeUseService extends IService { * 根据短码查询加密信息 */ WalletCodeUseEntity getInfoByShortCode(String shortCode); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index bc9ca188..99bf9c56 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -98,6 +98,17 @@ public interface WalletTransactionService extends IService 0; } + /** + * 将预约订单对应的最新二维码恢复为未使用状态。业务类型白名单用于防止订单 ID 与其他二维码业务 ID + * 偶然相同后误恢复钱包码或活动码;条件更新用于和并发核销串行竞争,避免覆盖新的使用结果。 + * + * @param id 预约订单业务主键 + * @return 找到预约二维码且已恢复或原本未使用时返回 {@code true} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean rollbackReservationCode(Long id) { + WalletCodeUseEntity codeUse = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(WalletCodeUseEntity::getBizId, id) + .in(WalletCodeUseEntity::getBizType, + WalletCodeBizEnum.CANTEEN_RESERVE.getCode(), WalletCodeBizEnum.SUPERMARKET_RESERVE.getCode()) + .orderByDesc(WalletCodeUseEntity::getCreateTime) + .orderByDesc(WalletCodeUseEntity::getId) + .last("limit 1")); + if (ObjectUtil.isEmpty(codeUse)) return false; + if (ObjectUtil.equals(codeUse.getStatus(), YesNoEnum.NO.getCode())) return true; + if (ObjectUtil.notEqual(codeUse.getStatus(), YesNoEnum.YES.getCode())) return false; + return baseMapper.update(null, new LambdaUpdateWrapper() + .set(WalletCodeUseEntity::getStatus, YesNoEnum.NO.getCode()) + .set(WalletCodeUseEntity::getUseTime, null) + .eq(WalletCodeUseEntity::getId, codeUse.getId()) + .eq(WalletCodeUseEntity::getStatus, YesNoEnum.YES.getCode())) == 1; + } + @Override @Transactional(rollbackFor = Exception.class) public Boolean isCancelCode(Long id) { @@ -283,4 +311,4 @@ public class WalletCodeUseServiceImpl extends ServiceImpl walletManageMapper.updateWhenRecharge(walletId, price); + case REFUND, TRANSFER_ACCOUNTS_REFUND, RETURN_CANTEEN -> walletManageMapper.updateWhenConsumptionRefund(walletId, price); + default -> throw new UnsupportedOperationException("待实现"); + } + WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, price, + wallet.getBalance() + price, bizId, bizType); + save(transaction); + return transaction; + } + /** * 增加钱包余额并将来源业务的财务标签同步到最终流水,供后续列表筛选和金额统计。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java index 8b65393b..1d70f7ac 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java @@ -1,6 +1,8 @@ package com.bsd.pjcloud.wallet.service.impl.mch; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -11,6 +13,7 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; @@ -49,7 +52,9 @@ public class MchPaySettingServiceImpl extends ServiceImpl scopePageList(Page page, MchPaySettingDto dto, DataScope dataScope) { - return baseMapper.scopePageList(page, dto, dataScope); + Page result = baseMapper.scopePageList(page, dto, dataScope); + result.setRecords(result.getRecords().stream().map(this::sanitizeForClient).toList()); + return result; } @Override @@ -57,6 +62,18 @@ public class MchPaySettingServiceImpl extends ServiceImpl { try { @@ -92,7 +110,7 @@ public class MchPaySettingServiceImpl extends ServiceImpl= 1; } -} \ No newline at end of file + /** + * 管理端因安全原因读取不到旧 token,提交空 token 时应保留数据库中的已配置值。 + * + * @param dto 管理端提交的支付配置 + */ + private void preserveAliPayAuthToken(MchPaySettingDto dto) { + if (dto == null || dto.getId() == null || !AppPlusPlatformPayType.ALI_PAY.getType().equals(dto.getPlatformType()) + || StrUtil.isBlank(dto.getSettingJson())) { + return; + } + MchPaySettingEntity existing = getById(dto.getId()); + if (existing == null || StrUtil.isBlank(existing.getSettingJson())) { + return; + } + try { + MchPaySettingEntity.AliPaySetting incoming = JSONUtil.toBean( + dto.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + MchPaySettingEntity.AliPaySetting stored = JSONUtil.toBean( + existing.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (incoming != null && stored != null && StrUtil.isBlank(incoming.getAppAuthToken()) + && StrUtil.isNotBlank(stored.getAppAuthToken())) { + incoming.setAppAuthToken(stored.getAppAuthToken()); + dto.setSettingJson(JSONUtil.toJsonStr(incoming)); + } + } catch (RuntimeException exception) { + throw new RuntimeException("支付宝支付配置无效"); + } + } + + /** + * 复制并脱敏支付配置,保证调用方不能通过返回对象修改缓存或内部商户配置。 + * + * @param source 内部完整支付配置 + * @param 支付配置实体或视图类型 + * @return 同类型的脱敏副本 + */ + @SuppressWarnings("unchecked") + private T sanitizeForClient(T source) { + if (source == null) { + return null; + } + T target = (T) BeanUtil.toBean(source, source.getClass()); + if (!AppPlusPlatformPayType.ALI_PAY.getType().equals(target.getPlatformType()) || StrUtil.isBlank(target.getSettingJson())) { + return target; + } + try { + MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean( + target.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + if (aliPaySetting != null) { + aliPaySetting.setAppAuthToken(null); + target.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + } + } catch (RuntimeException exception) { + // 无法解析的历史配置不应原样下发,以免其中包含未识别的敏感字段。 + target.setSettingJson(null); + } + return target; + } + +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java index 8022f66c..ed1292ab 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargePackageServiceImpl.java @@ -90,8 +90,9 @@ public class WalletRechargePackageServiceImpl extends ServiceImpl { + WalletTransactionEntity transaction = invocation.getArgument(0); + transaction.setId(1001L); + transaction.setTenantId(1L); + return 1; + }); + when(walletManageMapper.selectById(20L)).thenReturn(wallet); + when(walletMessageOutboxMapper.insert(any(WalletMessageOutboxEntity.class))).thenReturn(1); + + WalletTransactionEntity result = service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE); + + assertEquals(50, result.getPrice()); + assertEquals(150, result.getBalance()); + assertEquals("pay-1001", result.getBizId()); + verify(walletManageMapper).updateWhenRecharge(20L, 50); + verify(walletTransactionMapper).insert(any(WalletTransactionEntity.class)); + verify(walletMessageOutboxMapper).insert(any(WalletMessageOutboxEntity.class)); + } + + /** + * 已存在金额一致的充值流水应直接返回,不再次修改钱包余额。 + */ + @Test + void repeatedIdempotentCreditShouldReturnExistingTransaction() { + WalletManageEntity wallet = wallet(20L, 1L); + wallet.setBalance(150); + WalletTransactionEntity existing = new WalletTransactionEntity(); + existing.setWalletId(20L); + existing.setPrice(50); + existing.setBizId("pay-1001"); + existing.setBizType(WalletBizEnum.RECHARGE.getCode()); + when(walletManageMapper.selectByIdForUpdate(20L)).thenReturn(wallet); + when(walletTransactionMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + + assertSame(existing, service.addWalletBalanceIdempotent(20L, 50, "pay-1001", WalletBizEnum.RECHARGE)); + + verify(walletManageMapper, never()).updateWhenRecharge(any(), any()); + verify(walletTransactionMapper, never()).insert(any(WalletTransactionEntity.class)); + } + /** * 构造指定所有人和 ID 的 EAT 饭卡。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java new file mode 100644 index 00000000..11e14279 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java @@ -0,0 +1,123 @@ +package com.bsd.pjcloud.wallet.service.impl.mch; + +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; +import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.mapper.mch.MchPaySettingMapper; +import com.bsd.pjcloud.wallet.service.MchManageService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 商户支付配置支付宝授权令牌脱敏和更新保留测试。 + */ +@ExtendWith(MockitoExtension.class) +class MchPaySettingServiceImplTest { + + /** 支付配置 Mapper Mock,提供数据库中的完整配置。 */ + @Mock + private MchPaySettingMapper mchPaySettingMapper; + + /** 商户服务 Mock,仅补齐异步刷新依赖。 */ + @Mock + private MchManageService mchManageService; + + /** 用户远程服务 Mock,仅补齐构造依赖。 */ + @Mock + private RemoteUserService remoteUserService; + + /** 第三方支付远程服务 Mock,仅补齐异步刷新依赖。 */ + @Mock + private RemoterThirdAppPlusPayService thirdAppPlusPayService; + + /** 被测支付配置服务。 */ + @InjectMocks + private MchPaySettingServiceImpl service; + + /** + * 将 MyBatis-Plus 基类 Mapper 指向当前 Mock。 + */ + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "baseMapper", mchPaySettingMapper); + } + + /** + * 验证管理端详情保留 smid 和账号键,但不返回完整授权令牌。 + */ + @Test + void getSafeByIdShouldRemoveAliPayAuthToken() { + MchPaySettingEntity stored = storedAliPaySetting(); + when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + + MchPaySettingEntity result = service.getSafeById(stored.getId()); + + MchPaySettingEntity.AliPaySetting safe = JSONUtil.toBean( + result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertEquals("smid-1001", safe.getSubMchid()); + Assertions.assertEquals("secondary", safe.getAccountKey()); + Assertions.assertNull(safe.getAppAuthToken()); + Assertions.assertTrue(stored.getSettingJson().contains("sensitive-auth-token")); + } + + /** + * 验证前端提交空 token 时保存旧值,但接口返回值仍保持脱敏。 + */ + @Test + void editShouldPreserveStoredAliPayAuthTokenWithoutReturningIt() { + MchPaySettingEntity stored = storedAliPaySetting(); + when(mchPaySettingMapper.selectCount(any())).thenReturn(1L); + when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + MchPaySettingEntity.AliPaySetting incomingSetting = new MchPaySettingEntity.AliPaySetting(); + incomingSetting.setSubMchid("smid-1001"); + incomingSetting.setAccountKey("secondary"); + incomingSetting.setAppAuthToken(""); + MchPaySettingDto incoming = new MchPaySettingDto(); + incoming.setId(stored.getId()); + incoming.setMchId(stored.getMchId()); + incoming.setPlatformType("20"); + incoming.setSettingJson(JSONUtil.toJsonStr(incomingSetting)); + + MchPaySettingEntity result = service.edit(incoming); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MchPaySettingEntity.class); + verify(mchPaySettingMapper).updateById(updateCaptor.capture()); + MchPaySettingEntity.AliPaySetting persisted = JSONUtil.toBean( + updateCaptor.getValue().getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertEquals("sensitive-auth-token", persisted.getAppAuthToken()); + MchPaySettingEntity.AliPaySetting safeResult = JSONUtil.toBean( + result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertNull(safeResult.getAppAuthToken()); + } + + /** + * 构造数据库中带完整支付宝授权令牌的支付配置。 + * + * @return 完整内部配置 + */ + private MchPaySettingEntity storedAliPaySetting() { + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); + aliPaySetting.setAccountKey("secondary"); + aliPaySetting.setAppAuthToken("sensitive-auth-token"); + MchPaySettingEntity stored = new MchPaySettingEntity(); + stored.setId(1L); + stored.setMchId(88L); + stored.setPlatformType("20"); + stored.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); + return stored; + } +} From 9d2a0ee1ab1fce55abaa5644981fc3413eea41eb Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 25 Aug 2026 11:11:01 +0800 Subject: [PATCH 13/13] =?UTF-8?q?feat:=E9=92=B1=E5=8C=85=E7=9A=84=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E5=A2=9E=E5=8A=A0"=E5=A4=87=E6=B3=A8=E5=AD=97?= =?UTF-8?q?=E6=AE=B5"=20fix:=E5=90=88=E5=B9=B6=E8=AE=BE=E5=A4=87=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=9A=84=E7=BB=9F=E8=AE=A1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260824_add_third_payment_task.sql | 98 +++- db/20260825_add_third_mch_fz_relation.sql | 79 +++ db/20260825_add_wallet_transaction_remark.sql | 3 + db/800pjcloudx_wallet.sql | 1 + .../hard/api/dto/order/HardOrderDto.java | 5 + .../HardCanteenProductApptExportRecord.java | 22 +- .../excel/HardCanteenProductApptExcelVo.java | 48 +- ...dCanteenProductApptExportMergeHandler.java | 18 +- .../hard/mapper/order/HardCarMapper.java | 27 +- .../hard/service/HardCanteenTotalService.java | 22 +- .../order/HardCanteenTotalServiceImpl.java | 17 +- .../resources/mapper/order/HardCarMapper.xml | 280 ++++++----- .../fz/ThirdMchFzRelationSceneEnum.java | 24 + .../fz/ThirdMchFzRelationStatusEnum.java | 32 ++ .../pay/ThirdPaymentTaskTypeEnum.java | 2 +- .../dto/pay/ThirdPaymentTaskDispatchDto.java | 2 +- .../entity/pay/ThirdMchFzRelationEntity.java | 87 ++++ .../pay/ThirdPaymentTaskAttemptEntity.java | 28 +- .../entity/pay/ThirdPaymentTaskEntity.java | 54 +-- .../vo/pay/ThirdPaymentTaskDispatchVo.java | 2 +- .../job/ThirdAggregateOrderPayJob.java | 39 +- .../thirdparty/job/ThirdAppPlusPayJob.java | 37 +- .../mapper/pay/ThirdMchFzRelationMapper.java | 12 + .../mapper/pay/ThirdPaymentTaskMapper.java | 14 + .../service/ThirdMchFzRelationService.java | 34 ++ .../service/ThirdPayPollingCoordinator.java | 10 +- .../service/ThirdPaymentTaskEnqueuer.java | 100 +++- .../ThirdAggregateAliPayServiceImpl.java | 12 +- .../ThirdAggregateOrderPayServiceImpl.java | 24 +- .../ThirdAppPlusAliPayServiceImpl.java | 17 +- .../ThirdAggregatePaySessionServiceImpl.java | 31 +- .../pay/ThirdMchFzRelationServiceImpl.java | 456 ++++++++++++++++++ .../impl/pay/ThirdPaymentTaskServiceImpl.java | 114 ++++- .../mapper/ThirdPaymentTaskMapper.xml | 11 +- .../job/ThirdAggregateOrderPayJobTest.java | 36 +- ...ThirdPaymentTaskMapperSqlContractTest.java | 52 ++ .../service/ThirdPaymentTaskEnqueuerTest.java | 20 + .../ThirdAggregateAliPayServiceImplTest.java | 21 + ...ThirdAggregateOrderPayServiceImplTest.java | 46 +- .../ThirdAppPlusAliPayServiceImplTest.java | 16 + .../pay/ThirdPaymentTaskServiceImplTest.java | 274 +++++++++++ .../config/ThirdPaymentQuartzInitializer.java | 7 +- .../quartz/job/ThirdPaymentDispatchJob.java | 4 +- .../api/dto/WalletTransactionRemarkDto.java | 28 ++ .../api/entity/WalletTransactionEntity.java | 8 + .../WalletTransactionController.java | 21 +- .../service/WalletTransactionService.java | 22 + .../impl/WalletTransactionServiceImpl.java | 48 +- .../mapper/WalletTransactionMapper.xml | 2 + 49 files changed, 1943 insertions(+), 424 deletions(-) create mode 100644 db/20260825_add_third_mch_fz_relation.sql create mode 100644 db/20260825_add_wallet_transaction_remark.sql create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationSceneEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/fz/ThirdMchFzRelationStatusEnum.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/entity/pay/ThirdMchFzRelationEntity.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdMchFzRelationMapper.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionRemarkDto.java diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql index 7fc7ded9..31468234 100644 --- a/db/20260824_add_third_payment_task.sql +++ b/db/20260824_add_third_payment_task.sql @@ -31,8 +31,8 @@ CREATE TABLE IF NOT EXISTS `third_payment_task` `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', PRIMARY KEY (`id`), UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`), - KEY `idx_third_payment_task_due` (`status`, `next_run_time`, `priority`, `id`), - KEY `idx_third_payment_task_lease` (`status`, `lease_expire_time`), + KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`), + KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`), KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`), KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实'; @@ -57,3 +57,97 @@ CREATE TABLE IF NOT EXISTS `third_payment_task_attempt` KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`), KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试'; + +-- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。 +-- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。 +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'), + 'SELECT ''idx_third_pay_order_task_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'), + 'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'), + 'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'), + 'SELECT ''idx_third_pay_order_close_scan already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'), + 'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message', + 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'), + 'SELECT ''idx_third_fz_order_task_scan already exists'' AS message', + 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'), + 'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message', + 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'), + 'SELECT ''idx_third_aggregate_task_scan already exists'' AS message', + 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +SET @index_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'), + 'SELECT ''idx_third_aggregate_close_scan already exists'' AS message', + 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。 diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql new file mode 100644 index 00000000..25115867 --- /dev/null +++ b/db/20260825_add_third_mch_fz_relation.sql @@ -0,0 +1,79 @@ +USE `pjcloudx_thirdparty`; + +-- 商户默认平台分佣关系。微信关系由 sub_mchid 区分,支付宝关系由调用应用、授权商户和接收账号共同确定。 +-- 表中只保存渠道标识和脱敏调用快照,不保存私钥、公钥或完整 app_auth_token。 +CREATE TABLE IF NOT EXISTS `third_mch_fz_relation` +( + `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成', + `tenant_id` bigint NOT NULL COMMENT '租户ID,与 mch_manage 所属租户一致', + `mch_id` bigint NOT NULL COMMENT 'mch_manage 商户主键', + `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting 支付配置主键', + `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝', + `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为 DEFAULT_PLATFORM_COMMISSION', + `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照', + `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或支付宝直付通smid/ip_role_id', + `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空', + `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName', + `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏', + `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照', + `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝为空', + `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号', + `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED', + `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置的默认平台分佣关系:0否、1是', + `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照', + `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照', + `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据', + `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态', + `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间', + `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间', + `create_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(32) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_mch_fz_relation_identity` + (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`, + `receiver_type`, `receiver_account`, `relation_scene`), + KEY `idx_third_mch_fz_relation_default` + (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`), + KEY `idx_third_mch_fz_relation_task` + (`status`, `is_default`, `update_time`, `id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci + COMMENT ='商户第三方支付分账关系'; + +-- 支付任务通过结构化主键定位关系事实,禁止从 task_key 字符串反解析业务主键。 +SET @relation_task_column_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'third_payment_task' + AND COLUMN_NAME = 'fz_relation_id' +); +SET @relation_task_column_sql = IF( + @relation_task_column_exists = 0, + 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`, ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)', + 'SELECT 1' +); +PREPARE relation_task_column_stmt FROM @relation_task_column_sql; +EXECUTE relation_task_column_stmt; +DEALLOCATE PREPARE relation_task_column_stmt; + +-- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。 +SET @pay_relation_column_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'third_pay_order' + AND COLUMN_NAME = 'fz_relation_id' +); +SET @pay_relation_column_sql = IF( + @pay_relation_column_exists = 0, + 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`, ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', + 'SELECT 1' +); +PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql; +EXECUTE pay_relation_column_stmt; +DEALLOCATE PREPARE pay_relation_column_stmt; diff --git a/db/20260825_add_wallet_transaction_remark.sql b/db/20260825_add_wallet_transaction_remark.sql new file mode 100644 index 00000000..ee5f5b73 --- /dev/null +++ b/db/20260825_add_wallet_transaction_remark.sql @@ -0,0 +1,3 @@ +-- 为每笔钱包流水增加可选业务备注;历史流水保持为空,前后端统一限制为 256 个字符。 +ALTER TABLE wallet_transaction + ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符' AFTER tags; diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql index c4583ce2..462e9448 100644 --- a/db/800pjcloudx_wallet.sql +++ b/db/800pjcloudx_wallet.sql @@ -456,6 +456,7 @@ CREATE TABLE wallet_transaction price int NOT NULL COMMENT '交易金额, 单位分', balance int NOT NULL COMMENT '余额, 单位分', tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔', + remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符', create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_time datetime DEFAULT NULL COMMENT '创建时间', update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index 0a2829e9..a25062a1 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -56,6 +56,11 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn */ private List saleSourceCodes; + /** + * 商品名称模糊查询条件,仅供购物车商品统计 SQL 筛选使用,不对应订单表持久化字段。 + */ + private String productName; + private LocalDateTime[] refundTimeList; /** 预计使用日期范围。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java index cfb4aeb2..96f057a1 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java @@ -3,23 +3,27 @@ package com.bsd.pjcloud.hard.api.vo.order; import lombok.Data; /** - * 食堂预约菜品导出聚合记录。 + * 食堂菜品销售三级导出的聚合记录。 * - *

同一模型承载商户部门聚合和商品聚合两种查询结果,商品聚合时额外填充商品字段;金额统一使用分。

+ *

同一模型承载日期餐段、商户部门和商品三种聚合查询结果,商品聚合时额外填充商品字段; + * 统计时间已按销售来源统一转换为业务发生日期,金额统一使用分。

*/ @Data public class HardCanteenProductApptExportRecord { - /** 预约使用日期,格式为 yyyy-MM-dd。 */ - private String apptDate; + /** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */ + private String statisticsDate; /** 用餐时间段协议编码。 */ private String hourType; + /** 销售来源协议编码;为空表示当前筛选范围内的跨来源合计。 */ + private String saleSource; + /** 食堂商户主键。 */ private Long merchantId; - /** 预约用户所属部门主键。 */ + /** 购买用户所属部门主键。 */ private Long userDeptId; /** 商品主键;商户部门聚合记录为空。 */ @@ -28,15 +32,15 @@ public class HardCanteenProductApptExportRecord { /** 订单快照中的商品名称;商户部门聚合记录为空。 */ private String productName; - /** 去重预约人数。 */ + /** 当前统计层级内去重后的交易人数。 */ private Long payUserCount; - /** 去重预约次数。 */ + /** 当前统计层级内去重后的订单数量。 */ private Long timeCount; - /** 预约商品总数量。 */ + /** 有效商品总数量,预约来源已扣减退款完成数量。 */ private Long payCount; - /** 预约商品总价,单位为分。 */ + /** 有效商品总价,单位为分;预约来源已扣减退款完成金额。 */ private Long totalPrice; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java index bdf91045..f83e31a4 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java @@ -9,32 +9,36 @@ import java.math.BigDecimal; import java.time.LocalDate; /** - * 食堂预约菜品统计自定义导出行。 + * 食堂菜品销售统计自定义导出行。 * - *

一组日期和餐段按汇总行、商户部门行、商品明细行组织;商品统计在同一商户部门块内按名称合并套餐展开后的明细, - * 不同层级只填写所属列,配合自定义合并处理器形成树形报表。

+ *

一个日期、餐段和来源口径按汇总行、商户部门行、商品明细行组织;来源口径包含筛选范围合计及各销售来源。 + * 商品统计在同一商户部门块内按名称合并套餐展开后的明细,不同层级只填写所属列,配合自定义合并处理器形成树形报表。

*/ @Data public class HardCanteenProductApptExcelVo { - /** 日期餐段汇总中的预约日期。 */ - @ExcelProperty({"日期餐段汇总", "预约时间"}) + /** 日期餐段汇总中的业务发生日期。 */ + @ExcelProperty({"日期餐段汇总", "统计日期"}) private LocalDate summaryDate; /** 日期餐段汇总中的餐段编码或字典名称。 */ - @ExcelProperty({"日期餐段汇总", "预约时间段"}) + @ExcelProperty({"日期餐段汇总", "餐段"}) private String summaryHourType; - /** 日期餐段汇总中的去重预约人数。 */ - @ExcelProperty({"日期餐段汇总", "预约人数"}) + /** 日期餐段汇总的来源口径,取值为筛选范围合计、线下购买或预约核销。 */ + @ExcelProperty({"日期餐段汇总", "来源口径"}) + private String summarySaleSourceName; + + /** 日期餐段汇总中的去重交易人数。 */ + @ExcelProperty({"日期餐段汇总", "交易人数"}) private Long summaryPayUserCount; - /** 日期餐段汇总中的预约次数。 */ - @ExcelProperty({"日期餐段汇总", "预约次数"}) + /** 日期餐段汇总中的交易次数。 */ + @ExcelProperty({"日期餐段汇总", "交易次数"}) private Long summaryTimeCount; /** 日期餐段汇总中的商品总数量。 */ - @ExcelProperty({"日期餐段汇总", "预约商品数量"}) + @ExcelProperty({"日期餐段汇总", "商品数量"}) private Long summaryPayCount; /** 日期餐段汇总中的总价,单位为元。 */ @@ -50,24 +54,24 @@ public class HardCanteenProductApptExcelVo { @ExcelProperty({"商户部门统计", "部门"}) private String departmentName; - /** 商户部门统计中的预约日期。 */ - @ExcelProperty({"商户部门统计", "预约时间"}) + /** 商户部门统计中的业务发生日期。 */ + @ExcelProperty({"商户部门统计", "统计日期"}) private LocalDate merchantDate; /** 商户部门统计中的餐段名称。 */ - @ExcelProperty({"商户部门统计", "预约时间段"}) + @ExcelProperty({"商户部门统计", "餐段"}) private String merchantHourType; - /** 商户部门统计中的去重预约人数。 */ - @ExcelProperty({"商户部门统计", "预约人数"}) + /** 商户部门统计中的去重交易人数。 */ + @ExcelProperty({"商户部门统计", "交易人数"}) private Long merchantPayUserCount; - /** 商户部门统计中的预约次数。 */ - @ExcelProperty({"商户部门统计", "预约次数"}) + /** 商户部门统计中的交易次数。 */ + @ExcelProperty({"商户部门统计", "交易次数"}) private Long merchantTimeCount; - /** 商户部门统计中的商品总数量。 */ - @ExcelProperty({"商户部门统计", "预约商品数量"}) + /** 商户部门统计中的有效商品总数量。 */ + @ExcelProperty({"商户部门统计", "商品数量"}) private Long merchantPayCount; /** 商户部门统计中的总价,单位为元。 */ @@ -92,7 +96,7 @@ public class HardCanteenProductApptExcelVo { @ExcelProperty({"商品统计", "商品名称"}) private String statisticProductName; - /** 商品统计中的商品总数量,按退款扣减及套餐展开后的有效明细累计。 */ + /** 商品统计中的商品总数量,预约按退款扣减,套餐按组成商品展开后的有效明细累计。 */ @ExcelProperty({"商品统计", "商品总数量"}) private Long statisticProductCount; @@ -101,7 +105,7 @@ public class HardCanteenProductApptExcelVo { @NumberFormat("#,##0.00") private BigDecimal statisticProductTotalPrice; - /** 导出行层级,仅供合并处理器识别,不写入 Excel。 */ + /** 导出行层级,仅供合并处理器和测试识别,不写入 Excel。 */ @ExcelIgnore private String rowLevel; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java index b8c95bab..fa9811af 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java @@ -21,25 +21,25 @@ import java.util.HashMap; import java.util.Map; /** - * 食堂预约菜品统计导出合并处理器。 + * 食堂菜品销售统计三级导出合并处理器。 * - *

数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列; - * 第 17 至 19 列展示同一商户部门内按名称合并后的商品统计。 + *

数据行通过各层级专属列识别:日期餐段来源行填写第 0 列,商户部门行填写第 7 列,商品行填写第 15 列; + * 第 18 至 20 列展示同一商户部门内按名称合并后的商品统计。 * Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。

*/ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler { /** 日期餐段汇总列。 */ - private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5}; + private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5, 6}; /** 商户部门统计列。 */ - private static final int[] MERCHANT_COLUMNS = {6, 7, 8, 9, 10, 11, 12, 13}; + private static final int[] MERCHANT_COLUMNS = {7, 8, 9, 10, 11, 12, 13, 14}; /** Easy Excel 双层表头占用两行,数据从第三行开始。 */ private static final int DATA_START_ROW = 2; /** 导出列总数,用于逐行应用居中样式。 */ - private static final int COLUMN_COUNT = 20; + private static final int COLUMN_COUNT = 21; /** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */ private static final short HEAD_FONT_SIZE = 10; @@ -57,7 +57,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler * 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。 */ private static final int[] COLUMN_WIDTHS = { - 12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12 + 12, 11, 16, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12 }; /** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */ @@ -113,7 +113,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler applyCenteredStyle(row, writeSheetHolder.getParentWriteWorkbookHolder()); int rowIndex = row.getRowNum(); boolean summaryRow = hasValue(row, 0); - boolean merchantRow = hasValue(row, 6); + boolean merchantRow = hasValue(row, 7); boolean newDateGroup = false; if (summaryRow) { String summaryDateKey = getCellValueKey(row, 0); @@ -260,7 +260,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler CellStyle copiedStyle = holder.getWorkbook().createCellStyle(); copiedStyle.cloneStyleFrom(originalStyle); if (!(copiedStyle instanceof XSSFCellStyle xssfStyle)) { - throw new IllegalStateException("菜品预约导出仅支持 XLSX 彩色边框样式"); + throw new IllegalStateException("菜品销售统计导出仅支持 XLSX 彩色边框样式"); } XSSFColor color = new XSSFColor(DATE_GROUP_BORDER_COLORS[colorIndex]); if (leftBorder) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java index 6fcabb89..ef4e2303 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java @@ -69,14 +69,29 @@ public interface HardCarMapper extends PjcloudBaseMapper { Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope); - /** 按预约日期和餐段查询导出所需的日期餐段总计。 */ - List listApptExportSummary(@Param("dto") HardOrderDto dto); + /** + * 查询菜品销售三级导出的日期餐段汇总及来源拆分。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重 + */ + List listSalesExportSummary(@Param("dto") HardOrderDto dto); - /** 按预约日期、餐段、商户和部门查询导出商户部门统计。 */ - List listApptExportMerchant(@Param("dto") HardOrderDto dto); + /** + * 查询菜品销售三级导出的商户部门汇总及来源拆分。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 每个日期餐段下商户部门的跨来源合计及各来源统计 + */ + List listSalesExportMerchant(@Param("dto") HardOrderDto dto); - /** 按预约日期、餐段、商户、部门和商品查询导出商品明细。 */ - List listApptExportProduct(@Param("dto") HardOrderDto dto); + /** + * 查询菜品销售三级导出的商品汇总及来源拆分。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 每个商户部门块下商品的跨来源合计及各来源统计 + */ + List listSalesExportProduct(@Param("dto") HardOrderDto dto); Page proudctQuery(Page page, @Param("dto") HardCarDto dto); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java index 45518495..7502b74e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java @@ -15,8 +15,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; import java.util.List; import java.util.Map; @@ -69,21 +68,14 @@ public interface HardCanteenTotalService { List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope); /** - * 按页面相同筛选条件导出菜品销售统计。 + * 按页面筛选条件导出统一的菜品销售三级统计。 * - * @param dto 来源统计请求 - * @param dataScope 部门数据权限;为空时沿用控制器既有范围 - * @return 按日期、餐段和来源拆分的导出行 - */ - List exportProduct(HardOrderDto dto, DataScope dataScope); - - /** - * 按页面相同筛选条件导出部门销售统计。 + *

每个日期餐段同时生成当前来源范围的合计块以及各来源块,每个块继续按商户部门和商品展开; + * 菜品统计页与部门统计页共用该方法,避免两套导出口径再次分叉。

* - * @param dto 来源统计请求 - * @param user 当前登录用户,用于沿用商户范围查询 + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 * @param dataScope 部门数据权限;为空时沿用控制器既有范围 - * @return 按日期、食堂、来源、部门和餐段拆分的导出行 + * @return 日期餐段、商户部门、商品三级排列的导出行 */ - List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope); + List exportSales(HardOrderDto dto, DataScope dataScope); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java index 9fc2cc23..b8656b81 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java @@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.service.impl.order; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysDictItem; @@ -10,7 +11,10 @@ import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteDictService; import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService; +import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService; import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity; +import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.CollectionUtils; @@ -24,14 +28,14 @@ import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByCanteenDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByClassifyDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductLikeTopDto; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; @@ -50,11 +54,14 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -73,6 +80,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { static private final String TAG = "食堂统计"; + /** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */ + private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; + private final HardOrderMapper hardOrderMapper; private final HardCarMapper hardCarMapper; @@ -81,6 +91,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { private final RemoteDataScopeService dataScopeService; private final RemoteCanteenCategoryService canteenCategoryService; + /** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */ + private final RemoteDailyMenuService remoteDailyMenuService; + /** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */ private final RemoteDictService remoteDictService; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index ed7348bd..1237b604 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -640,138 +640,164 @@ order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') desc, o.hour_type - - + with sale_stats as ( + select * from + ) + select export_rows.statisticsDate, + export_rows.hourType, + export_rows.saleSource, + export_rows.payUserCount, + export_rows.timeCount, + export_rows.payCount, + export_rows.totalPrice + from ( + select sale_stats.stat_date as statisticsDate, + sale_stats.hour_type as hourType, + null as saleSource, + count(distinct sale_stats.user_id) as payUserCount, + count(distinct sale_stats.order_id) as timeCount, + sum(sale_stats.product_count) as payCount, + sum(sale_stats.total_price) as totalPrice + from sale_stats + + + + group by sale_stats.stat_date, sale_stats.hour_type + union all + select sale_stats.stat_date as statisticsDate, + sale_stats.hour_type as hourType, + sale_stats.sale_source as saleSource, + count(distinct sale_stats.user_id) as payUserCount, + count(distinct sale_stats.order_id) as timeCount, + sum(sale_stats.product_count) as payCount, + sum(sale_stats.total_price) as totalPrice + from sale_stats + + + + group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source + ) export_rows + order by export_rows.statisticsDate, + export_rows.hourType, + case when export_rows.saleSource is null then 0 + when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end - - + with sale_stats as ( + select * from + ) + select export_rows.statisticsDate, + export_rows.hourType, + export_rows.saleSource, + export_rows.merchantId, + export_rows.userDeptId, + export_rows.payUserCount, + export_rows.timeCount, + export_rows.payCount, + export_rows.totalPrice + from ( + select sale_stats.stat_date as statisticsDate, + sale_stats.hour_type as hourType, + null as saleSource, + sale_stats.mch_id as merchantId, + sale_stats.user_dept_id as userDeptId, + count(distinct sale_stats.user_id) as payUserCount, + count(distinct sale_stats.order_id) as timeCount, + sum(sale_stats.product_count) as payCount, + sum(sale_stats.total_price) as totalPrice + from sale_stats + + + + group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id + union all + select sale_stats.stat_date as statisticsDate, + sale_stats.hour_type as hourType, + sale_stats.sale_source as saleSource, + sale_stats.mch_id as merchantId, + sale_stats.user_dept_id as userDeptId, + count(distinct sale_stats.user_id) as payUserCount, + count(distinct sale_stats.order_id) as timeCount, + sum(sale_stats.product_count) as payCount, + sum(sale_stats.total_price) as totalPrice + from sale_stats + + + + group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source, + sale_stats.mch_id, sale_stats.user_dept_id + ) export_rows + order by export_rows.statisticsDate, + export_rows.hourType, + case when export_rows.saleSource is null then 0 + when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end, + export_rows.merchantId, + export_rows.userDeptId - - + with sale_stats as ( + select * from + ) + select export_rows.statisticsDate, + export_rows.hourType, + export_rows.saleSource, + export_rows.merchantId, + export_rows.userDeptId, + export_rows.productId, + export_rows.productName, + export_rows.payCount, + export_rows.totalPrice + from ( + select sale_stats.stat_date as statisticsDate, + sale_stats.hour_type as hourType, + null as saleSource, + sale_stats.mch_id as merchantId, + sale_stats.user_dept_id as userDeptId, + sale_stats.product_id as productId, + sale_stats.product_name as productName, + sum(sale_stats.product_count) as payCount, + sum(sale_stats.total_price) as totalPrice + from sale_stats + + + + group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id, + sale_stats.product_id, sale_stats.product_name + union all + select sale_stats.stat_date as statisticsDate, + sale_stats.hour_type as hourType, + sale_stats.sale_source as saleSource, + sale_stats.mch_id as merchantId, + sale_stats.user_dept_id as userDeptId, + sale_stats.product_id as productId, + sale_stats.product_name as productName, + sum(sale_stats.product_count) as payCount, + sum(sale_stats.total_price) as totalPrice + from sale_stats + + + + group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source, + sale_stats.mch_id, sale_stats.user_dept_id, sale_stats.product_id, sale_stats.product_name + ) export_rows + order by export_rows.statisticsDate, + export_rows.hourType, + case when export_rows.saleSource is null then 0 + when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end, + export_rows.merchantId, + export_rows.userDeptId, + export_rows.productName -