diff --git a/.gitignore b/.gitignore
index 7e9076a93..a217649e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,6 +62,9 @@ gen_code
### node ###
node_modules
+### 充值导入测试脚本(含 Excel 原始数据与本地配置) ###
+scripts/recharge-import/
+
### AI 辅助工具 ###
.video_agent
.mimosa
diff --git a/AGENTS.md b/AGENTS.md
index aca325182..893e0b0d1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,6 +93,7 @@
## 开发规则
+- **新增源码文件不写版权/许可注释头**(`Copyright (c) pjcloud` 开头的大段 `/* */` 块)。既有文件里的此类注释头保持原样,不主动清理、不扩散到新文件。
- 修改前先确认所属业务模块及其 `api` / `biz` 边界,沿用现有 Controller、Service、Mapper、DTO、VO、Feign Client、异常处理、鉴权和统一响应结构。
- 跨服务调用优先遵循项目现有 OpenFeign 封装、服务名、请求模型、降级与异常处理方式,不得绕过已有边界直接耦合其他服务内部实现。
- Redis 使用必须复用已有缓存工具、序列化方式、键命名规则和过期策略。新增或修改缓存逻辑时,必须考虑缓存穿透、数据一致性、失效时机和空值处理。
diff --git a/db/20260915_add_hard_order_parent_car_index.sql b/db/20260915_add_hard_order_parent_car_index.sql
new file mode 100644
index 000000000..b9bb5850c
--- /dev/null
+++ b/db/20260915_add_hard_order_parent_car_index.sql
@@ -0,0 +1,28 @@
+-- 购物车商品记录页(/hard/hard-car-record)查询索引。
+-- 幂等迁移脚本:可反复执行。索引先查 information_schema,已存在则跳过(执行 SELECT 1 占位),
+-- 不存在才执行 DDL;已应用过的库重跑不会报 Duplicate key。
+-- 背景:记录页与导出按 hard_car a JOIN hard_parent_car p(a.parent_car_id = p.id)
+-- JOIN hard_order o(o.parent_car_id = p.id)关联,hard_order.parent_car_id 此前无索引,
+-- 量级增长后三表 HASH JOIN 全表扫描会成为列表与导出的共同瓶颈。
+
+-- 【可选诊断】两表 parent_car_id 现有索引。
+SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS index_columns
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = 'pjcloudx_hardware'
+ AND TABLE_NAME IN ('hard_car', 'hard_order', 'hard_parent_car')
+ AND COLUMN_NAME = 'parent_car_id'
+GROUP BY TABLE_NAME, INDEX_NAME;
+
+-- hard_order 关联索引:缺失才创建(含租户与删除标记,与既有组合索引风格一致)。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD KEY `idx_hard_order_parent_tenant_del` (`parent_car_id`, `tenant_id`, `del_flag`)',
+ 'SELECT 1')
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND INDEX_NAME = 'idx_hard_order_parent_tenant_del');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- hard_parent_car 主键已覆盖 p.id 关联,无需新增;hard_car 侧已有
+-- idx_hard_car_parent_tenant_del(parent_car_id, tenant_id, del_flag),无需新增。
+-- 以上判断依据上方诊断查询输出,重跑时可复核。
diff --git a/db/20260916_add_hard_appt_statistics_index.sql b/db/20260916_add_hard_appt_statistics_index.sql
new file mode 100644
index 000000000..0e16c37f9
--- /dev/null
+++ b/db/20260916_add_hard_appt_statistics_index.sql
@@ -0,0 +1,55 @@
+-- 食堂预约人员统计(/hard/hard-canteen-appt-total-log/app/user-reserv-daily-statistics 等)查询索引。
+-- 幂等迁移脚本:可反复执行。索引先查 information_schema,已存在则跳过(执行 SELECT 1 占位),
+-- 不存在才执行 DDL;已应用过的库重跑不会报 Duplicate key。
+-- 背景:人员预约统计按商户范围 + 预约/核销日期范围查询 hard_order(基线 DDL 仅有主键索引,
+-- 只能全表扫),退款净商品聚合按 parent_car_id 过滤 hard_after_sale_car 同样无索引。
+-- 配套代码改动:HardCarMapper.listEffectiveReservationCars 已把父购物车 IN 条件下推进内层聚合,
+-- hard_after_sale_car 需要本索引才能走索引范围扫描;hard_order 的两个组合索引分别覆盖
+-- 默认(appt_use_date 范围)与已核销(verification_time 范围)两种统计口径。
+-- 执行顺序:本脚本须先于 pjcloud-hardware-biz 新版本部署手工执行。
+-- 取舍说明:hard_order 由此新增 2 个二级索引(连同 20260915 的 parent_car 索引共 3 个二级索引),
+-- 离线消费机批量补传等写入链路会有少量额外索引维护成本;若确认已核销口径流量占比很低,
+-- 可先只执行前两个索引、暂缓 idx_hard_order_tenant_mch_verify。
+
+-- 【可选诊断】相关表现有索引一览,执行前后可对比复核。
+SELECT TABLE_NAME, INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS index_columns
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = 'pjcloudx_hardware'
+ AND TABLE_NAME IN ('hard_order', 'hard_car', 'hard_after_sale_car')
+GROUP BY TABLE_NAME, INDEX_NAME;
+
+-- hard_order 默认统计口径:tenant 等值 + 商户等值集合 + 预约使用日期范围。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD KEY `idx_hard_order_tenant_mch_appt` (`tenant_id`, `merchant_id`, `appt_use_date`)',
+ 'SELECT 1')
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
+ AND INDEX_NAME = 'idx_hard_order_tenant_mch_appt');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- hard_order 已核销统计口径:核销时间范围替代预约使用日期范围。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD KEY `idx_hard_order_tenant_mch_verify` (`tenant_id`, `merchant_id`, `verification_time`)',
+ 'SELECT 1')
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order'
+ AND INDEX_NAME = 'idx_hard_order_tenant_mch_verify');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- hard_after_sale_car 退款净额聚合:父购物车下推过滤后走索引范围扫描,避免全表聚合。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_after_sale_car` ADD KEY `idx_hard_after_sale_car_parent_del` (`parent_car_id`, `del_flag`)',
+ 'SELECT 1')
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_after_sale_car'
+ AND INDEX_NAME = 'idx_hard_after_sale_car_parent_del');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- hard_car 侧已有 idx_hard_car_parent_tenant_del(parent_car_id, tenant_id, del_flag),
+-- 下推后的购物车聚合直接复用,无需新增;以上判断依据上方诊断查询输出,重跑时可复核。
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 e10bb2ede..3549d165c 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
@@ -164,14 +164,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl 0 ? baseMapper.updateStock(item.getId(), delta)
+ : baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock);
+ if (updated == 0) {
+ throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
+ : ErrorCodes.CANTEEN_STOCK_CHANGED, "");
+ }
}
}
String targetState = applyOperation ? "APPLIED"
@@ -277,7 +287,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl 0 ? null : item.getBeforeStock();
updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
- item.getHourType(), item.getId(), delta, item.getBeforeStock())
+ item.getHourType(), item.getId(), delta, expected)
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
- item.getHourType(), item.getId(), delta, item.getBeforeStock());
+ item.getHourType(), item.getId(), delta, expected);
if (updated == 0) {
- throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_CHANGED, "");
+ throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
+ : ErrorCodes.CANTEEN_STOCK_CHANGED, "");
}
}
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml
index 6b81757e7..7423d4e5c 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml
@@ -324,17 +324,22 @@
delta 与旧接口一致:下单扣减传负数、售后回传正数。
实际改写销量:sold = sold - delta(扣减时 sold 增加)。
expected 为操作前「剩余」快照(配额 - 销量)。
+ 正增量恢复不带任何边界条件:SET 已把销量钳制到 0 为止(菜谱重导或按订单重算销量后
+ sold 可能小于恢复量,退款/补偿不能被统计口径阻断),配额为 0 等脏数据同样不得阻断;
+ 负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。
-->
UPDATE canteen_daily_menu
- SET online_sold = IFNULL(online_sold, 0) - #{delta}
+ SET online_sold = GREATEST(IFNULL(online_sold, 0) - #{delta}, 0)
WHERE del_flag = '0'
AND merchant_id = #{merchantId}
AND start_time = #{mealDate}
AND hour_type = #{hourType}
AND spu_id = #{spuId}
+
AND IFNULL(online_sold, 0) - #{delta} >= 0
AND IFNULL(online_sold, 0) - #{delta} <= IFNULL(online_stock, 600)
+
AND (IFNULL(online_stock, 600) - IFNULL(online_sold, 0)) = #{expected}
@@ -342,14 +347,16 @@
UPDATE canteen_daily_menu
- SET offline_sold = IFNULL(offline_sold, 0) - #{delta}
+ SET offline_sold = GREATEST(IFNULL(offline_sold, 0) - #{delta}, 0)
WHERE del_flag = '0'
AND merchant_id = #{merchantId}
AND start_time = #{mealDate}
AND hour_type = #{hourType}
AND spu_id = #{spuId}
+
AND IFNULL(offline_sold, 0) - #{delta} >= 0
AND IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600)
+
AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java
index efc9b963d..d4045f9b4 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java
@@ -1,23 +1,37 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
+import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
+import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.core.util.SpringContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.MessageSource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
+import java.time.LocalDate;
import java.util.List;
+import java.util.Locale;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -32,6 +46,9 @@ class CanteenSpuServiceImplTest {
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
private CanteenSpuMapper spuMapper;
+ /** 每日菜谱 Mapper,用于验证分池正增量恢复的剩余匹配条件。 */
+ private CanteenDailyMenuMapper dailyMenuMapper;
+
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
private ValueOperations valueOperations;
@@ -41,11 +58,12 @@ class CanteenSpuServiceImplTest {
@BeforeEach
void setUp() {
spuMapper = mock(CanteenSpuMapper.class);
+ dailyMenuMapper = mock(CanteenDailyMenuMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
- CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, null, null, null, null, null,
- mock(CanteenMerchantScopeHelper.class), mock(CanteenDailyMenuStockSupport.class));
+ CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, dailyMenuMapper, null, null, null,
+ null, mock(CanteenMerchantScopeHelper.class), mock(CanteenDailyMenuStockSupport.class));
ReflectionTestUtils.setField(target, "baseMapper", spuMapper);
service = target;
TransactionSynchronizationManager.initSynchronization();
@@ -55,10 +73,13 @@ class CanteenSpuServiceImplTest {
@AfterEach
void tearDown() {
TransactionSynchronizationManager.clearSynchronization();
+ // 撤销最小 MessageSource 注入,避免静态 SpringContextHolder 影响后续测试类。
+ ReflectionTestUtils.setField(SpringContextHolder.class, "beanFactory", null);
}
/**
* 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
+ * 正增量恢复走无条件原子累加,不再与快照做 CAS 对比。
*/
@Test
void compensatedRefundInventoryShouldAllowRetry() {
@@ -66,15 +87,96 @@ class CanteenSpuServiceImplTest {
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
.thenReturn("COMPENSATED");
when(spuMapper.selectById(1L)).thenReturn(spu(10));
- when(spuMapper.updateStockIfMatch(1L, 2, 10)).thenReturn(1);
+ when(spuMapper.updateStock(1L, 2)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
- verify(spuMapper).updateStockIfMatch(1L, 2, 10);
+ verify(spuMapper).updateStock(1L, 2);
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
Duration.ofDays(7));
}
+ /**
+ * 正增量恢复是可交换操作:快照与当前库存不一致(并发下单/退款已改变库存)时仍应直接累加成功,
+ * 不得被误判为“库存已变化”阻断退款。
+ */
+ @Test
+ void restoreApplyShouldIgnoreConcurrentStockChange() {
+ CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
+ when(spuMapper.selectById(1L)).thenReturn(spu(8));
+ when(spuMapper.updateStock(1L, 2)).thenReturn(1);
+
+ assertTrue(service.updateSkuStockIdempotent(dto).isOk());
+
+ verify(spuMapper).updateStock(1L, 2);
+ verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
+ Duration.ofDays(7));
+ }
+
+ /**
+ * 负增量扣减仍要求当前库存等于快照,防止不同业务号基于同一剩余量重复判断导致超卖。
+ */
+ @Test
+ void deductApplyShouldStillRejectChangedStock() {
+ registerMinimalMessageSource();
+ CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2, 10);
+ when(spuMapper.selectById(1L)).thenReturn(spu(8));
+
+ R result = service.updateSkuStockIdempotent(dto);
+
+ assertFalse(result.isOk());
+ verify(spuMapper, never()).updateStock(anyLong(), anyInt());
+ verify(spuMapper, never()).updateStockIfMatch(anyLong(), anyInt(), anyInt());
+ }
+
+ /**
+ * 每日菜谱分池正增量恢复:当前剩余与快照不一致(菜谱重导/销量重算后)也不得阻断退款,
+ * 且传给 SQL 的剩余匹配条件为 null(销量回退由 SQL 钳制到 0 为止)。
+ */
+ @Test
+ void dailyRestoreApplyShouldSkipRemainMatch() {
+ LocalDate mealDate = LocalDate.of(2026, 9, 14);
+ CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
+ item.setId(100L);
+ item.setIncrCount(2);
+ item.setBeforeStock(5);
+ item.setMealDate(mealDate);
+ item.setHourType(1);
+ CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
+ dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
+ dto.setOperation("APPLY");
+ dto.setStockChannel("ONLINE");
+ dto.setMerchantId(1L);
+ dto.setItems(List.of(item));
+
+ // 配额 10、已售 0:剩余 10 与快照 5 不一致,正增量恢复仍应放行
+ when(dailyMenuMapper.selectOne(any())).thenReturn(dailyMenu(10, 0));
+ when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null)).thenReturn(1);
+
+ assertTrue(service.updateSkuStockIdempotent(dto).isOk());
+
+ verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null);
+ verify(valueOperations).set("canteen:spu:order-stock:daily:ONLINE:HARD_DAILY_MENU_AFTER_SALE:9",
+ "APPLIED", Duration.ofDays(7));
+ }
+
+ /**
+ * 构造每日菜谱菜谱行实体。
+ *
+ * @param onlineStock 线上配额
+ * @param onlineSold 线上已售
+ * @return 仅包含本用例所需字段的实体
+ */
+ private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
+ CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
+ menu.setMerchantId(1L);
+ menu.setSpuId(100L);
+ menu.setHourType(1);
+ menu.setOnlineStock(onlineStock);
+ menu.setOnlineSold(onlineSold);
+ return menu;
+ }
+
/**
* 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。
*/
@@ -93,6 +195,19 @@ class CanteenSpuServiceImplTest {
Duration.ofDays(7));
}
+ /**
+ * 为纯 Mockito 环境注入最小 MessageSource,使 R.failed 分支的 MsgUtils 文案解析可用;
+ * 文案内容不影响断言,直接回显错误码。
+ */
+ private void registerMinimalMessageSource() {
+ MessageSource messageSource = mock(MessageSource.class);
+ when(messageSource.getMessage(anyString(), any(), any(Locale.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0, String.class));
+ ConfigurableListableBeanFactory factory = mock(ConfigurableListableBeanFactory.class);
+ when(factory.getBean("pjcloudMessageSource")).thenReturn(messageSource);
+ new SpringContextHolder().postProcessBeanFactory(factory);
+ }
+
/**
* 构造单个基础 SPU 的幂等库存请求。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarRecordDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarRecordDto.java
new file mode 100644
index 000000000..aa0d02c50
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarRecordDto.java
@@ -0,0 +1,147 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 购物车商品记录管理端查询条件,以购物车商品明细为查询维度。
+ *
+ * 记录页一行对应一条购物车商品快照,商品条件作用于 hard_car,其余订单、用户、部门和时间条件
+ * 均通过关联的订单表过滤;身份条件交由 UPMS 用户接口转换为用户主键范围。
+ */
+@Data
+public class HardCarRecordDto implements BaseUserIdentityQueryInterface {
+
+ /**
+ * 商品主键筛选;来源于购物车快照 product_id,为空表示不限定商品。
+ */
+ private Long productId;
+
+ /**
+ * 商品名称模糊筛选;作用于购物车快照的商品名称。
+ */
+ private String productName;
+
+ /**
+ * 购物车商品行主键集合;列表勾选导出时优先限定这些明细行。
+ */
+ private List carIds;
+
+ /**
+ * 订单业务号精确筛选。
+ */
+ private String orderNo;
+
+ /**
+ * 订单类型协议编码;页面订单类型 Tab 使用单值,为空表示全部类型。
+ */
+ private String orderType;
+
+ /**
+ * 订单状态协议编码集合;为空表示不限制订单状态。
+ */
+ private List orderStatusCodes;
+
+ /**
+ * 支付方式协议编码集合;为空表示不限制支付方式。
+ */
+ private List orderPayTypeCodes;
+
+ /**
+ * 用餐时间段协议编码集合;为空表示不限制餐段。
+ */
+ private List hourTypeCodes;
+
+ /**
+ * 页面选择的商户类型编码集合;与服务端注入的设备类型权限条件分别保留,最终按交集过滤。
+ */
+ private List merchantTypeCodes;
+
+ /**
+ * 服务端按当前用户设备类型权限注入的商户类型编码集合;与页面多选条件按交集过滤。
+ */
+ private List mchTypeCodes;
+
+ /**
+ * 设备类型协议编码集合;为空表示不限制设备类型。
+ */
+ private List hardTypeCodes;
+
+ /**
+ * 商户主键筛选;与商户多选条件叠加。
+ */
+ private Long merchantId;
+
+ /**
+ * 商户主键集合;单商户时服务端会展开为自身加子商户范围。
+ */
+ private List mchIds;
+
+ /**
+ * 单个购买用户主键筛选。
+ */
+ private Long userId;
+
+ /**
+ * 购买用户主键集合;身份筛选转换结果也写入该字段。
+ */
+ private List userIds;
+
+ /**
+ * 用户部门主键筛选(下拉选择部门)。
+ */
+ private Long userDeptId;
+
+ /**
+ * 用户部门主键集合(左侧部门树勾选)。
+ */
+ private List userDeptIds;
+
+ /**
+ * 部门树勾选是否包含子部门;控制 userDeptIds 使用 IN 或 NOT IN。
+ */
+ private Boolean isInclude;
+
+ /**
+ * 订单下单时间范围。
+ */
+ private LocalDateTime[] createTimeList;
+
+ /**
+ * 订单支付时间范围。
+ */
+ private LocalDateTime[] payTimeList;
+
+ /**
+ * 订单核销时间范围。
+ */
+ private LocalDateTime[] verificationTimeList;
+
+ /**
+ * 预计使用日期范围。
+ */
+ private LocalDateTime[] apptUseDateTimeList;
+
+ /**
+ * 用户身份协议编码筛选集合;查询前转换为用户主键范围。
+ */
+ private List identityTypes;
+
+ /**
+ * 学生身份扩展筛选条件。
+ */
+ private com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto studentInfo;
+
+ /**
+ * 老师身份扩展筛选条件。
+ */
+ private com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto teacherInfo;
+
+ /**
+ * 人脸录入状态筛选条件。
+ */
+ private String hasFace;
+}
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 da5bab525..45ed8c735 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
@@ -142,6 +142,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
/** App 当前访问者的有效被监护人。 */
private List appWardUserIds;
+ /** App 当前访问者的关系图谱用户(不含本人),其全部类型订单对访问者可见。 */
+ private List appRelatedUserIds;
+
/**
* `apptId`或者`appt_rule_id`不为null(筛选预约订单)
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarRecordVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarRecordVo.java
new file mode 100644
index 000000000..b80501e97
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarRecordVo.java
@@ -0,0 +1,290 @@
+package com.bsd.pjcloud.hard.api.vo.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.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
+import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 购物车商品记录管理端展示对象,一行对应一条购物车商品快照及其所属订单摘要。
+ *
+ * 商品列来源于 hard_car 快照,订单列通过父购物车关联 hard_order 得到;用户、身份和商户展示资料
+ * 由服务层批量装配。实现用户部门与身份展示契约后可直接复用通用身份装配器和前端身份组件。
+ */
+@Data
+public class HardCarRecordVo implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseMchInterface {
+
+ /**
+ * 购物车商品行主键,即记录页的行主键。
+ */
+ private Long id;
+
+ /**
+ * 父购物车主键,来源于 hard_car.parent_car_id。
+ */
+ private Long parentCarId;
+
+ /* ************************************* 商品快照 ************************************* */
+
+ /**
+ * 商品主键,来源于购物车快照。
+ */
+ private Long productId;
+
+ /**
+ * 商品来源详情 ID;每日菜谱等来源指向对应业务记录。
+ */
+ private Long sourceProductId;
+
+ /**
+ * 商品名称快照。
+ */
+ private String productName;
+
+ /**
+ * 商品图片快照。
+ */
+ private String productPic;
+
+ /**
+ * 商品单价快照,单位为分。
+ */
+ private Integer productPrice;
+
+ /**
+ * 商品性质协议编码。
+ */
+ private String productNature;
+
+ /**
+ * 购买数量。
+ */
+ private Integer productCount;
+
+ /**
+ * 商品重量。
+ */
+ private java.math.BigDecimal productWeight;
+
+ /**
+ * 商品小计金额,单位为分。
+ */
+ private Long totalPrice;
+
+ /**
+ * 购物车行创建时间。
+ */
+ private LocalDateTime carCreateTime;
+
+ /* ************************************* 订单摘要 ************************************* */
+
+ /**
+ * 所属订单主键。
+ */
+ private Long orderId;
+
+ /**
+ * 所属订单业务号。
+ */
+ private String orderNo;
+
+ /**
+ * 订单类型协议编码。
+ */
+ private String orderType;
+
+ /**
+ * 订单状态协议编码。
+ */
+ private String orderStatus;
+
+ /**
+ * 支付方式协议编码。
+ */
+ private String payType;
+
+ /**
+ * 用餐时间段协议编码。
+ */
+ private String hourType;
+
+ /**
+ * 预约目标用餐日期。
+ */
+ private LocalDate apptUseDate;
+
+ /**
+ * 核销时间。
+ */
+ private LocalDateTime verificationTime;
+
+ /**
+ * 核销方式协议编码;'2' 表示自动核销。
+ */
+ private String verificationType;
+
+ /**
+ * 订单下单时间。
+ */
+ private LocalDateTime orderCreateTime;
+
+ /**
+ * 订单支付时间。
+ */
+ private LocalDateTime payTime;
+
+ /**
+ * 订单商品总价,单位为分。
+ */
+ private Integer orderTotalPrice;
+
+ /**
+ * 额外支付金额,单位为分。
+ */
+ private Integer extraPrice;
+
+ /**
+ * 配送费金额,单位为分。
+ */
+ private Integer deliveryPrice;
+
+ /**
+ * 订单实际支付金额,单位为分。
+ */
+ private Integer payPrice;
+
+ /**
+ * 订单已退款金额,单位为分。
+ */
+ private Integer refundPrice;
+
+ /**
+ * 退款状态协议编码。
+ */
+ private String refundStatus;
+
+ /**
+ * 最近退款完成时间。
+ */
+ private LocalDateTime refundTime;
+
+ /**
+ * 订单关联钱包主键。
+ */
+ private Long walletId;
+
+ /**
+ * 付款人用户 ID,来源于订单关联的父购物车。
+ */
+ private Long payerUserId;
+
+ /**
+ * 业务系统类型协议编码。
+ */
+ private String sysType;
+
+ /**
+ * 是否线下补录订单协议编码;空值按非线下处理。
+ */
+ private String isOfflineOrder;
+
+ /* ************************************* 商户与设备 ************************************* */
+
+ /**
+ * 商户主键。
+ */
+ private Long merchantId;
+
+ /**
+ * 商户类型协议编码。
+ */
+ private String merchantType;
+
+ /**
+ * 商户所属部门主键;数据权限拦截器依赖该列包装部门过滤。
+ */
+ private Long merchantDeptId;
+
+ /**
+ * 设备主键。
+ */
+ private Long hardId;
+
+ /**
+ * 设备名称,来源于设备表。
+ */
+ private String hardName;
+
+ /**
+ * 设备类型协议编码,来源于设备表。
+ */
+ private String hardType;
+
+ /**
+ * 商户名称,服务层批量装配。
+ */
+ private String merchantName;
+
+ /**
+ * 商户 Logo 图片地址,服务层批量装配。
+ */
+ private String logoPic;
+
+ /**
+ * 钱包类型唯一编码,服务层批量装配。
+ */
+ private String walletTypeUniqueCode;
+
+ /* ************************************* 用户与身份展示 ************************************* */
+
+ /**
+ * 购买用户主键。
+ */
+ private Long userId;
+
+ /**
+ * 用户姓名,UPMS 装配结果。
+ */
+ private String username;
+
+ /**
+ * 用户手机号,UPMS 装配结果。
+ */
+ private String userMobile;
+
+ /**
+ * 用户头像地址,UPMS 装配结果。
+ */
+ private String userAvatar;
+
+ /**
+ * 用户部门主键,UPMS 装配结果。
+ */
+ private Long userDeptId;
+
+ /**
+ * 用户部门名称,UPMS 装配结果。
+ */
+ private String userDeptName;
+
+ /**
+ * 用户身份协议编码集合,UPMS 装配结果。
+ */
+ private List identityTypes;
+
+ /**
+ * 学生身份扩展资料,UPMS 装配结果。
+ */
+ private SysUserStudentDto studentInfo;
+
+ /**
+ * 老师身份扩展资料,UPMS 装配结果。
+ */
+ private SysUserTeacherDto teacherInfo;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCarRecordExportExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCarRecordExportExcelVo.java
new file mode 100644
index 000000000..a7f87e119
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCarRecordExportExcelVo.java
@@ -0,0 +1,148 @@
+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 com.alibaba.excel.annotation.write.style.ContentFontStyle;
+import com.alibaba.excel.annotation.write.style.HeadFontStyle;
+import com.alibaba.excel.annotation.write.style.HeadRowHeight;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 购物车商品记录导出行,按商品业务域组织二级表头。
+ *
+ * 一行对应一条购物车商品快照及其所属订单摘要,仅保留业务核对所需的商品、用户、身份、
+ * 订单和时间信息,隐藏内部主键及审计字段;金额字段统一使用元。该对象仅用于导出展示,
+ * 不参与数据库持久化和接口入参。
+ */
+@Data
+@ColumnWidth(15)
+@HeadRowHeight(15)
+@HeadFontStyle(fontHeightInPoints = 10)
+@ContentFontStyle(fontHeightInPoints = 10)
+public class HardCarRecordExportExcelVo {
+
+ /** 当前商品记录在本次导出结果中的一基序号;合计行为空。 */
+ @ExcelProperty({"商品", "序号"})
+ private String lineNumber;
+
+ /** 购物车快照商品名称。 */
+ @ExcelProperty({"商品", "商品名称"})
+ @ColumnWidth(22)
+ private String productName;
+
+ /** 商品单价,单位为元并保留两位小数。 */
+ @ExcelProperty({"商品", "单价(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal productPrice;
+
+ /** 购买数量。 */
+ @ExcelProperty({"商品", "数量"})
+ private Integer productCount;
+
+ /** 商品小计金额,单位为元并保留两位小数。 */
+ @ExcelProperty({"商品", "小计(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal totalPrice;
+
+ /** 用户姓名,优先取 UPMS 姓名,其次取昵称、账号和历史快照。 */
+ @ExcelProperty({"用户", "姓名"})
+ private String userName;
+
+ /** 用户手机号;UPMS 资料缺失时回退历史快照。 */
+ @ExcelProperty({"用户", "手机号"})
+ private String phone;
+
+ /** 用户当前所属部门名称;UPMS 资料缺失时回退历史快照。 */
+ @ExcelProperty({"用户", "部门"})
+ private String deptName;
+
+ /** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */
+ @ExcelProperty({"身份", "身份类型"})
+ private String identityTypeNames;
+
+ /** 学生身份学号;没有学生身份时为空。 */
+ @ExcelProperty({"身份", "学号"})
+ private String studentNo;
+
+ /** 学生身份学校名称;没有学生身份时为空。 */
+ @ExcelProperty({"身份", "学校"})
+ private String schoolName;
+
+ /** 学生身份班级名称;没有学生身份时为空。 */
+ @ExcelProperty({"身份", "班级"})
+ private String className;
+
+ /** 老师身份工号或学号;没有老师身份时为空。 */
+ @ExcelProperty({"身份", "老师学号"})
+ private String teacherNo;
+
+ /** 订单业务号。 */
+ @ExcelProperty({"订单", "订单号"})
+ @ColumnWidth(24)
+ private String orderNo;
+
+ /** 订单类型中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "订单类型"})
+ private String orderType;
+
+ /** 订单状态中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "订单状态"})
+ private String orderStatus;
+
+ /** 钱包服务返回的商户名称;历史商户不存在时回退快照名称。 */
+ @ExcelProperty({"订单", "商户"})
+ private String merchantName;
+
+ /** 订单关联设备名称;用于识别核销机器。 */
+ @ExcelProperty({"订单", "设备"})
+ private String hardName;
+
+ /** 实际关联设备的设备类型中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "设备类型"})
+ private String hardType;
+
+ /** 支付方式中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "支付方式"})
+ private String payType;
+
+ /** 用餐时间段中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "用餐时段"})
+ private String hourType;
+
+ /** 订单创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
+ @ExcelProperty({"时间", "下单时间"})
+ @ColumnWidth(22)
+ private String createTime;
+
+ /** 支付完成时间,格式为 yyyy-MM-dd HH:mm:ss;未支付时为空。 */
+ @ExcelProperty({"时间", "支付时间"})
+ @ColumnWidth(22)
+ private String payTime;
+
+ /** 预约目标用餐日期;非预约订单可能为下单日期或空值。 */
+ @ExcelProperty({"时间", "预约时间"})
+ @ColumnWidth(22)
+ private String apptUseDate;
+
+ /** 订单实际核销完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
+ @ExcelProperty({"时间", "核销时间"})
+ @ColumnWidth(22)
+ private String verificationTime;
+
+ /** 订单退款状态中文名称;未记录时为空,未知历史编码保留原始值。 */
+ @ExcelProperty({"退款", "退款状态"})
+ private String refundStatus;
+
+ /** 所属订单已退款金额,单位为元并保留两位小数。 */
+ @ExcelProperty({"退款", "退款金额(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal refundPrice;
+
+ /** 退款完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
+ @ExcelProperty({"退款", "退款完成时间"})
+ @ColumnWidth(22)
+ private String refundTime;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java
index 9f8b3a75e..f5ec1f8f5 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java
@@ -242,17 +242,20 @@ public class HardAfterSaleController {
}
/**
- * 将误同意的人工钱包退款退回审核中,并同步冲正钱包入账和退款库存恢复。
+ * 将误同意的人工钱包退款退回审核中,并同步冲正钱包入账;默认重新占用本次退款恢复的商品库存,
+ * deductStock=false 时跳过库存重新占用,后续再次同意该售后也不会重复恢复库存。
*
* @param id 已完成人工审核的售后单 ID
+ * @param deductStock 是否重新占用本次退款恢复的商品库存,默认 true
* @return 资金、库存和售后状态均恢复成功时返回 {@code true}
*/
@PutMapping("/return-to-review")
- @Operation(summary = "退回到审核前")
+ @Operation(summary = "退回到审核前", description = "是否扣减库存 deductStock 默认 true;false 时跳过库存重新占用,后续再次同意也不再恢复库存")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_change')")
@Idempotent(expireTime = 10, key = "'/hard-after-sale/return-to-review' + #id", info = "正在处理中,请勿重复操作")
- public R returnToReview(@RequestParam Long id) {
- return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id));
+ public R returnToReview(@RequestParam Long id,
+ @RequestParam(value = "deductStock", defaultValue = "true") Boolean deductStock) {
+ return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id, deductStock));
}
@Operation(summary = "分页查询", description = "分页查询")
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarRecordController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarRecordController.java
new file mode 100644
index 000000000..d3fe1b72f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarRecordController.java
@@ -0,0 +1,109 @@
+package com.bsd.pjcloud.hard.controller.order;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
+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.dto.order.HardCarRecordDto;
+import com.bsd.pjcloud.hard.service.HardCarRecordExportService;
+import com.bsd.pjcloud.hard.service.HardCarRecordService;
+import com.bsd.pjcloud.hard.service.HardManagerService;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+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.springdoc.core.annotations.ParameterObject;
+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;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 购物车商品记录管理
+ *
+ * 以购物车商品明细为查询维度的记录页接口,一行对应一条商品快照及其所属订单摘要;
+ * 只提供分页查询和异步导出,购物车表维护仍由 {@link HardCarController} 负责。
+ *
+ * @author dzc
+ * @date 2026-09-15
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-car-record")
+@Tag(description = "hard-car-record", name = "购物车商品记录管理")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class HardCarRecordController {
+
+ private final HardCarRecordService hardCarRecordService;
+
+ private final HardCarRecordExportService hardCarRecordExportService;
+
+ private final HardManagerService hardManagerService;
+
+ private final RemoteMchService mchService;
+
+ /**
+ * 分页复杂查询购物车商品记录。
+ *
+ * @param page 分页参数
+ * @param dto 商品、订单、用户、部门、时间及身份筛选条件
+ * @return 已装配用户、商户展示资料的商品记录分页
+ */
+ @Operation(summary = "分页复杂查询", description = "以购物车商品明细为维度分页查询记录")
+ @PostMapping("/page-join")
+ @PreAuthorize("@pms.hasPermission('hard_hardCarRecord_view')")
+ public R pageJoin(@ParameterObject Page page, @RequestBody HardCarRecordDto dto) {
+ DataScope dataScope = prepareCarRecordQuery(dto);
+ return R.ok(hardCarRecordService.scopePageList(page, dto, dataScope));
+ }
+
+ /**
+ * 提交购物车商品记录异步分批导出任务。
+ *
+ * @param dto 商品记录页面当前筛选条件;carIds 非空时仅导出勾选的商品明细行
+ * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
+ */
+ @Operation(summary = "购物车商品记录异步导出", description = "按商品记录筛选条件分批查询并流式生成 Excel")
+ @SysLog("购物车商品记录异步导出")
+ @PostMapping("/export-task")
+ @PreAuthorize("@pms.hasPermission('hard_hardCarRecord_export')")
+ public R submitExport(@RequestBody HardCarRecordDto dto) {
+ return R.ok(hardCarRecordExportService.submitExport(dto), "购物车记录导出任务已提交");
+ }
+
+ /**
+ * 为商品记录分页统一注入设备类型权限、单商户子级范围及商户部门数据权限。
+ *
+ * @param dto 商品记录筛选条件,方法会写入权限侧商户类型集合
+ * @return 商户部门数据权限配置
+ */
+ private DataScope prepareCarRecordQuery(HardCarRecordDto dto) {
+ DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
+ dataScope.setScopeDeptName("merchant_dept_id");
+ PjcloudUser user = SecurityUtils.getUser();
+ dto.setMchTypeCodes(hardManagerService.getCurrentUserHardTypeInfo(user).getHardTypeList());
+ // 查询商户和下面的子商户
+ List mchDtoIds = dto.getMchIds();
+ if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
+ Long mchId = mchDtoIds.get(0);
+ List mchList = mchService.getByParentId(mchId).getData();
+ if (ObjectUtil.isNotEmpty(mchList)) {
+ List mchIds = mchList.stream().map(MchManageVo::getId).toList();
+ mchDtoIds.addAll(mchIds);
+ dto.setMchIds(mchDtoIds);
+ }
+ }
+ return dataScope;
+ }
+}
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 8a593a958..d6eae4c35 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
@@ -687,6 +687,8 @@ public class HardOrderController {
private DataScope prepareAdminOrderQuery(HardOrderDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
+ // 订单 create_by 不会是后台账号,个人权限子句匹配不到数据且 scopePageIds 未查询该列,关闭避免拼出 temp_data_scope.create_by 报错
+ dataScope.setScopeUserName(null);
PjcloudUser user = SecurityUtils.getUser();
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
@@ -710,6 +712,8 @@ public class HardOrderController {
public R> noRecogPageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
+ // 与 prepareAdminOrderQuery 同口径:scopePageIds 未查询 create_by,必须关闭个人权限子句
+ dataScope.setScopeUserName(null);
PjcloudUser user = SecurityUtils.getUser();
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarRecordMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarRecordMapper.java
new file mode 100644
index 000000000..e694d6bd7
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarRecordMapper.java
@@ -0,0 +1,33 @@
+package com.bsd.pjcloud.hard.mapper.order;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 购物车商品记录查询 Mapper,以购物车商品明细为维度关联订单展示信息。
+ *
+ * @author dzc
+ * @date 2026-09-15
+ */
+@Mapper
+public interface HardCarRecordMapper extends PjcloudBaseMapper {
+
+ /**
+ * 按商品记录筛选条件和数据范围分页查询购物车商品明细及其所属订单摘要。
+ *
+ * 查询列保留 merchant_dept_id 供数据权限拦截器包装部门过滤;排序以订单创建时间倒序、
+ * 订单主键倒序加购物车行主键倒序保证跨页稳定,列表与异步导出共用同一口径。
+ *
+ * @param page 分页参数
+ * @param dto 商品、订单、用户、部门、时间及身份转换后的筛选条件
+ * @param dataScope 后台商户部门数据范围
+ * @return 当前页商品记录及符合相同筛选条件的总数
+ */
+ Page recordPage(Page page, @Param("dto") HardCarRecordDto dto, DataScope dataScope);
+}
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 f1746178c..67df45952 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
@@ -190,6 +190,19 @@ public interface HardOrderMapper extends PjcloudBaseMapper {
List scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 人员预约统计专用订单查询,只读取 hard_order 主表列。
+ *
+ * 统计聚合不使用设备、最新售后、付款人和发票等展示关联,这里省去 {@link #scopeList} 的
+ * 全部 join 与相关子查询,降低大日期范围统计的订单加载开销;返回映射结构与 {@link #scopeList}
+ * 一致,关联展示列置空。查询列保留 merchant_dept_id 供数据权限拦截器包装部门过滤。
+ *
+ * @param dto 预约订单筛选条件(商户范围、订单状态、预约或核销日期等)
+ * @param dataScope 保留的数据范围参数;当前统计调用传 {@code null}
+ * @return 当前租户内符合筛选条件的订单列表,统计筛选口径与订单列表一致
+ */
+ List scopeListReservationStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
+
/**
* 按订单列表完整筛选条件统计订单金额、商品份数及日期餐段明细。
*
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 5913bdd38..79c0b18c1 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
@@ -119,13 +119,14 @@ public interface HardAfterSaleService extends IService {
Boolean disagreeRefund(Long userId, HardDisagreeDto dto);
/**
- * 将人工审核完成的钱包退款冲正并退回申请中,同时恢复退款前的库存占用和订单聚合状态。
+ * 将人工审核完成的钱包退款冲正并退回申请中,默认同时恢复退款前的库存占用和订单聚合状态。
*
* @param userId 当前后台操作用户 ID
* @param id 售后单 ID
+ * @param deductStock 是否重新占用本次退款恢复的商品库存;false 时只冲正钱包并冻结本轮库存链路
* @return 资金、库存和售后状态均已恢复时返回 {@code true}
*/
- Boolean returnToReview(Long userId, Long id);
+ Boolean returnToReview(Long userId, Long id, boolean deductStock);
/**
* 校验订单是否可以退款
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordExportService.java
new file mode 100644
index 000000000..698e561f7
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordExportService.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
+
+/**
+ * 购物车商品记录异步导出服务。
+ *
+ * @author dzc
+ * @date 2026-09-15
+ */
+public interface HardCarRecordExportService {
+
+ /**
+ * 提交购物车商品记录异步分批导出任务。
+ *
+ * @param dto 商品记录页面当前筛选条件;carIds 非空时仅导出勾选的商品明细行
+ * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
+ */
+ AsyncTaskProgressVO submitExport(HardCarRecordDto dto);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordService.java
new file mode 100644
index 000000000..e4b85f382
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarRecordService.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
+
+/**
+ * 购物车商品记录查询服务,以购物车商品明细为维度装配订单和用户展示信息。
+ *
+ * @author dzc
+ * @date 2026-09-15
+ */
+public interface HardCarRecordService {
+
+ /**
+ * 按商品记录筛选条件和数据范围分页查询购物车商品明细。
+ *
+ * 身份筛选条件先转换为用户主键范围再进入 SQL;返回行包含所属订单摘要,并批量装配
+ * 用户资料、商户名称和钱包类型,列表页与导出任务共用同一查询口径。
+ *
+ * @param page 分页参数
+ * @param dto 商品、订单、用户、部门、时间及身份筛选条件
+ * @param dataScope 后台商户部门数据范围
+ * @return 已完成展示信息装配的商品记录分页结果
+ */
+ Page scopePageList(Page page, HardCarRecordDto dto, 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 6f7decb8d..f449fe26b 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
@@ -112,7 +112,7 @@ public interface HardOrderService extends IService {
void applyAppPermissions(List orders, Long viewerUserId);
/**
- * 配置只扩展食堂预约订单的 App 可见范围。
+ * 配置 App 订单可见范围:本人、食堂预约订单的付款人和有效被监护人,以及关系图谱用户的全部类型订单。
*/
void configureAppVisibility(HardOrderDto dto, Long viewerUserId);
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 62c43f4cc..fffd5cbe1 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
@@ -63,12 +63,14 @@ import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
+import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Supplier;
@@ -103,8 +105,17 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 0
- ? RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX + id + ":" + returnReviewCount : null;
- restoreRefundInventory(order, id, inventoryBizId);
+ // 上一轮退回审核若选择"未扣减库存",库存仍处于已恢复态:本轮只退款不再恢复,避免恢复两次。
+ boolean stockKeptByReview = isStockKeptByLastReturnToReview(id);
+ if (canteenSpuInventoryOrder && !stockKeptByReview) {
+ restoreRefundInventory(order, id, nextAgreeRestoreInventoryBizId(id));
}
getSelf().refund(updateRefundObj.getId());
- if (!canteenSpuInventoryOrder) {
+ if (!canteenSpuInventoryOrder && !stockKeptByReview) {
restoreOrderInventory(order, id);
}
recomputeOrderRefundState(order.getId());
@@ -935,25 +948,29 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().returnToReviewInTransaction(userId, id));
+ return withOrderRefundLock(current.getOrderId(),
+ () -> getSelf().returnToReviewInTransaction(userId, id, deductStock));
}
/**
* 在订单锁内冲正人工钱包退款,并恢复退款审核前的库存占用、售后状态和订单聚合状态。
* 食堂基础 SPU 具备跨服务幂等补偿,先重新占用库存再扣回钱包;普通库存沿用历史接口,
* 先完成可幂等恢复的钱包冲正,再执行库存扣减,失败后可按仍为完成的售后状态继续重试。
+ * deductStock=false 时跳过库存重新占用,库存停留在已恢复态,并在售后日志写入
+ * "未扣减库存"标记,后续再次同意该售后时凭标记跳过库存恢复,避免恢复两次。
*
* @param userId 当前后台操作用户 ID
* @param id 已完成人工审核的售后单 ID
+ * @param deductStock 是否重新占用本次退款恢复的商品库存
* @return 资金、库存和售后状态均恢复时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
- public Boolean returnToReviewInTransaction(Long userId, Long id) {
+ public Boolean returnToReviewInTransaction(Long userId, Long id, boolean deductStock) {
HardAfterSaleEntity afterSale = baseMapper.selectById(id);
if (afterSale == null) {
throw new I18nBizException(ErrorCodes.AFTER_SALE_NOT_FOUND);
@@ -977,15 +994,21 @@ public class HardAfterSaleServiceImpl extends ServiceImpl updateWrapper = new LambdaUpdateWrapper<>();
@@ -999,9 +1022,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl跳过时库存停留在已恢复态,后续再次同意只退款不恢复库存,保证整轮退回-重审
+ * 的库存净效果仍为一次恢复。标记持久化在售后日志 content 后缀,事务回滚随之撤销。
+ *
+ * @param afterSaleId 售后单 ID
+ * @return 最近一次退回审核未扣减库存时返回 {@code true}
+ */
+ private boolean isStockKeptByLastReturnToReview(Long afterSaleId) {
+ HardAfterSaleLogEntity lastReturn = hardAfterSaleLogMapper.selectOne(new LambdaQueryWrapper()
+ .eq(HardAfterSaleLogEntity::getAfterSaleId, afterSaleId)
+ .eq(HardAfterSaleLogEntity::getOperateType,
+ HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType())
+ .orderByDesc(HardAfterSaleLogEntity::getId)
+ .last("limit 1"));
+ return lastReturn != null && lastReturn.getContent() != null
+ && lastReturn.getContent().endsWith(RETURN_REVIEW_STOCK_KEEP_MARKER);
+ }
+
@Override
public Boolean disagreeRefund(Long userId, HardDisagreeDto dto) {
HardAfterSaleEntity current = baseMapper.selectById(dto.getId());
@@ -2016,6 +2060,24 @@ public class HardAfterSaleServiceImpl extends ServiceImpl食堂库存服务的幂等状态机会把失败回滚后的业务号标记为 ROLLED_BACK 终态,
+ * 同业务号后续恢复请求会被静默跳过(返回成功但不再回补库存)。后台强化退款每次
+ * 新建售后单天然换号;人工审核复用同一售后单,必须按审核尝试轮次隔离业务号:
+ * 同一事务内轮次固定(远程重试不重复恢复库存),失败重试自增换号(不被终态吞掉)。
+ *
+ * @param afterSaleId 本次同意的售后单 ID
+ * @return 本轮库存恢复幂等业务号
+ */
+ private String nextAgreeRestoreInventoryBizId(Long afterSaleId) {
+ String cycleKey = AGREE_RESTORE_INVENTORY_CYCLE_KEY_PREFIX + afterSaleId;
+ Long cycle = stringRedisTemplate.opsForValue().increment(cycleKey);
+ stringRedisTemplate.expire(cycleKey, AGREE_RESTORE_INVENTORY_CYCLE_TTL);
+ return AGREE_RESTORE_INVENTORY_BIZ_PREFIX + afterSaleId + ":" + cycle;
+ }
+
/**
* 恢复每日菜谱或机器收银本次售后占用的基础 SPU 库存。
*
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 886a6791f..e01a67118 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
@@ -103,6 +103,15 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
/** 有效预约订单状态,只统计已支付待核销和已完成核销的订单。 */
private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes();
+ /**
+ * 人员预约统计的有效订单与退款净商品一次性加载结果。
+ *
+ * 统计链路原先在订单保留和菜品聚合两个阶段分别执行同一有效商品/核销事实查询,
+ * 快照把两份数据合并为一次查询结果,供每日聚合与范围总计共用。
+ */
+ private record EffectiveReservationSnapshot(List orders, Map> carMap) {
+ }
+
/** 菜品预约聚合 Mapper。 */
private final HardCarMapper hardCarMapper;
@@ -271,7 +280,8 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
*
* 统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取完成退款扣减后的有效商品。
* 已核销筛选直接复用菜品销售统计的核销事实,保证人数、订单、份数和金额不会因整行状态或分页产生偏差。
- * 菜品分组名称按 canteen_spu 当前数据解析,避免商户在每日菜谱中改菜名后同一商品按历史快照拆成多行。
+ * 菜品分组名称按 canteen_spu 当前数据解析,避免商户在每日菜谱中改菜名后同一商品按历史快照拆成多行。
+ * 有效订单与退款净商品通过同一快照一次加载,避免聚合阶段重复执行重查询。
*
* @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件
* @return 按日期升序排列并包含范围总计的统计结果
@@ -282,11 +292,13 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
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);
+ EffectiveReservationSnapshot snapshot = loadEffectiveReservationSnapshot(
+ fetchRawReservationOrders(userIds, query), query);
+ List orders = snapshot.orders();
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, query);
+ Map> carMap = snapshot.carMap();
Map currentSpuMap = fetchCurrentCanteenSpus(carMap);
Map dailyBuckets = new LinkedHashMap<>();
@@ -328,7 +340,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
*
* 订单先按商户范围、日期和餐段读取,再复用 PC 人员预约页的核销状态判定筛选用户日期行,
* 保证待核销、部分核销和已核销的协议值及统计日期口径一致。菜品分组名称与 PC 统计一致,
- * 按 canteen_spu 当前数据解析。
+ * 按 canteen_spu 当前数据解析。有效订单与退款净商品通过同一快照一次加载,避免重复执行重查询。
*
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围
@@ -347,8 +359,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
HardOrderDto orderQuery = buildReservationOrderQuery(query, null);
orderQuery.setMerchantId(null);
orderQuery.setMchIds(scopedMerchantIds);
- List orders = retainEffectiveReservationOrders(
- hardOrderMapper.scopeList(orderQuery, null), query);
+ EffectiveReservationSnapshot snapshot = loadEffectiveReservationSnapshot(
+ hardOrderMapper.scopeListReservationStats(orderQuery, null), query);
+ List orders = snapshot.orders();
if (orders.isEmpty()) {
return new HardCanteenUserApptStatisticsVo();
}
@@ -361,7 +374,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
.filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList();
Map orderMap = orders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
- Map> carMap = fetchReservationCars(orders, query);
+ Map> carMap = snapshot.carMap();
Map currentSpuMap = fetchCurrentCanteenSpus(carMap);
Map dailyBuckets = new LinkedHashMap<>();
@@ -802,6 +815,30 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
return retainEffectiveReservationOrders(orders, query);
}
+ /**
+ * 按用户主键分批查询指定日期范围内的食堂预约订单,不做有效商品过滤。
+ *
+ * 统计链路随后统一加载有效订单与退款净商品快照,避免先在分批查询后逐次保留、
+ * 再对保留结果重复查询有效商品数据。订单查询使用统计专用轻量语句,只读 hard_order 主表列。
+ *
+ * @param userIds 权限范围内的用户主键
+ * @param query 日期范围、食堂和餐段筛选条件
+ * @return 当前租户内符合条件的预约订单
+ */
+ private List fetchRawReservationOrders(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.scopeListReservationStats(orderQuery, null));
+ }
+ return orders;
+ }
+
/**
* 只保留完成退款后仍有有效商品的预约订单,并把订单展示金额改为退款后的净商品金额。
*
@@ -836,34 +873,71 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
.map(HardCarEntity::getTotalPrice).mapToLong(HardCanteenApptTotalServiceImpl::number).sum(),
(left, right) -> left, LinkedHashMap::new));
}
+ return retainOrdersByEffectiveTotalPrice(orders, effectiveTotalPriceMap);
+ }
+
+ /**
+ * 一次性加载有效预约订单及其退款后有效商品,供统计链路同时获得保留订单和菜品聚合数据。
+ *
+ * 原实现先在订单保留阶段查一次有效商品(或核销事实),聚合阶段再对保留后的订单重复同一查询,
+ * 大日期范围下重查询执行两遍直接放大线上耗时。这里按核销口径只查一次并派生两份数据,
+ * 聚合结果与原"保留 + 重新查询"链路逐行等价:已核销用销售统计事实,其他状态用退款净商品快照,
+ * 全额退款订单剔除、部分退款只保留净额。
+ *
+ * @param orders 已按订单状态和业务日期查询出的预约订单
+ * @param query 当前人员页面筛选条件
+ * @return 有效订单与父购物车有效商品映射的统计快照
+ */
+ private EffectiveReservationSnapshot loadEffectiveReservationSnapshot(List orders,
+ HardCanteenUserApptQueryDto query) {
+ if (orders.isEmpty()) {
+ return new EffectiveReservationSnapshot(List.of(), Map.of());
+ }
+ if (isVerifiedFilter(query)) {
+ Set orderIds = orders.stream().map(HardOrderVo::getId)
+ .filter(Objects::nonNull).collect(Collectors.toSet());
+ List facts = fetchReservationVerifiedSaleFacts(
+ orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query)
+ .stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId())).toList();
+ Map effectiveTotalPriceMap = facts.stream().collect(Collectors.groupingBy(
+ HardCanteenReservationSaleFact::getOrderId,
+ Collectors.summingLong(fact -> number(fact.getTotalPrice()))));
+ List retainedOrders = retainOrdersByEffectiveTotalPrice(orders, effectiveTotalPriceMap);
+ Map retainedOrderMap = retainedOrders.stream().filter(order -> order.getId() != null)
+ .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
+ Map> carMap = facts.stream()
+ .filter(fact -> retainedOrderMap.containsKey(fact.getOrderId()))
+ .map(fact -> toEffectiveCar(fact, retainedOrderMap.get(fact.getOrderId())))
+ .collect(Collectors.groupingBy(HardCarEntity::getParentCarId, LinkedHashMap::new,
+ Collectors.toList()));
+ return new EffectiveReservationSnapshot(retainedOrders, carMap);
+ }
+ Map> carMap = fetchEffectiveReservationCars(orders);
+ Map effectiveTotalPriceMap = orders.stream()
+ .filter(order -> order.getId() != null && order.getParentCarId() != null)
+ .filter(order -> carMap.containsKey(order.getParentCarId()))
+ .collect(Collectors.toMap(HardOrderVo::getId,
+ order -> carMap.get(order.getParentCarId()).stream()
+ .map(HardCarEntity::getTotalPrice).mapToLong(HardCanteenApptTotalServiceImpl::number).sum(),
+ (left, right) -> left, LinkedHashMap::new));
+ return new EffectiveReservationSnapshot(retainOrdersByEffectiveTotalPrice(orders, effectiveTotalPriceMap), carMap);
+ }
+
+ /**
+ * 按有效净额映射过滤订单并回写退款后净总价。
+ *
+ * @param orders 原始预约订单
+ * @param effectiveTotalPriceMap 订单主键到退款后净总价的映射
+ * @return 仍有有效商品的订单,总价已更新为净额
+ */
+ private List retainOrdersByEffectiveTotalPrice(List orders,
+ Map effectiveTotalPriceMap) {
return orders.stream().filter(order -> order.getId() != null)
.filter(order -> effectiveTotalPriceMap.containsKey(order.getId()))
.peek(order -> order.setTotalPrice(Math.toIntExact(effectiveTotalPriceMap.get(order.getId()))))
.toList();
}
- /**
- * 分批读取预约订单关联的退款后有效商品,避免统计请求生成超长父购物车 IN 条件。
- *
- * @param orders 当前筛选口径下的预约订单
- * @param query 当前人员页面筛选条件;已核销时用于切换到销售统计事实
- * @return 父购物车主键到有效商品快照的映射
- */
- private Map> fetchReservationCars(List orders,
- HardCanteenUserApptQueryDto query) {
- if (isVerifiedFilter(query)) {
- Map orderMap = orders.stream().filter(order -> order.getId() != null)
- .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
- return fetchReservationVerifiedSaleFacts(
- orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query)
- .stream().filter(fact -> fact.getOrderId() != null && orderMap.containsKey(fact.getOrderId()))
- .map(fact -> toEffectiveCar(fact, orderMap.get(fact.getOrderId())))
- .collect(Collectors.groupingBy(HardCarEntity::getParentCarId, LinkedHashMap::new,
- Collectors.toList()));
- }
- return fetchEffectiveReservationCars(orders);
- }
-
/**
* 按父购物车分批读取完成退款扣减后的预约商品。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordExportServiceImpl.java
new file mode 100644
index 000000000..eb87f46ed
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordExportServiceImpl.java
@@ -0,0 +1,804 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+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.feign.RemoteFileService;
+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.util.R;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
+import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
+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.constant.order.HardPayTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
+import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardCarRecordExportExcelVo;
+import com.bsd.pjcloud.hard.mapper.order.HardCarRecordMapper;
+import com.bsd.pjcloud.hard.service.HardCarRecordExportService;
+import com.bsd.pjcloud.hard.service.HardManagerService;
+import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
+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.beans.BeanUtils;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 购物车商品记录异步导出服务实现。
+ *
+ * 导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取商品明细,
+ * 并在同一任务线程同步补充完整用户资料和商户名称,避免列表服务公共线程池丢失 Feign 登录凭证。
+ * 使用 EasyExcel 自定义二级表头分片写入,完成后上传文件中心并返回下载地址。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HardCarRecordExportServiceImpl implements HardCarRecordExportService {
+
+ /**
+ * 商品记录导出日志标签,用于定位分批查询、工作簿写入和文件上传异常。
+ */
+ private static final String LOG_TAG = "购物车记录导出";
+
+ /**
+ * 商品记录导出任务的 Redis Key 业务命名空间。
+ */
+ private static final String CAR_RECORD_EXPORT_REDIS_KEY = "hard:carRecord:export";
+
+ /**
+ * 商品记录导出任务在进度弹窗中的展示名称。
+ */
+ private static final String CAR_RECORD_EXPORT_TASK_NAME = "购物车记录导出";
+
+ /**
+ * 后台取得真实记录数前创建任务使用的占位总数。
+ */
+ private static final int CAR_RECORD_EXPORT_INITIAL_TOTAL = 1;
+
+ /**
+ * 单次商品记录查询和 EasyExcel 流式写入使用的记录数。
+ */
+ private static final int CAR_RECORD_EXPORT_BATCH_SIZE = 500;
+
+ /**
+ * 文件上传及异步任务成功收尾占用的额外进度步数。
+ */
+ private static final int CAR_RECORD_EXPORT_EXTRA_PROGRESS_STEPS = 2;
+
+ /**
+ * EasyExcel 二级表头占用的固定行数。
+ */
+ private static final int CAR_RECORD_EXPORT_HEADER_ROW_COUNT = 2;
+
+ /**
+ * 全部商品明细之后写入的合计行数量。
+ */
+ private static final int CAR_RECORD_EXPORT_SUMMARY_ROW_COUNT = 1;
+
+ /**
+ * 单个工作表最多允许写入的明细数量。
+ */
+ private static final int MAX_CAR_RECORD_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount(
+ CAR_RECORD_EXPORT_HEADER_ROW_COUNT, CAR_RECORD_EXPORT_SUMMARY_ROW_COUNT);
+
+ /**
+ * 商品记录导出工作表名称。
+ */
+ private static final String CAR_RECORD_EXPORT_SHEET_NAME = "购物车记录";
+
+ /**
+ * 文件中心保存商品记录导出文件的业务目录。
+ */
+ private static final String CAR_RECORD_EXPORT_FILE_DIR = "hard-export";
+
+ /**
+ * 文件中心区分商品记录导出文件的业务类型编码。
+ */
+ private static final String CAR_RECORD_EXPORT_FILE_TYPE = "HARD_CAR_RECORD_EXPORT";
+
+ /**
+ * XLSX 文件的标准 MIME 类型。
+ */
+ private static final String CAR_RECORD_EXPORT_CONTENT_TYPE =
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ /**
+ * 异步商品记录导出文件名使用的时间格式。
+ */
+ private static final DateTimeFormatter CAR_RECORD_EXPORT_FILE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ /**
+ * Excel 日期时间字段统一展示格式,避免 EasyExcel 依赖服务端默认格式。
+ */
+ private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ /**
+ * Excel 预约用餐日期字段统一展示格式。
+ */
+ private static final DateTimeFormatter EXCEL_DATE_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ /**
+ * 购物车商品记录 Mapper,负责在当前导出任务线程内按数据权限分页读取原始记录。
+ */
+ private final HardCarRecordMapper hardCarRecordMapper;
+
+ /**
+ * 用户身份支持器,负责把身份筛选条件预转换为用户主键范围并同步装配展示资料。
+ */
+ private final HardUserIdentitySupport userIdentitySupport;
+
+ /**
+ * 设备服务,用于冻结当前用户允许查看的设备类型。
+ */
+ private final HardManagerService hardManagerService;
+
+ /**
+ * 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围并查询商户名称。
+ */
+ private final RemoteMchService remoteMchService;
+
+ /**
+ * 文件中心远程服务,用于持久化异步生成的商品记录工作簿。
+ */
+ private final RemoteFileService remoteFileService;
+
+ /**
+ * 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。
+ */
+ private final AsyncTaskExecutorService asyncTaskExecutorService;
+
+ /**
+ * 提交购物车商品记录异步分批导出任务。
+ *
+ * @param dto 商品记录页面当前筛选条件;carIds 非空时仅导出勾选的商品明细行
+ * @return 可供前端轮询的初始任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitExport(HardCarRecordDto dto) {
+ HardCarRecordDto querySnapshot = prepareExportQuery(copyQuery(dto));
+ return asyncTaskExecutorService.submitWithResult(CAR_RECORD_EXPORT_REDIS_KEY, CAR_RECORD_EXPORT_TASK_NAME,
+ CAR_RECORD_EXPORT_INITIAL_TOTAL, reporter -> exportToFile(querySnapshot, reporter));
+ }
+
+ /**
+ * 在异步线程中生成商品记录工作簿并上传文件中心。
+ *
+ * @param query 已冻结权限和筛选条件的商品记录查询
+ * @param reporter 当前异步任务进度报告器
+ * @return 文件中心下载地址和业务文件名
+ */
+ private AsyncTaskResult exportToFile(HardCarRecordDto query, AsyncTaskProgressReporter reporter) {
+ Page firstPage = fetchExportPage(1L, CAR_RECORD_EXPORT_BATCH_SIZE, query, true);
+ long total = validateExportTotal(firstPage.getTotal());
+ int progressTotal = BatchExcelExportTemplate.calculateProgressTotal(total, CAR_RECORD_EXPORT_BATCH_SIZE,
+ CAR_RECORD_EXPORT_EXTRA_PROGRESS_STEPS);
+ reporter.updateTotal(progressTotal, "已查询 " + total + " 条商品记录,将分批生成文件");
+
+ String fileName = "购物车记录_" + LocalDateTime.now().format(CAR_RECORD_EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
+ try {
+ String fileUrl = writeExportWorkbook(query, firstPage, total, reporter,
+ file -> uploadExportFile(file, fileName));
+ reporter.advance("购物车记录导出文件已上传,共 " + total + " 条");
+ return AsyncTaskResult.file(fileUrl, fileName);
+ } catch (IllegalArgumentException | IllegalStateException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("|{}|生成或上传文件失败,fileName:{}", LOG_TAG, fileName, exception);
+ throw new IllegalStateException("生成购物车记录导出文件失败", exception);
+ }
+ }
+
+ /**
+ * 按页查询商品记录并使用 EasyExcel 流式写入自定义二级表头工作簿。
+ *
+ * 文件先写入临时路径,只有工作簿完整关闭后才交给上传回调;每批写入后上报一次进度,
+ * 避免大批量记录长期占用请求线程和堆内存。
+ *
+ * @param query 已冻结的商品记录查询条件
+ * @param firstPage 已用于取得总数的第一页,避免重复查询首批记录
+ * @param total 导出开始时按相同条件统计的记录总数
+ * @param reporter 异步任务进度报告器
+ * @param fileConsumer 完整临时文件消费回调
+ * @param 文件消费结果类型
+ * @return 文件消费回调返回的结果
+ */
+ private R writeExportWorkbook(HardCarRecordDto query, Page firstPage, long total,
+ AsyncTaskProgressReporter reporter,
+ BatchExcelExportTemplate.FileConsumer fileConsumer) {
+ Path outputPath = null;
+ try {
+ outputPath = Files.createTempFile("hard-car-record-export-", ".xlsx");
+ CarRecordExportSummary summary = new CarRecordExportSummary();
+ long processedTotal = 0L;
+ long current = 1L;
+ try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCarRecordExportExcelVo.class).build()) {
+ WriteSheet writeSheet = EasyExcel.writerSheet(CAR_RECORD_EXPORT_SHEET_NAME).build();
+ Page currentPage = firstPage;
+ while (processedTotal < total) {
+ List records = safeRecords(currentPage);
+ if (records.isEmpty()) {
+ break;
+ }
+ long remaining = total - processedTotal;
+ int recordCount = Math.toIntExact(Math.min(records.size(), remaining));
+ List batch = records.subList(0, recordCount);
+ List excelRows = new ArrayList<>(batch.size());
+ for (HardCarRecordVo item : batch) {
+ summary.add(item);
+ excelRows.add(toExcelRow(item, processedTotal + excelRows.size() + 1L));
+ }
+ excelWriter.write(excelRows, writeSheet);
+ processedTotal += batch.size();
+ if (reporter != null) {
+ reporter.advance("已写入 " + processedTotal + " / " + total + " 条商品记录");
+ }
+ if (processedTotal >= total) {
+ break;
+ }
+ currentPage = fetchExportPage(++current, CAR_RECORD_EXPORT_BATCH_SIZE, query, false);
+ }
+ if (processedTotal != total) {
+ throw new IllegalStateException("商品记录在导出期间发生变化,预期 " + total
+ + " 条,实际处理 " + processedTotal + " 条");
+ }
+ excelWriter.write(List.of(toSummaryExcelRow(summary)), writeSheet);
+ }
+ return fileConsumer.consume(outputPath);
+ } catch (IllegalArgumentException | IllegalStateException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw new IllegalStateException("生成购物车记录导出文件失败", exception);
+ } finally {
+ deleteTemporaryExportFile(outputPath);
+ }
+ }
+
+ /**
+ * 查询指定商品记录导出分页,数据范围与列表页保持为商户部门权限。
+ *
+ * @param current 从一开始的页码
+ * @param size 单批最大记录数
+ * @param query 已冻结的商品记录筛选条件
+ * @param searchCount 是否执行总数查询,仅第一页需要
+ * @return 在当前任务线程同步装配完整用户资料和商户名称的导出分页
+ */
+ private Page fetchExportPage(long current, long size, HardCarRecordDto query, boolean searchCount) {
+ Page page = new Page<>(current, size, searchCount);
+ Page recordPage = hardCarRecordMapper.recordPage(page, query, createRecordDataScope());
+ disposeRows(recordPage.getRecords());
+ return recordPage;
+ }
+
+ /**
+ * 在导出任务线程中同步装配一个商品记录分片的用户资料和商户名称。
+ *
+ * @param records 当前商品记录分片,最多为商品记录导出批大小
+ */
+ private void disposeRows(List records) {
+ if (records == null || records.isEmpty()) {
+ return;
+ }
+ userIdentitySupport.fillUserInfo(records, true);
+ List merchantIds = records.stream().map(HardCarRecordVo::getMerchantId)
+ .filter(Objects::nonNull).distinct().toList();
+ Map merchantNameMap = fetchMerchantNameMap(merchantIds);
+ for (HardCarRecordVo item : records) {
+ if (StrUtil.isBlank(item.getMerchantName())) {
+ item.setMerchantName(merchantNameMap.get(item.getMerchantId()));
+ }
+ }
+ }
+
+ /**
+ * 批量查询商品记录商户名称并校验钱包服务业务响应。
+ *
+ * @param merchantIds 当前记录分片去重后的商户主键
+ * @return 商户主键到名称的映射;记录未关联系统商户时返回空映射
+ */
+ private Map fetchMerchantNameMap(List merchantIds) {
+ if (merchantIds.isEmpty()) {
+ return Map.of();
+ }
+ R> response = remoteMchService.getByIds(merchantIds);
+ if (response == null || !response.isOk()) {
+ String message = response == null ? null : response.getMsg();
+ throw new IllegalStateException(StrUtil.blankToDefault(message, "查询商品记录导出商户资料失败"));
+ }
+ if (response.getData() == null || response.getData().isEmpty()) {
+ return Map.of();
+ }
+ return response.getData().stream().filter(merchant -> merchant != null && merchant.getId() != null
+ && StrUtil.isNotBlank(merchant.getName()))
+ .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left,
+ LinkedHashMap::new));
+ }
+
+ /**
+ * 将一条商品记录转换为二级表头导出对象。
+ *
+ * @param item 当前商品记录,已完成用户资料和商户名称装配
+ * @param lineNumber 当前记录在全部导出结果中的一基序号
+ * @return 已完成枚举翻译、金额和时间格式化的精简导出行
+ */
+ private HardCarRecordExportExcelVo toExcelRow(HardCarRecordVo item, long lineNumber) {
+ HardCarRecordExportExcelVo row = new HardCarRecordExportExcelVo();
+ row.setLineNumber(Long.toString(lineNumber));
+ row.setProductName(item.getProductName());
+ row.setProductPrice(formatPrice(item.getProductPrice()));
+ row.setProductCount(item.getProductCount());
+ row.setTotalPrice(formatPrice(item.getTotalPrice()));
+
+ row.setUserName(StrUtil.blankToDefault(item.getUsername(), ""));
+ row.setPhone(item.getUserMobile());
+ row.setDeptName(item.getUserDeptName());
+ row.setIdentityTypeNames(joinIdentityNames(item.getIdentityTypes()));
+ row.setStudentNo(item.getStudentInfo() == null ? null : item.getStudentInfo().getStudentNo());
+ row.setSchoolName(item.getStudentInfo() == null ? null : item.getStudentInfo().getSchoolName());
+ row.setClassName(item.getStudentInfo() == null ? null : item.getStudentInfo().getClassName());
+ row.setTeacherNo(item.getTeacherInfo() == null ? null : item.getTeacherInfo().getTeacherNo());
+
+ row.setOrderNo(item.getOrderNo());
+ row.setOrderType(resolveOrderType(item.getOrderType()));
+ row.setOrderStatus(resolveOrderStatus(item.getOrderStatus()));
+ row.setMerchantName(item.getMerchantName());
+ row.setHardName(item.getHardName());
+ row.setHardType(resolveHardType(item.getHardType()));
+ row.setPayType(resolvePayType(item.getPayType()));
+ row.setHourType(resolveHourType(item.getHourType()));
+
+ row.setCreateTime(formatDateTime(item.getOrderCreateTime()));
+ row.setPayTime(formatDateTime(item.getPayTime()));
+ row.setApptUseDate(item.getApptUseDate() == null ? null : item.getApptUseDate().format(EXCEL_DATE_FORMATTER));
+ row.setVerificationTime(formatDateTime(item.getVerificationTime()));
+
+ row.setRefundStatus(resolveRefundStatus(item.getRefundStatus()));
+ row.setRefundPrice(formatPrice(item.getRefundPrice()));
+ row.setRefundTime(formatDateTime(item.getRefundTime()));
+ return row;
+ }
+
+ /**
+ * 生成与明细相同结构的数量与金额合计行,确保 EasyExcel 保持二级表头列顺序。
+ *
+ * @param summary 全部分片累计的购买数量和小计金额
+ * @return 首列显示"合计"且数量、小计列写入汇总值的导出行
+ */
+ private HardCarRecordExportExcelVo toSummaryExcelRow(CarRecordExportSummary summary) {
+ HardCarRecordExportExcelVo row = new HardCarRecordExportExcelVo();
+ row.setLineNumber("合计");
+ row.setProductCount(Math.toIntExact(summary.productCountSum));
+ row.setTotalPrice(summary.formatPrice(summary.totalPriceSum));
+ return row;
+ }
+
+ /**
+ * 将用户身份协议编码转换并拼接为中文名称,未知历史编码保留原值。
+ *
+ * @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 orderType 数据库和跨端接口使用的订单类型编码
+ * @return 订单类型中文描述、原始未知编码或空值
+ */
+ private String resolveOrderType(String orderType) {
+ if (StrUtil.isBlank(orderType)) {
+ return orderType;
+ }
+ HardOrderTypeEnum value = HardOrderTypeEnum.valueOfByType(orderType);
+ return value == null ? orderType : value.getDescription();
+ }
+
+ /**
+ * 将订单状态协议编码安全转换为中文描述。
+ *
+ * 既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。
+ *
+ * @param orderStatus 数据库和跨端接口使用的订单状态编码
+ * @return 订单状态中文描述、原始未知编码或空值
+ */
+ private String resolveOrderStatus(String orderStatus) {
+ if (StrUtil.isBlank(orderStatus)) {
+ return orderStatus;
+ }
+ for (HardOrderStatusEnum value : HardOrderStatusEnum.values()) {
+ if (Objects.equals(value.getType(), orderStatus)) {
+ return value.getDescription();
+ }
+ }
+ return orderStatus;
+ }
+
+ /**
+ * 将设备类型协议编码安全转换为中文描述,未知历史编码原样保留。
+ *
+ * @param hardType 硬件设备类型编码
+ * @return 设备类型中文描述、原始未知编码或空值
+ */
+ private String resolveHardType(String hardType) {
+ if (StrUtil.isBlank(hardType)) {
+ return hardType;
+ }
+ for (HardTypeEnum value : HardTypeEnum.values()) {
+ if (Objects.equals(value.getType(), hardType)) {
+ return value.getDescription();
+ }
+ }
+ return hardType;
+ }
+
+ /**
+ * 将支付方式协议编码转换为现有枚举中文描述,未知编码原样保留以兼容历史数据。
+ *
+ * @param payType 订单支付方式协议编码
+ * @return 支付方式中文描述或原始编码
+ */
+ private String resolvePayType(String payType) {
+ if (StrUtil.isBlank(payType)) {
+ return payType;
+ }
+ HardPayTypeEnum value = HardPayTypeEnum.valueOfByType(payType);
+ return value == null ? payType : value.getDescription();
+ }
+
+ /**
+ * 将餐段协议编码转换为现有枚举中文描述,未知历史编码原样保留。
+ *
+ * @param hourType 用餐时间段协议编码
+ * @return 餐段中文描述、原始未知编码或空值
+ */
+ private String resolveHourType(String hourType) {
+ if (StrUtil.isBlank(hourType)) {
+ return hourType;
+ }
+ HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType);
+ return value == null ? hourType : value.getDescription();
+ }
+
+ /**
+ * 将订单退款状态编码转换为中文名称,未知历史编码原样保留。
+ *
+ * @param refundStatus hard_order.refund_status 中保存的退款状态编码
+ * @return 退款状态中文名称、原始未知编码或空值
+ */
+ private String resolveRefundStatus(String refundStatus) {
+ if (StrUtil.isBlank(refundStatus)) {
+ return refundStatus;
+ }
+ for (HardRefundStatusEnum value : HardRefundStatusEnum.values()) {
+ if (Objects.equals(value.getStatus(), refundStatus)) {
+ return value.getName();
+ }
+ }
+ return refundStatus;
+ }
+
+ /**
+ * 将金额从分转换为保留两位小数的元金额。
+ *
+ * @param amount 金额,单位为分;空值表示模板单元格留空
+ * @return 单位为元的金额;输入为空时返回空
+ */
+ private BigDecimal formatPrice(Number amount) {
+ return amount == null ? null : BigDecimal.valueOf(amount.longValue())
+ .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 将业务日期时间转换为 Excel 统一使用的标准文本。
+ *
+ * @param dateTime 业务日期时间,允许为空
+ * @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空
+ */
+ private String formatDateTime(LocalDateTime dateTime) {
+ return dateTime == null ? null : dateTime.format(EXCEL_DATE_TIME_FORMATTER);
+ }
+
+ /**
+ * 上传完整商品记录工作簿并校验文件中心业务响应。
+ *
+ * @param file 已关闭并可读取的完整 XLSX 临时文件
+ * @param fileName 用户下载时使用的文件名
+ * @return 文件中心提供的下载地址
+ * @throws Exception 文件读取或远程上传失败时抛出
+ */
+ private String uploadExportFile(Path file, String fileName) throws Exception {
+ MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
+ CAR_RECORD_EXPORT_CONTENT_TYPE, Files.readAllBytes(file));
+ R> uploadResult = remoteFileService.innerUpload(exportFile, CAR_RECORD_EXPORT_FILE_DIR, null,
+ CAR_RECORD_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 fileName 用户看到的 UTF-8 文件名
+ * @return 文件中心上传接口使用的原始文件名
+ */
+ private String encodeUploadOriginalFilename(String fileName) {
+ return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
+ }
+
+ /**
+ * 清理商品记录导出临时文件;清理失败只记录告警,不覆盖原始导出或上传结果。
+ *
+ * @param outputPath 待删除的临时 XLSX 路径,允许为空
+ */
+ private void deleteTemporaryExportFile(Path outputPath) {
+ if (outputPath == null) {
+ return;
+ }
+ try {
+ Files.deleteIfExists(outputPath);
+ } catch (Exception exception) {
+ log.warn("|{}|清理临时文件失败,path:{}", LOG_TAG, outputPath, exception);
+ }
+ }
+
+ /**
+ * 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。
+ *
+ * @param query 已复制、由当前任务独占的商品记录查询
+ * @return 已补充设备类型、身份用户和商户权限范围的同一查询对象
+ */
+ private HardCarRecordDto prepareExportQuery(HardCarRecordDto query) {
+ CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(SecurityUtils.getUser());
+ query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null
+ ? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList()));
+
+ applyIdentityQuery(query);
+
+ List mchIds = query.getMchIds();
+ if (mchIds != null && mchIds.size() == 1) {
+ Long parentMchId = mchIds.get(0);
+ R> response = remoteMchService.getByParentId(parentMchId);
+ if (response == null || !response.isOk()) {
+ throw new IllegalStateException("查询导出子商户范围失败");
+ }
+ List expandedMchIds = new ArrayList<>();
+ expandedMchIds.add(parentMchId);
+ if (response.getData() != null) {
+ response.getData().stream().map(MchManageVo::getId).filter(Objects::nonNull)
+ .forEach(expandedMchIds::add);
+ }
+ query.setMchIds(expandedMchIds.stream().distinct().toList());
+ }
+ return query;
+ }
+
+ /**
+ * 将身份筛选一次性转换为用户主键范围,后续所有记录分片复用同一条件。
+ *
+ * @param query 当前导出任务独占的商品记录筛选条件,方法会清除已转换的身份字段
+ */
+ private void applyIdentityQuery(HardCarRecordDto query) {
+ if (!userIdentitySupport.hasIdentityQuery(query)) {
+ return;
+ }
+ query.setUserIds(userIdentitySupport.findIdentityUserIds(query, query.getUserId(), query.getUserIds()));
+ query.setUserId(null);
+ query.setIdentityTypes(null);
+ query.setStudentInfo(null);
+ query.setTeacherInfo(null);
+ query.setHasFace(null);
+ }
+
+ /**
+ * 深复制商品记录导出筛选条件,避免请求对象中的集合或数组在后台任务开始前发生变化。
+ *
+ * @param source Controller 接收的商品记录筛选条件,允许为空
+ * @return 后台导出任务独占的筛选条件副本
+ */
+ private HardCarRecordDto copyQuery(HardCarRecordDto source) {
+ HardCarRecordDto target = new HardCarRecordDto();
+ if (source == null) {
+ return target;
+ }
+ BeanUtils.copyProperties(source, target);
+ target.setCarIds(copyList(source.getCarIds()));
+ target.setOrderStatusCodes(copyList(source.getOrderStatusCodes()));
+ target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes()));
+ target.setHourTypeCodes(copyList(source.getHourTypeCodes()));
+ target.setMerchantTypeCodes(copyList(source.getMerchantTypeCodes()));
+ target.setMchTypeCodes(copyList(source.getMchTypeCodes()));
+ target.setHardTypeCodes(copyList(source.getHardTypeCodes()));
+ target.setMchIds(copyList(source.getMchIds()));
+ target.setUserIds(copyList(source.getUserIds()));
+ target.setUserDeptIds(copyList(source.getUserDeptIds()));
+ target.setIdentityTypes(copyList(source.getIdentityTypes()));
+ target.setCreateTimeList(cloneArray(source.getCreateTimeList()));
+ target.setPayTimeList(cloneArray(source.getPayTimeList()));
+ target.setVerificationTimeList(cloneArray(source.getVerificationTimeList()));
+ target.setApptUseDateTimeList(cloneArray(source.getApptUseDateTimeList()));
+ target.setStudentInfo(copyStudentInfo(source.getStudentInfo()));
+ target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo()));
+ return target;
+ }
+
+ /**
+ * 复制可变列表,保留空值语义并隔离调用方后续修改。
+ *
+ * @param source 原始列表,允许为空
+ * @param 列表元素类型
+ * @return 独立列表副本;输入为空时返回空
+ */
+ private List copyList(List source) {
+ return source == null ? null : new ArrayList<>(source);
+ }
+
+ /**
+ * 复制日期时间范围数组,保留空值语义。
+ *
+ * @param source 原始日期时间范围,允许为空
+ * @return 独立数组副本;输入为空时返回空
+ */
+ private LocalDateTime[] cloneArray(LocalDateTime[] source) {
+ return source == null ? null : source.clone();
+ }
+
+ /**
+ * 复制学生身份扩展筛选条件,避免异步任务共享请求对象。
+ *
+ * @param source 学生身份筛选条件,允许为空
+ * @return 独立学生筛选副本;输入为空时返回空
+ */
+ private com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto copyStudentInfo(
+ com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto source) {
+ if (source == null) {
+ return null;
+ }
+ com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto target =
+ new com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto();
+ BeanUtils.copyProperties(source, target);
+ return target;
+ }
+
+ /**
+ * 复制老师身份扩展筛选条件,避免异步任务共享请求对象。
+ *
+ * @param source 老师身份筛选条件,允许为空
+ * @return 独立老师筛选副本;输入为空时返回空
+ */
+ private com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto copyTeacherInfo(
+ com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto source) {
+ if (source == null) {
+ return null;
+ }
+ com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto target =
+ new com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto();
+ BeanUtils.copyProperties(source, target);
+ return target;
+ }
+
+ /**
+ * 创建商品记录页面使用的数据权限配置,以商户部门字段限制当前用户可见记录。
+ *
+ * @return 独立的数据权限参数,供单次分页查询使用
+ */
+ private DataScope createRecordDataScope() {
+ DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
+ dataScope.setScopeDeptName("merchant_dept_id");
+ return dataScope;
+ }
+
+ /**
+ * 校验导出总数和单工作表容量。
+ *
+ * @param total 第一页分页查询返回的商品记录总数
+ * @return 已通过校验的商品记录总数
+ */
+ private long validateExportTotal(long total) {
+ if (total <= 0L) {
+ throw new IllegalStateException("没有符合当前条件的购物车商品记录");
+ }
+ if (total > MAX_CAR_RECORD_EXPORT_ROWS) {
+ throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + MAX_CAR_RECORD_EXPORT_ROWS
+ + " 条,请缩小查询范围后分批导出");
+ }
+ return total;
+ }
+
+ /**
+ * 获取分页记录并把空记录统一为不可变空列表。
+ *
+ * @param page 商品记录分页结果
+ * @return 当前页导出行;分页或记录为空时返回空列表
+ */
+ private List safeRecords(Page page) {
+ return page == null || page.getRecords() == null ? List.of() : page.getRecords();
+ }
+
+ /**
+ * 商品记录导出跨分页汇总器,数量按整数累计、小计金额按数据库分单位累计。
+ */
+ private static final class CarRecordExportSummary {
+
+ /**
+ * 购买数量累计值。
+ */
+ private long productCountSum = 0L;
+
+ /**
+ * 商品小计金额累计值,单位为分。
+ */
+ private BigDecimal totalPriceSum = BigDecimal.ZERO;
+
+ /**
+ * 累计当前商品记录的购买数量和小计金额,历史空值按零处理。
+ *
+ * @param item 当前商品记录
+ */
+ private void add(HardCarRecordVo item) {
+ if (item.getProductCount() != null) {
+ productCountSum += item.getProductCount();
+ }
+ if (item.getTotalPrice() != null) {
+ totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(item.getTotalPrice()));
+ }
+ }
+
+ /**
+ * 将累计的分金额转换为两位小数元金额。
+ *
+ * @param amount 累计金额,单位为分
+ * @return 单位为元的两位小数金额
+ */
+ private BigDecimal formatPrice(BigDecimal amount) {
+ return amount.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+ }
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordServiceImpl.java
new file mode 100644
index 000000000..c0546025f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarRecordServiceImpl.java
@@ -0,0 +1,136 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.core.util.CollectionUtils;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.hard.api.dto.order.HardCarRecordDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardCarRecordVo;
+import com.bsd.pjcloud.hard.mapper.order.HardCarRecordMapper;
+import com.bsd.pjcloud.hard.service.HardCarRecordService;
+import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
+import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.web.context.request.RequestAttributes;
+import org.springframework.web.context.request.RequestContextHolder;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * 购物车商品记录查询服务实现。
+ *
+ * 身份筛选统一转换为用户主键范围后进入 SQL;当前页记录在查询返回后并行装配用户资料、
+ * 商户名称和钱包类型,装配口径与订单列表保持一致。列表页和异步导出任务共用本实现,
+ * 保证两个入口的数据口径完全相同。
+ */
+@RequiredArgsConstructor
+@Service
+public class HardCarRecordServiceImpl implements HardCarRecordService {
+
+ /**
+ * 购物车商品记录 Mapper,负责按数据范围分页读取商品明细及订单摘要。
+ */
+ private final HardCarRecordMapper hardCarRecordMapper;
+
+ /**
+ * 用户身份支持器,负责把身份筛选条件预转换为用户主键范围并装配展示资料。
+ */
+ private final HardUserIdentitySupport userIdentitySupport;
+
+ /**
+ * 商户远程服务,用于批量查询商户名称和 Logo。
+ */
+ private final RemoteMchService remoteMchService;
+
+ /**
+ * 钱包远程服务,用于批量查询钱包类型编码。
+ */
+ private final RemoteWalletService remoteWalletService;
+
+ /**
+ * 按筛选条件和数据范围分页查询商品记录,并在身份条件转换后装配跨服务展示资料。
+ *
+ * @param page 分页参数
+ * @param dto 商品、订单、用户、部门、时间及身份筛选条件
+ * @param dataScope 后台商户部门数据范围
+ * @return 已完成展示信息装配的商品记录分页结果
+ */
+ @Override
+ public Page scopePageList(Page page, HardCarRecordDto dto, DataScope dataScope) {
+ applyIdentityQuery(dto);
+ Page list = hardCarRecordMapper.recordPage(page, dto, dataScope);
+ disposeList(list.getRecords());
+ return list;
+ }
+
+ /**
+ * 把身份筛选一次性转换为用户主键范围,无匹配用户时写入无效主键避免空集合放宽查询。
+ *
+ * @param dto 当前商品记录筛选条件,方法会清除已转换的身份字段
+ */
+ private void applyIdentityQuery(HardCarRecordDto dto) {
+ if (!userIdentitySupport.hasIdentityQuery(dto)) {
+ return;
+ }
+ dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
+ dto.setUserId(null);
+ dto.setIdentityTypes(null);
+ dto.setStudentInfo(null);
+ dto.setTeacherInfo(null);
+ dto.setHasFace(null);
+ }
+
+ /**
+ * 批量装配商品记录的用户资料、商户名称和钱包类型,各独立数据源并行执行以缩短整体等待时间。
+ *
+ * @param list 当前页商品记录;空列表不发起任何关联查询
+ */
+ private void disposeList(List list) {
+ if (ObjectUtil.isEmpty(list)) {
+ return;
+ }
+ RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
+ // 用户与身份资料
+ CompletableFuture userFuture = CompletableFuture.runAsync(() -> {
+ RequestContextHolder.setRequestAttributes(requestAttributes);
+ userIdentitySupport.fillUserInfo(list, true);
+ });
+ // 商户名称与 Logo
+ CompletableFuture mchFuture = CompletableFuture.runAsync(() -> {
+ RequestContextHolder.setRequestAttributes(requestAttributes);
+ List mchIds = list.stream().map(HardCarRecordVo::getMerchantId)
+ .filter(ObjectUtil::isNotEmpty).distinct().toList();
+ if (ObjectUtil.isNotEmpty(mchIds)) {
+ List mchList = remoteMchService.getByIds(mchIds).getData();
+ if (ObjectUtil.isNotEmpty(mchList)) {
+ WalletDataUtil.convertMchList(list, CollectionUtils.convertMap(mchList, MchManageVo::getId));
+ }
+ }
+ });
+ // 钱包类型
+ CompletableFuture walletFuture = CompletableFuture.runAsync(() -> {
+ RequestContextHolder.setRequestAttributes(requestAttributes);
+ List walletIds = list.stream().map(HardCarRecordVo::getWalletId)
+ .filter(ObjectUtil::isNotEmpty).distinct().toList();
+ if (ObjectUtil.isNotEmpty(walletIds)) {
+ List walletList = remoteWalletService.getWalletByIds(walletIds).getData();
+ Map walletMap = CollectionUtils.convertMap(walletList, WalletManageVo::getId);
+ for (HardCarRecordVo item : list) {
+ WalletManageVo wallet = walletMap.get(item.getWalletId());
+ if (ObjectUtil.isEmpty(wallet)) {
+ continue;
+ }
+ item.setWalletTypeUniqueCode(wallet.getWalletTypeUniqueCode());
+ }
+ }
+ });
+ CompletableFuture.allOf(userFuture, mchFuture, walletFuture).join();
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java
index 93a4b2211..de86ea586 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java
@@ -408,12 +408,14 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
/**
* 创建订单页面使用的数据权限配置,以商户部门字段限制当前用户可见订单。
+ * 与订单分页接口同口径关闭个人权限子句:订单 create_by 不会是后台账号,导出与列表只按商户部门过滤。
*
* @return 独立的数据权限参数,供单次分页查询使用
*/
private DataScope createOrderDataScope() {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
+ dataScope.setScopeUserName(null);
return dataScope;
}
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 6575e6db1..5231fe3a6 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
@@ -584,7 +584,8 @@ public class HardOrderServiceImpl extends ServiceImpl查询失败时按入参决定降级策略:App 列表/详情场景 failClosed=false,降级为空列表
+ * 以保留本人和被监护人可见性,不阻断订单查询。
+ *
+ * @param userId 当前访问者用户ID
+ * @param failClosed true 表示查询失败直接抛错;false 表示降级为空列表
+ * @return 关系图谱用户 ID(不含本人,已去重);查询失败且允许降级时返回空列表
+ */
+ private List listRelatedUserIds(Long userId, boolean failClosed) {
+ R> result;
+ try {
+ result = userRelationService.listRelatedUserIds(userId);
+ } catch (Exception exception) {
+ if (failClosed) {
+ throw new RuntimeException("查询用户关系图谱失败", exception);
+ }
+ log.warn("|{}|查询用户关系图谱异常,仅保留本人和被监护人订单权限,userId:|{}|,error:|{}|", TAG, userId, exception.getMessage());
+ return List.of();
+ }
+ if (result == null || !result.isOk() || result.getData() == null) {
+ if (failClosed) {
+ throw new I18nBizException(ErrorCodes.WALLET_USER_RELATION_FAIL);
+ }
+ log.warn("|{}|查询用户关系图谱失败,仅保留本人和被监护人订单权限,userId:|{}|", TAG, userId);
+ return List.of();
+ }
+ return result.getData().stream().filter(Objects::nonNull).distinct().toList();
+ }
+
private boolean isCanteenReserveOrder(HardOrderEntity order) {
return Objects.equals(order.getMerchantType(), MerchantTypeEnum.CANTEEN.getType())
&& Objects.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())
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 9aceb91db..393434efd 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
@@ -376,7 +376,12 @@
order by sale_stats.stat_time, sale_stats.order_id, sale_stats.product_id
-
+
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarRecordMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarRecordMapper.xml
new file mode 100644
index 000000000..e66e065d9
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarRecordMapper.xml
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
index 38581d45e..2551b4907 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
@@ -294,11 +294,20 @@
-
+
and (
a.user_id = #{dto.appViewerUserId}
+
+ or a.user_id in
+
+ #{v}
+
+
or (
a.merchant_type = '1'
and a.sys_type = '2'
@@ -447,6 +456,8 @@
merchant_dept_id 必须保留在查询列中:数据权限拦截器对部门权限用户包一层
SELECT * FROM (本查询) temp_data_scope WHERE temp_data_scope.merchant_dept_id IN (...),
缺少该列会直接 SQL 报错;总数统计走分页插件自动 count,基于数据权限改写后的 SQL 生成,同样带权限。
+ create_by 有意不查询:管理端入口(prepareAdminOrderQuery / noRecogPageJoin / 导出 createOrderDataScope)
+ 已 setScopeUserName(null) 关闭个人权限子句,不会拼出 temp_data_scope.create_by。
-->
+
+
+