feat:钱包模块导出都改为分批,前端显示导出进度条的模式。

fix:修改各个统计模块统计的数量不一致的情况。
fix:修改支付宝分账退款失败的情况
This commit is contained in:
dzc committed 2026-08-26 17:49:13 +08:00
1 parent f9e50d0af8
commit fc35443b90
34 files changed
+2620 -187

No files matched your search

@@ -22,10 +22,13 @@ import java.util.List;
@Schema(description = "食堂用户预约查询条件")
public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
/** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */
/**
* 统计日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。
* 核销状态为 VERIFIED 时匹配实际核销时间,其他状态匹配预约用餐日期。
*/
@NotNull(message = "用餐日期范围不能为空")
@Size(min = 2, max = 2, message = "用餐日期范围必须包含开始和结束日期")
@Schema(description = "用餐日期范围,固定为开始日期和结束日期", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "统计日期范围,已核销取核销日期,其他状态取预约用餐日期", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate[] apptUseDateList;
/** 用户姓名、登录账号或手机号的模糊查询关键字;为空时不限制。 */
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
@@ -20,6 +21,10 @@ public class HardCanteenUserApptOrderVo {
@Schema(description = "用餐时间段字典编码")
private String hourType;
/** 订单预约用餐日期;已核销按核销日查询时用于保留原预约日期,历史未记录时为空。 */
@Schema(description = "预约用餐日期")
private LocalDate apptUseDate;
/** 下单时保存的商品描述;历史数据未记录时为空。 */
@Schema(description = "订餐内容")
private String productDesc;
@@ -92,8 +92,8 @@ public class HardCanteenUserApptVo implements BaseUserDeptInterface, BaseUserIde
@Schema(description = "老师身份扩展信息")
private SysUserTeacherDto teacherInfo;
/** 本行对应的用餐日期。 */
@Schema(description = "用餐日期")
/** 本行统计日期;已核销筛选取实际核销日期,其他筛选取预约用餐日期。 */
@Schema(description = "统计日期,已核销取核销日期,其他状态取预约用餐日期")
private LocalDate apptUseDate;
/** 订餐状态,RESERVED 表示当前筛选口径存在有效预约,NOT_RESERVED 表示不存在。 */
@@ -9,7 +9,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 用户和用餐日期维度的食堂预约导出行。
* 用户和统计日期维度的食堂预约导出行。
*/
@Data
@ColumnWidth(22)
@@ -63,8 +63,8 @@ public class HardCanteenUserApptExcelVo {
@ExcelProperty({"老师身份", "老师学号"})
private String teacherNo;
/** 查询指定的用餐日期。 */
@ExcelProperty({"预约信息", "用餐日期"})
/** 查询指定的统计日期;已核销筛选取核销日期,其他状态取预约用餐日期。 */
@ExcelProperty({"预约信息", "统计日期"})
private LocalDate apptUseDate;
/** 是否存在符合当前食堂、餐段筛选的有效预约。 */
@@ -103,12 +103,12 @@ public class HardCanteenUserApptExcelVo {
@ExcelProperty({"金额统计", "餐段总金额(元)"})
private String mealSegmentTotalPrices;
/** 用户本行日期内有效预约商品总价,单位为元并保留两位小数。 */
@ExcelProperty({"金额统计", "当日总金额(元)"})
/** 用户本行统计日期内完成退款扣减后的预约商品净额,单位为元并保留两位小数。 */
@ExcelProperty({"金额统计", "统计日净额(元)"})
@NumberFormat("#,##0.00")
private BigDecimal totalPrice;
/** 用户当天有效预约的整体核销状态。 */
/** 用户在本行统计日期和当前筛选口径内有效预约的整体核销状态。 */
@ExcelProperty({"核销信息", "核销状态"})
private String verificationStatus;
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -67,6 +68,14 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
*/
List<HardCanteenProductTotalVo> productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 查询预约核销商品事实,复用菜品销售统计的核销时间和完成退款净额口径。
*
* @param dto 核销日期、食堂、餐段和用户范围;调用方必须把来源限制为预约核销
* @return 按核销时间、订单和商品稳定排序的有效商品事实,全额退款订单不返回记录
*/
List<HardCanteenReservationSaleFact> listReservationVerifiedSaleFacts(@Param("dto") HardOrderDto dto);
Page<HardCanteenProductTotalVo> productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
@@ -133,6 +142,17 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
.orderByAsc(HardCarEntity::getId));
}
/**
* 按父购物车批量查询完成退款扣减后的预约商品快照。
*
* <p>部分退款仅保留剩余份数和金额,全额退款商品不返回;该结果用于待核销、部分核销及预约日统计,
* 避免人员页面继续累计原始购物车快照。</p>
*
* @param parentCarIds 预约订单关联的父购物车主键集合
* @return 退款后仍有有效数量的商品快照;传入空集合时调用方应直接返回空列表
*/
List<HardCarEntity> listEffectiveReservationCars(@Param("parentCarIds") List<Long> parentCarIds);
/**
* 菜品销量按照分类查询
*/
@@ -0,0 +1,39 @@
package com.bsd.pjcloud.hard.mapper.order.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 食堂预约核销商品事实。
*
* <p>数据直接来自菜品销售统计的预约核销分支,日期取订单核销时间,数量和金额均已扣除状态为 50 的
* 完成退款;仅用于 Hardware 服务内部统一人员统计与商品统计口径,不作为外部接口模型。</p>
*/
@Data
public class HardCanteenReservationSaleFact {
/** 订单实际核销完成时间,用于把人员统计行归入对应核销日期。 */
private LocalDateTime statisticsTime;
/** 已核销预约订单主键,用于订单去重及关联页面预约明细。 */
private Long orderId;
/** 实际用餐用户主键,用于人员筛选和人数去重。 */
private Long userId;
/** 订单餐段协议编码,来源于核销时的订单快照。 */
private String hourType;
/** 订单商品主键,历史数据无法识别商品时可为空。 */
private Long productId;
/** 下单时保存的商品名称,历史未记录时可为空。 */
private String productName;
/** 完成退款扣减后的有效商品份数,最小为零。 */
private Integer productCount;
/** 完成退款扣减后的有效商品金额,单位为分,最小为零。 */
private Long totalPrice;
}
@@ -27,6 +27,7 @@ import com.bsd.pjcloud.common.core.util.R;
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.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
@@ -36,6 +37,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -499,15 +501,15 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
private void validateQuery(HardCanteenUserApptQueryDto query) {
if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2
|| query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) {
throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期");
throw new IllegalArgumentException("统计日期范围必须包含开始和结束日期");
}
LocalDate startDate = query.getApptUseDateList()[0];
LocalDate endDate = query.getApptUseDateList()[1];
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("用餐结束日期不能早于开始日期");
throw new IllegalArgumentException("统计结束日期不能早于开始日期");
}
if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) {
throw new IllegalArgumentException("用餐日期范围最多查询两个月");
throw new IllegalArgumentException("统计日期范围最多查询两个月");
}
if (StrUtil.isNotBlank(query.getReservationStatus())
&& !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) {
@@ -520,7 +522,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
/**
* 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。
* 展开统计范围内的全部自然日,开始和结束日期均包含在结果中。
*
* @param query 已通过日期范围校验的查询条件
* @return 按日期升序排列的查询日期
@@ -697,9 +699,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
Map<Long, String> merchantNameMap = fetchMerchantNameMap(orders);
Map<Long, Map<LocalDate, List<HardCanteenUserApptOrderVo>>> orderMap = orders.stream()
.filter(order -> order.getUserId() != null && order.getApptUseDate() != null)
.filter(order -> order.getUserId() != null && resolveStatisticsDate(order, query) != null)
.collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new,
Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new,
Collectors.groupingBy(order -> resolveStatisticsDate(order, query), LinkedHashMap::new,
Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList()))));
return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> {
@@ -754,16 +756,54 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
new ArrayList<>(userIds.subList(start, end)));
orders.addAll(hardOrderMapper.scopeList(orderQuery, null));
}
return orders;
return retainEffectiveReservationOrders(orders, query);
}
/**
* 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。
* 只保留完成退款后仍有有效商品的预约订单,并把导出金额改为退款后的净商品金额。
*
* @param orders 已按订单状态和业务日期查询出的预约订单
* @param query 当前导出筛选条件
* @return 完成退款后仍有商品的订单,订单总价已更新为净额
*/
private List<HardOrderVo> retainEffectiveReservationOrders(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (orders.isEmpty()) {
return List.of();
}
Map<Long, Long> effectiveTotalPriceMap;
if (isVerifiedFilter(query)) {
Set<Long> orderIds = orders.stream().map(HardOrderVo::getId)
.filter(Objects::nonNull).collect(Collectors.toSet());
effectiveTotalPriceMap = fetchReservationVerifiedSaleFacts(
orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query)
.stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId()))
.collect(Collectors.groupingBy(HardCanteenReservationSaleFact::getOrderId,
Collectors.summingLong(fact -> number(fact.getTotalPrice()))));
} else {
Map<Long, List<HardCarEntity>> carMap = fetchEffectiveReservationCars(orders);
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(HardCanteenApptTotalExportServiceImpl::number).sum(),
(left, right) -> left, LinkedHashMap::new));
}
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 当前筛选口径下的预约订单
* @return 父购物车主键到商品快照的映射
* @return 父购物车主键到退款后有效商品的映射
*/
private Map<Long, List<HardCarEntity>> fetchReservationCars(List<HardOrderVo> orders) {
private Map<Long, List<HardCarEntity>> fetchEffectiveReservationCars(List<HardOrderVo> orders) {
List<Long> parentCarIds = orders.stream().map(HardOrderVo::getParentCarId)
.filter(Objects::nonNull).distinct().toList();
if (parentCarIds.isEmpty()) {
@@ -772,12 +812,41 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
Map<Long, List<HardCarEntity>> result = new LinkedHashMap<>();
for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) {
int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size());
hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end))
hardCarMapper.listEffectiveReservationCars(parentCarIds.subList(start, end))
.forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car));
}
return result;
}
/**
* 按用户分批查询菜品销售统计中的预约核销商品事实。
*
* @param userIds 已经过 UPMS 数据权限和人员条件限制的用户主键
* @param query 核销日期、食堂和餐段筛选条件
* @return 完成退款扣减后仍有商品的预约核销事实
*/
private List<HardCanteenReservationSaleFact> fetchReservationVerifiedSaleFacts(
List<Long> userIds, HardCanteenUserApptQueryDto query) {
if (userIds.isEmpty()) {
return List.of();
}
List<HardCanteenReservationSaleFact> facts = 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 factQuery = new HardOrderDto();
factQuery.setUserIds(new ArrayList<>(userIds.subList(start, end)));
factQuery.setMerchantId(query.getMerchantId());
factQuery.setHourType(query.getHourType());
factQuery.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
factQuery.setStatisticsDateTimeList(new LocalDateTime[]{
query.getApptUseDateList()[0].atStartOfDay(),
query.getApptUseDateList()[1].atTime(LocalTime.MAX)
});
facts.addAll(hardCarMapper.listReservationVerifiedSaleFacts(factQuery));
}
return facts;
}
/**
* 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。
*
@@ -795,15 +864,64 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
orderQuery.setMerchantId(query.getMerchantId());
orderQuery.setHourType(query.getHourType());
orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType());
orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES);
orderQuery.setApptIdOrApptRuleIdNotNull(true);
orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{
query.getApptUseDateList()[0].atStartOfDay(),
query.getApptUseDateList()[1].atTime(LocalTime.MAX)
});
LocalDateTime[] dateTimeRange = new LocalDateTime[]{
query.getApptUseDateList()[0].atStartOfDay(), query.getApptUseDateList()[1].atTime(LocalTime.MAX)};
if (isVerifiedFilter(query)) {
orderQuery.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType()));
orderQuery.setVerificationTimeList(dateTimeRange);
} else {
orderQuery.setOrderStatusCodes(isUnverifiedFilter(query)
? List.of(HardOrderStatusEnum.PAY.getType()) : VALID_RESERVATION_ORDER_STATUSES);
orderQuery.setApptUseDateTimeList(dateTimeRange);
}
return orderQuery;
}
/**
* 解析导出行的业务统计日期。
*
* @param order 预约订单
* @param query 当前核销状态筛选
* @return 已核销筛选返回实际核销日期,其他情况返回预约用餐日期
*/
private LocalDate resolveStatisticsDate(HardOrderVo order, HardCanteenUserApptQueryDto query) {
if (isVerifiedFilter(query)) {
return order.getVerificationTime() == null ? null : order.getVerificationTime().toLocalDate();
}
return order.getApptUseDate();
}
/**
* 判断当前导出是否要求按单笔已核销订单统计。
*
* @param query 导出查询条件
* @return 核销状态为 VERIFIED 时返回 true
*/
private boolean isVerifiedFilter(HardCanteenUserApptQueryDto query) {
return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_VERIFIED);
}
/**
* 判断当前导出是否要求按单笔待核销订单统计。
*
* @param query 导出查询条件
* @return 核销状态为 UNVERIFIED 时返回 true
*/
private boolean isUnverifiedFilter(HardCanteenUserApptQueryDto query) {
return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_UNVERIFIED);
}
/**
* 将可能为空的商品金额安全归一为长整型零值。
*
* @param value 商品金额,单位为分
* @return 原数值的长整型表示;空值返回零
*/
private static long number(Number value) {
return value == null ? 0L : value.longValue();
}
/**
* 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。
*
@@ -843,6 +961,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo();
detail.setId(order.getId());
detail.setHourType(order.getHourType());
detail.setApptUseDate(order.getApptUseDate());
detail.setProductDesc(order.getProductDesc());
detail.setMerchantName(merchantNameMap.get(order.getMerchantId()));
detail.setTotalPrice(order.getTotalPrice());
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
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.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
@@ -27,6 +28,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -260,8 +262,8 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
/**
* 按当前用户预约查询条件生成每日和查询范围总计。
*
* <p>统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数,
* 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。</p>
* <p>统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取完成退款扣减后的有效商品。
* 已核销筛选直接复用菜品销售统计的核销事实,保证人数、订单、份数和金额不会因整行状态或分页产生偏差。</p>
*
* @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件
* @return 按日期升序排列并包含范围总计的统计结果
@@ -276,7 +278,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
List<HardCanteenUserApptVo> rows = filterRows(buildRows(users, query, queryDates, orders), query);
Map<Long, HardOrderVo> orderMap = orders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
Map<Long, List<HardCarEntity>> carMap = fetchReservationCars(orders);
Map<Long, List<HardCarEntity>> carMap = fetchReservationCars(orders, query);
Map<LocalDate, StatisticsBucket> dailyBuckets = new LinkedHashMap<>();
queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket()));
@@ -304,15 +306,15 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
private void validateQuery(HardCanteenUserApptQueryDto query) {
if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2
|| query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) {
throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期");
throw new IllegalArgumentException("统计日期范围必须包含开始和结束日期");
}
LocalDate startDate = query.getApptUseDateList()[0];
LocalDate endDate = query.getApptUseDateList()[1];
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("用餐结束日期不能早于开始日期");
throw new IllegalArgumentException("统计结束日期不能早于开始日期");
}
if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) {
throw new IllegalArgumentException("用餐日期范围最多查询两个月");
throw new IllegalArgumentException("统计日期范围最多查询两个月");
}
if (StrUtil.isNotBlank(query.getReservationStatus())
&& !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) {
@@ -325,7 +327,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
}
/**
* 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。
* 展开统计范围内的全部自然日,开始和结束日期均包含在结果中。
*
* @param query 已通过日期范围校验的查询条件
* @return 按日期升序排列的查询日期
@@ -621,9 +623,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
}
Map<Long, String> merchantNameMap = fetchMerchantNameMap(orders);
Map<Long, Map<LocalDate, List<HardCanteenUserApptOrderVo>>> orderMap = orders.stream()
.filter(order -> order.getUserId() != null && order.getApptUseDate() != null)
.filter(order -> order.getUserId() != null && resolveStatisticsDate(order, query) != null)
.collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new,
Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new,
Collectors.groupingBy(order -> resolveStatisticsDate(order, query), LinkedHashMap::new,
Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList()))));
return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> {
@@ -678,16 +680,78 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
new ArrayList<>(userIds.subList(start, end)));
orders.addAll(hardOrderMapper.scopeList(orderQuery, null));
}
return orders;
return retainEffectiveReservationOrders(orders, query);
}
/**
* 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。
* 只保留完成退款后仍有有效商品的预约订单,并把订单展示金额改为退款后的净商品金额。
*
* <p>已核销筛选直接使用菜品销售统计事实判定有效订单;其他预约状态读取退款后有效商品快照。
* 这样全额退款订单不会继续计入人员、订单和金额,部分退款只保留实际剩余值。</p>
*
* @param orders 已按订单状态和业务日期查询出的预约订单
* @param query 当前人员页面筛选条件
* @return 完成退款后仍有商品的订单,订单总价已更新为净额
*/
private List<HardOrderVo> retainEffectiveReservationOrders(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (orders.isEmpty()) {
return List.of();
}
Map<Long, Long> effectiveTotalPriceMap;
if (isVerifiedFilter(query)) {
Set<Long> orderIds = orders.stream().map(HardOrderVo::getId)
.filter(Objects::nonNull).collect(Collectors.toSet());
effectiveTotalPriceMap = fetchReservationVerifiedSaleFacts(
orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query)
.stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId()))
.collect(Collectors.groupingBy(HardCanteenReservationSaleFact::getOrderId,
Collectors.summingLong(fact -> number(fact.getTotalPrice()))));
} else {
Map<Long, List<HardCarEntity>> carMap = fetchEffectiveReservationCars(orders);
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 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 当前筛选口径下的预约订单
* @return 父购物车主键到商品快照的映射
* @param query 当前人员页面筛选条件;已核销时用于切换到销售统计事实
* @return 父购物车主键到有效商品快照的映射
*/
private Map<Long, List<HardCarEntity>> fetchReservationCars(List<HardOrderVo> orders) {
private Map<Long, List<HardCarEntity>> fetchReservationCars(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (isVerifiedFilter(query)) {
Map<Long, HardOrderVo> 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);
}
/**
* 按父购物车分批读取完成退款扣减后的预约商品。
*
* @param orders 当前筛选口径下的预约订单
* @return 父购物车主键到退款后有效商品的映射
*/
private Map<Long, List<HardCarEntity>> fetchEffectiveReservationCars(List<HardOrderVo> orders) {
List<Long> parentCarIds = orders.stream().map(HardOrderVo::getParentCarId)
.filter(Objects::nonNull).distinct().toList();
if (parentCarIds.isEmpty()) {
@@ -696,12 +760,58 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
Map<Long, List<HardCarEntity>> result = new LinkedHashMap<>();
for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) {
int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size());
hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end))
hardCarMapper.listEffectiveReservationCars(parentCarIds.subList(start, end))
.forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car));
}
return result;
}
/**
* 按用户分批查询菜品销售统计中的预约核销商品事实。
*
* @param userIds 已经过 UPMS 数据权限和人员条件限制的用户主键
* @param query 核销日期、食堂和餐段筛选条件
* @return 完成退款扣减后仍有商品的预约核销事实
*/
private List<HardCanteenReservationSaleFact> fetchReservationVerifiedSaleFacts(
List<Long> userIds, HardCanteenUserApptQueryDto query) {
if (userIds.isEmpty()) {
return List.of();
}
List<HardCanteenReservationSaleFact> facts = 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 factQuery = new HardOrderDto();
factQuery.setUserIds(new ArrayList<>(userIds.subList(start, end)));
factQuery.setMerchantId(query.getMerchantId());
factQuery.setHourType(query.getHourType());
factQuery.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
factQuery.setStatisticsDateTimeList(new java.time.LocalDateTime[]{
query.getApptUseDateList()[0].atStartOfDay(),
query.getApptUseDateList()[1].atTime(LocalTime.MAX)
});
facts.addAll(hardCarMapper.listReservationVerifiedSaleFacts(factQuery));
}
return facts;
}
/**
* 将销售统计商品事实转换为人员统计聚合器使用的有效购物车快照。
*
* @param fact 已扣完成退款的预约核销商品事实
* @param order 商品所属预约订单
* @return 仅包含统计所需父购物车、商品、份数和金额的内存对象
*/
private HardCarEntity toEffectiveCar(HardCanteenReservationSaleFact fact, HardOrderVo order) {
HardCarEntity car = new HardCarEntity();
car.setParentCarId(order.getParentCarId());
car.setProductId(fact.getProductId());
car.setProductName(fact.getProductName());
car.setProductCount(fact.getProductCount());
car.setTotalPrice(fact.getTotalPrice());
return car;
}
/**
* 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。
*
@@ -719,15 +829,64 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
orderQuery.setMerchantId(query.getMerchantId());
orderQuery.setHourType(query.getHourType());
orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType());
orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES);
orderQuery.setApptIdOrApptRuleIdNotNull(true);
orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{
query.getApptUseDateList()[0].atStartOfDay(),
query.getApptUseDateList()[1].atTime(LocalTime.MAX)
});
java.time.LocalDateTime[] dateTimeRange = new java.time.LocalDateTime[]{
query.getApptUseDateList()[0].atStartOfDay(), query.getApptUseDateList()[1].atTime(LocalTime.MAX)};
if (isVerifiedFilter(query)) {
orderQuery.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType()));
orderQuery.setVerificationTimeList(dateTimeRange);
} else {
orderQuery.setOrderStatusCodes(isUnverifiedFilter(query)
? List.of(HardOrderStatusEnum.PAY.getType()) : VALID_RESERVATION_ORDER_STATUSES);
orderQuery.setApptUseDateTimeList(dateTimeRange);
}
return orderQuery;
}
/**
* 解析当前订单在人员页面中的统计日期。
*
* @param order 预约订单
* @param query 当前核销状态筛选
* @return 已核销筛选返回实际核销日期,其他情况返回预约用餐日期
*/
private LocalDate resolveStatisticsDate(HardOrderVo order, HardCanteenUserApptQueryDto query) {
if (isVerifiedFilter(query)) {
return order.getVerificationTime() == null ? null : order.getVerificationTime().toLocalDate();
}
return order.getApptUseDate();
}
/**
* 判断当前查询是否要求按单笔已核销订单统计。
*
* @param query 人员页面查询条件
* @return 核销状态为 VERIFIED 时返回 true
*/
private boolean isVerifiedFilter(HardCanteenUserApptQueryDto query) {
return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_VERIFIED);
}
/**
* 判断当前查询是否要求按单笔待核销订单统计。
*
* @param query 人员页面查询条件
* @return 核销状态为 UNVERIFIED 时返回 true
*/
private boolean isUnverifiedFilter(HardCanteenUserApptQueryDto query) {
return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_UNVERIFIED);
}
/**
* 将可能为空的数值安全归一为长整型零值。
*
* @param value 商品数量或金额
* @return 原数值的长整型表示;空值返回零
*/
private static long number(Number value) {
return value == null ? 0L : value.longValue();
}
/**
* 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。
*
@@ -767,6 +926,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo();
detail.setId(order.getId());
detail.setHourType(order.getHourType());
detail.setApptUseDate(order.getApptUseDate());
detail.setProductDesc(order.getProductDesc());
detail.setMerchantName(merchantNameMap.get(order.getMerchantId()));
detail.setTotalPrice(order.getTotalPrice());
@@ -356,6 +356,44 @@
</if>
</sql>
<!-- 人员已核销统计直接读取菜品统计的预约核销事实,避免再次实现核销日期和退款扣减规则。 -->
<select id="listReservationVerifiedSaleFacts"
resultType="com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact">
select sale_stats.stat_time as statisticsTime,
sale_stats.order_id as orderId,
sale_stats.user_id as userId,
sale_stats.hour_type as hourType,
sale_stats.product_id as productId,
sale_stats.product_name as productName,
sale_stats.product_count as productCount,
sale_stats.total_price as totalPrice
from <include refid="canteenSaleStatsSource"/>
<where>
<include refid="canteenSaleStatsWhere"/>
and sale_stats.sale_source = 'RESERVATION_VERIFIED'
</where>
order by sale_stats.stat_time, sale_stats.order_id, sale_stats.product_id
</select>
<!-- 非已核销人员统计按父购物车读取同一套完成退款净商品,外层限制避免影响其他预约统计调用。 -->
<select id="listEffectiveReservationCars" resultMap="hardCarMap">
select a.parent_car_id,
a.product_id,
a.product_name,
a.product_pic,
a.product_price,
a.product_count,
a.total_price,
a.create_time,
a.del_flag
from <include refid="apptEffectiveCarSource"/>
where a.parent_car_id in
<foreach collection="parentCarIds" item="parentCarId" open="(" close=")" separator=",">
#{parentCarId}
</foreach>
order by a.parent_car_id, a.product_id
</select>
<select id="productTotalByDept" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select DATE_FORMAT(a.create_time, '%Y-%m-%d') as createTime,
o.user_dept_id,
@@ -665,11 +665,14 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
* 判断支付宝退款查询是否尚未找到当前稳定退款请求。
*
* @param response 支付宝退款查询响应
* @return 成功响应缺少退款状态,或返回明确的退款/交易不存在错误码时返回 {@code true}
* @return 仅在没有任何退款状态且成功响应未给出记录,或返回明确的退款/交易不存在错误码时返回 {@code true}
*/
private boolean isAlipayRefundQueryMissing(AlipayTradeFastpayRefundQueryResponse response) {
return response != null && (response.isSuccess() && StrUtil.isBlank(response.getRefundStatus())
|| ALIPAY_REFUND_NOT_FOUND_CODES.contains(response.getSubCode()));
if (response == null || StrUtil.isNotBlank(response.getRefundStatus())) {
// refundStatus 是支付宝退款查询的业务事实;即使 subCode 为空,也不能覆盖明确的退款终态。
return false;
}
return response.isSuccess() || ALIPAY_REFUND_NOT_FOUND_CODES.contains(response.getSubCode());
}
/**
@@ -233,7 +233,9 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
*/
private final RemoteHardOrderService remoteHardOrderService;
/** 支付创建前验证并固定商户平台分佣关系。 */
/**
* 支付创建前验证并固定商户平台分佣关系。
*/
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
@@ -475,7 +477,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
* 远程 DTO 中的商户 ID 只用于一致性校验,不能覆盖订单归属。
*
* @param userId 当前付款用户 ID
* @param dto 小程序支付请求
* @param dto 小程序支付请求
* @return 已校验的商城收款商户
*/
private MchManageVo resolveShopPayMerchant(Long userId, ThirdMiniAppPayDto dto) {
@@ -747,7 +749,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
* 解析默认关系并生成订单级渠道配置快照,二级商户与支付宝授权均不得再从支付配置 JSON 读取。
*
* @param paySetting 已由商户服务确认启用的支付配置
* @param payDto 即将提交渠道服务的内部支付参数
* @param payDto 即将提交渠道服务的内部支付参数
* @return 由关系事实生成的独立支付配置快照
*/
private MchPaySettingVo applyDefaultPaymentRelation(MchPaySettingVo paySetting, ThirdAppPlusPayDto payDto) {
@@ -1223,18 +1225,18 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
if (query == null) {
return;
}
boolean refundSuccess = "REFUND_SUCCESS".equals(query.getRefundStatus());
boolean retryable = (query.isSuccess() && StrUtil.isBlank(query.getRefundStatus()))
|| StrUtil.isBlank(query.getSubCode())
|| List.of("ACQ.SYSTEM_ERROR", "ACQ.TRADE_SETTLE_ERROR", "ACQ.FREQUENCY_LIMITED")
.contains(query.getSubCode());
if (!refundSuccess && retryable) {
String refundStatus = query.getRefundStatus();
boolean refundSuccess = "REFUND_SUCCESS".equals(refundStatus);
boolean refundProcessing = "REFUND_PROCESSING".equals(refundStatus) || "PROCESSING".equals(refundStatus);
// subCode 为空是支付宝成功查询的正常返回形式,只有退款状态也为空时才可作为未知结果重试。
boolean refundStatusMissing = StrUtil.isBlank(refundStatus);
boolean retryable = refundStatusMissing && (query.isSuccess() || StrUtil.isBlank(query.getSubCode()) || List.of("ACQ.SYSTEM_ERROR", "ACQ.TRADE_SETTLE_ERROR", "ACQ.FREQUENCY_LIMITED").contains(query.getSubCode()));
if (!refundSuccess && (refundProcessing || retryable)) {
return;
}
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setRefundCallbackRes(JSONUtil.toJsonStr(query));
updateObj.setStatus(refundSuccess ? ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()
: ThirdPayOrderStatusEnum.REFUND_FAILED.getType());
updateObj.setStatus(refundSuccess ? ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType() : ThirdPayOrderStatusEnum.REFUND_FAILED.getType());
if (!refundSuccess) {
updateObj.setIsBizCallback(YesNoEnum.YES.getCode());
}
@@ -445,6 +445,24 @@ class ThirdAppPlusAliPayServiceImplTest {
verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class));
}
/**
* 验证支付宝明确返回退款成功时,即使 subCode 为空也保留退款业务事实,不会误触发退款重提。
*/
@Test
void queryRefundShouldKeepSuccessWhenSubCodeIsBlank() throws Exception {
ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund-blank-sub-code");
AlipayTradeFastpayRefundQueryResponse success = new AlipayTradeFastpayRefundQueryResponse();
success.setCode("10000");
success.setMsg("Success");
success.setRefundStatus("REFUND_SUCCESS");
success.setRefundAmount("1.00");
success.setSendBackFee("1.00");
when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(success);
Assertions.assertSame(success, service.queryRefund(payOrder));
verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class));
}
@Test
void precreateShouldSetRequiredParametersAndSaveUnpaidOrder() throws Exception {
AlipayTradePrecreateResponse response = successPrecreateResponse("https://qr.alipay.com/test-content");
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.json.JSONUtil;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.admin.api.feign.RemoteTokenService;
@@ -645,6 +646,34 @@ class ThirdAppPlusServiceImplOrderCodePayTest {
Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(2).getStatus());
}
/**
* 验证支付宝退款成功以 refundStatus 为准,subCode 为空不会阻止订单落库和响应快照回填。
*/
@Test
void queryRefundResultShouldMapAlipaySuccessWhenSubCodeIsBlank() {
ThirdPayOrderEntity refundingOrder = refundingOrder(205L, "refund-alipay-success");
refundingOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
AlipayTradeFastpayRefundQueryResponse query = new AlipayTradeFastpayRefundQueryResponse();
query.setCode("10000");
query.setMsg("Success");
query.setRefundStatus("REFUND_SUCCESS");
query.setRefundAmount("1.00");
when(thirdMiniAppAliPayService.queryRefund(refundingOrder)).thenReturn(query);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity latest = refundingOrder(205L, "refund-alipay-success");
latest.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
latest.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType());
latest.setIsBizCallback(YesNoEnum.YES.getCode());
when(thirdPayOrderService.getById(205L)).thenReturn(latest);
service.queryRefundResult(refundingOrder);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), updateCaptor.getValue().getStatus());
Assertions.assertTrue(updateCaptor.getValue().getRefundCallbackRes().contains("REFUND_SUCCESS"));
}
private void mockNoExistingOrder() {
when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L);
when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(null);
@@ -0,0 +1,67 @@
package com.bsd.pjcloud.wallet.api.vo.export;
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 lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* 钱包账户余额及月度钱包记录导出行。
*
* <p>账户余额导出使用当前累计金额,月度记录导出使用指定月份末的历史余额;两种场景共享完整用户、
* 身份和钱包二级表头,不依赖固定 Excel 模板。</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ColumnWidth(15)
@HeadRowHeight(15)
@HeadFontStyle(fontHeightInPoints = 10)
@ContentFontStyle(fontHeightInPoints = 10)
public class WalletManageExportExcelVo extends WalletUserExportExcelVo {
/** 当前钱包在本次导出结果中的一基序号。 */
@ExcelProperty(value = {"钱包", "序号"}, index = 10)
private String lineNumber;
/** 月度记录查询月份,账户当前余额导出时为空。 */
@ExcelProperty(value = {"钱包", "统计月份"}, index = 11)
private String recordMonth;
/** 钱包类型中文名称;类型资料缺失时为空。 */
@ExcelProperty(value = {"钱包", "钱包类型"}, index = 12)
private String walletTypeName;
/** 钱包实体卡号;未绑定实体卡时为空。 */
@ExcelProperty(value = {"钱包", "卡号"}, index = 13)
private String cardNumber;
/** 当前或指定月份末钱包余额,单位为元并保留两位小数。 */
@ExcelProperty(value = {"金额", "余额(元)"}, index = 14)
@NumberFormat("#,##0.00")
private BigDecimal balance;
/** 钱包累计消费金额,单位为元并保留两位小数。 */
@ExcelProperty(value = {"金额", "累计消费(元)"}, index = 15)
@NumberFormat("#,##0.00")
private BigDecimal totalExpense;
/** 钱包累计充值金额,单位为元并保留两位小数。 */
@ExcelProperty(value = {"金额", "累计充值(元)"}, index = 16)
@NumberFormat("#,##0.00")
private BigDecimal totalRecharge;
/** 钱包冻结状态中文名称;空值表示历史数据未记录。 */
@ExcelProperty(value = {"钱包", "冻结状态"}, index = 17)
private String freezeStatus;
/** 指定月份末余额对应的最后流水时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty(value = {"钱包", "最后变动时间"}, index = 18)
@ColumnWidth(22)
private String lastTopUpTime;
}
@@ -0,0 +1,71 @@
package com.bsd.pjcloud.wallet.api.vo.export;
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 lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* 批量充值扣款水单导出行,按用户、身份、审批和金额组织二级表头。
*
* <p>用户与身份资料由 UPMS 完整导出接口按批补齐,金额统一转换为元;扣款和食堂退还场景保持负数,
* 以便财务人员直接汇总。</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ColumnWidth(15)
@HeadRowHeight(15)
@HeadFontStyle(fontHeightInPoints = 10)
@ContentFontStyle(fontHeightInPoints = 10)
public class WalletRechargeImportExportExcelVo extends WalletUserExportExcelVo {
/** 当前水单明细在本次导出结果中的一基序号。 */
@ExcelProperty(value = {"水单", "序号"}, index = 10)
private String lineNumber;
/** 批量充值或扣款所属月份,格式为 yyyy-MM。 */
@ExcelProperty(value = {"水单", "业务月份"}, index = 11)
private String importMonth;
/** 批量处理业务类型中文名称;未知历史编码保留原值。 */
@ExcelProperty(value = {"水单", "业务类型"}, index = 12)
private String bizTypeName;
/** 钱包类型中文名称;类型资料缺失时为空。 */
@ExcelProperty(value = {"钱包", "钱包类型"}, index = 13)
private String walletTypeName;
/** 操作前钱包金额,单位为元并保留两位小数。 */
@ExcelProperty(value = {"金额", "操作前金额(元)"}, index = 14)
@NumberFormat("#,##0.00")
private BigDecimal beforePrice;
/** 本次操作金额,单位为元并保留两位小数;扣款和退还场景为负数。 */
@ExcelProperty(value = {"金额", "操作金额(元)"}, index = 15)
@NumberFormat("#,##0.00")
private BigDecimal price;
/** 财务统计标签原始字典值,多个值使用英文逗号分隔。 */
@ExcelProperty(value = {"水单", "财务标签"}, index = 16)
private String tags;
/** 审批流程状态原始字典值,便于与管理端字典核对。 */
@ExcelProperty(value = {"审批", "审批状态"}, index = 17)
private String processStatus;
/** 审批处理时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty(value = {"审批", "审批时间"}, index = 18)
@ColumnWidth(22)
private String processTime;
/** 水单明细创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty(value = {"水单", "创建时间"}, index = 19)
@ColumnWidth(22)
private String createTime;
}
@@ -0,0 +1,20 @@
package com.bsd.pjcloud.wallet.api.vo.export;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 个人充值统计导出行。
*
* <p>在钱包流水公共用户、身份和金额列基础上补充所属食堂,保留历史个人充值统计
* 的财务核对维度;导出使用 EasyExcel 二级表头,不依赖固定模板。</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class WalletRechargePersonExportExcelVo extends WalletTransactionExportExcelVo {
/** 流水所属食堂名称,来源于钱包商户部门视图;无法匹配时为空。 */
@ExcelProperty(value = {"食堂", "所属食堂"}, index = 20)
private String canteenName;
}
@@ -0,0 +1,71 @@
package com.bsd.pjcloud.wallet.api.vo.export;
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 lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* 钱包流水导出行,按用户、身份、钱包和流水业务域组织二级表头。
*
* <p>充值、记账扣款、消费、退款、食堂退还及个人充值统计共用该结构;金额统一以元展示,
* 未识别的历史业务编码保留原值,避免导出过程丢失业务信息。</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ColumnWidth(15)
@HeadRowHeight(15)
@HeadFontStyle(fontHeightInPoints = 10)
@ContentFontStyle(fontHeightInPoints = 10)
public class WalletTransactionExportExcelVo extends WalletUserExportExcelVo {
/** 当前流水在本次导出结果中的一基序号。 */
@ExcelProperty(value = {"流水", "序号"}, index = 10)
private String lineNumber;
/** 钱包类型中文名称;类型资料缺失时为空。 */
@ExcelProperty(value = {"钱包", "钱包类型"}, index = 11)
private String walletTypeName;
/** 钱包卡号;未绑定实体卡时为空。 */
@ExcelProperty(value = {"钱包", "卡号"}, index = 12)
private String cardNumber;
/** 流水业务类型中文名称;未知历史编码保留原值。 */
@ExcelProperty(value = {"流水", "业务类型"}, index = 13)
private String bizTypeName;
/** 流水业务标题,来源于交易发生时保存的标题快照。 */
@ExcelProperty(value = {"流水", "标题"}, index = 14)
private String title;
/** 本次交易金额,单位为元并保留两位小数,支出保留负数。 */
@ExcelProperty(value = {"流水", "交易金额(元)"}, index = 15)
@NumberFormat("#,##0.00")
private BigDecimal price;
/** 交易完成后的钱包余额,单位为元并保留两位小数。 */
@ExcelProperty(value = {"流水", "交易后余额(元)"}, index = 16)
@NumberFormat("#,##0.00")
private BigDecimal balance;
/** 财务统计标签原始字典值,多个值使用英文逗号分隔。 */
@ExcelProperty(value = {"流水", "财务标签"}, index = 17)
private String tags;
/** 后台补充的流水业务备注;历史记录允许为空。 */
@ExcelProperty(value = {"流水", "备注"}, index = 18)
@ColumnWidth(24)
private String remark;
/** 流水创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty(value = {"流水", "交易时间"}, index = 19)
@ColumnWidth(22)
private String createTime;
}
@@ -0,0 +1,54 @@
package com.bsd.pjcloud.wallet.api.vo.export;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;
/**
* 钱包业务用户导出的公共用户与身份列。
*
* <p>用户资料统一来源于 UPMS 完整导出接口,业务数据只在用户已经删除或历史资料缺失时作为展示回退。
* 该对象仅定义二级表头的公共列,不参与数据库持久化和业务接口传输。</p>
*/
@Data
public abstract class WalletUserExportExcelVo {
/** 用户姓名,优先使用 UPMS 姓名,其次使用钱包业务历史展示名。 */
@ExcelProperty(value = {"用户", "姓名"}, index = 0)
private String userName;
/** 用户绑定手机号,优先使用 UPMS 当前手机号。 */
@ExcelProperty(value = {"用户", "手机号"}, index = 1)
private String phone;
/** 用户当前所属部门名称,UPMS 资料缺失时回退业务记录中的部门名称。 */
@ExcelProperty(value = {"用户", "部门"}, index = 2)
private String deptName;
/** 用户当前岗位名称,多个岗位使用中文逗号分隔。 */
@ExcelProperty(value = {"用户", "岗位"}, index = 3)
private String postNames;
/** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */
@ExcelProperty(value = {"身份", "身份类型"}, index = 4)
private String identityTypeNames;
/** 学生身份学号;用户没有学生身份时为空。 */
@ExcelProperty(value = {"身份", "学号"}, index = 5)
private String studentNo;
/** 学生身份学校名称;用户没有学生身份时为空。 */
@ExcelProperty(value = {"身份", "学校"}, index = 6)
private String schoolName;
/** 学生身份班级名称;用户没有学生身份时为空。 */
@ExcelProperty(value = {"身份", "班级"}, index = 7)
private String className;
/** 学生身份监护人手机号,多个号码沿用 UPMS 业务存储格式。 */
@ExcelProperty(value = {"身份", "监护人手机号"}, index = 8)
private String guardianPhones;
/** 老师身份工号或学号;用户没有老师身份时为空。 */
@ExcelProperty(value = {"身份", "老师学号"}, index = 9)
private String teacherNo;
}
@@ -25,8 +25,8 @@ import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo;
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.mapper.mch.view.MchDeptViewMapper;
import com.bsd.pjcloud.wallet.service.WalletCodeUseService;
import com.bsd.pjcloud.wallet.service.WalletManageExportService;
import com.bsd.pjcloud.wallet.service.WalletManageService;
import com.bsd.pjcloud.wallet.service.WalletUserExportService;
import org.springframework.security.access.prepost.PreAuthorize;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springdoc.core.annotations.ParameterObject;
@@ -51,8 +51,8 @@ public class WalletManageController {
private final WalletManageService walletManageService;
private final WalletCodeUseService walletCodeUseService;
/** 钱包管理异步导出服务,负责提交任务并生成可轮询的文件结果。 */
private final WalletManageExportService walletManageExportService;
/** 钱包用户导出服务,统一生成余额和月度记录二级表头文件。 */
private final WalletUserExportService walletUserExportService;
private final WalletManageMapper walletManageMapper;
private final MchDeptViewMapper mchDeptViewMapper;
@@ -371,7 +371,7 @@ public class WalletManageController {
@PostMapping("/export")
@PreAuthorize("@pms.hasPermission('wallet_walletManage_export')")
public R<AsyncTaskProgressVO> export(@RequestBody WalletManageDto dto) {
return R.ok(walletManageExportService.submitExport(dto), "账户余额导出任务已提交");
return R.ok(walletUserExportService.submitWalletBalanceExport(dto), "账户余额导出任务已提交");
}
/**
@@ -383,6 +383,6 @@ public class WalletManageController {
@PostMapping("/export-qb")
// @PreAuthorize("@pms.hasPermission('wallet_walletManage_export')")
public R<AsyncTaskProgressVO> exportQb(@RequestBody WalletManageBalanceDto dto) {
return R.ok(walletManageExportService.submitQbExport(dto), "钱包记录导出任务已提交");
return R.ok(walletUserExportService.submitWalletRecordExport(dto), "钱包记录导出任务已提交");
}
}
@@ -5,6 +5,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.log.annotation.SysLog;
@@ -23,6 +24,8 @@ import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo;
import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo;
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
import com.bsd.pjcloud.wallet.service.WalletTransactionExportScene;
import com.bsd.pjcloud.wallet.service.WalletUserExportService;
import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
@@ -54,6 +57,9 @@ public class WalletTransactionController {
private final WalletTransactionMapper walletTransactionMapper;
/** 钱包用户导出服务,负责异步分片、用户身份补齐和自定义二级表头文件生成。 */
private final WalletUserExportService walletUserExportService;
/* ************************************* start 远程调用 start ************************************* */
@Inner
@PostMapping("/add-balance")
@@ -217,18 +223,16 @@ public class WalletTransactionController {
walletTransactionService.exportCzTotal(response, dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
}
/**
* 使用自定义二级表头同步导出个人充值统计明细,保留旧接口供现有调用方兼容使用。
*
* @param response 当前 HTTP 文件下载响应
* @param dto 页面当前充值金额、时间、标签和部门筛选条件;金额单位为元
*/
@Operation(summary = "后台充值统计按照个人(导出)", description = "后台充值统计按照个人(导出)")
@PostMapping("/export-cz-total-person")
public void exportCzTotalPerson(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(WalletBizEnum.getTopUpCodes());
dto.setType(WalletTransactionDto.TYPE_INCOME);
if (ObjectUtil.isNotEmpty(dto.getPrice())) {
dto.setPrice(dto.getPrice() * 100);
}
if (ObjectUtil.isNotEmpty(dto.getPriceList())) {
dto.setPriceList(dto.getPriceList().stream().map(item -> item * 100).toList());
}
walletTransactionService.exportCzTotalPerson(response, dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.RECHARGE_PERSON_TOTAL);
}
@Operation(summary = "后台充值统计", description = "后台充值统计")
@@ -330,19 +334,30 @@ public class WalletTransactionController {
return R.ok(walletTransactionService.removeBatchByIds(CollUtil.toList(ids)));
}
/**
* 使用自定义二级表头同步导出充值流水,保留旧接口供现有调用方兼容使用。
*
* @param response 当前 HTTP 文件下载响应
* @param dto 页面当前充值流水筛选条件
*/
@Operation(summary = "充值导出", description = "充值导出")
@PostMapping("/export-cz")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportCz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(WalletBizEnum.getTopUpCodes());
dto.setType(WalletTransactionDto.TYPE_INCOME);
walletTransactionService.prepareIdentityQuery(dto);
walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.RECHARGE);
}
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
List<WalletTransactionVo> list = walletTransactionService.scopeList(dto, DataScope.of());
walletTransactionService.exportByTemplate(response, dto, "file/个人充值明细.xlsx", list);
/**
* 提交充值流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。
*
* @param dto 页面当前充值流水筛选条件
* @return 包含任务编号和初始进度的统一响应
*/
@PostMapping("/export-cz-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<AsyncTaskProgressVO> submitCzExport(@RequestBody WalletTransactionDto dto) {
return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.RECHARGE),
"充值记录导出任务已提交");
}
@Operation(summary = "充值统计", description = "充值统计")
@@ -353,19 +368,30 @@ public class WalletTransactionController {
return R.ok(walletTransactionService.walletTypetotal(dto, DataScope.of()));
}
/**
* 使用自定义二级表头同步导出记账扣款流水,保留旧接口供现有调用方兼容使用。
*
* @param response 当前 HTTP 文件下载响应
* @param dto 页面当前记账扣款筛选条件
*/
@Operation(summary = "记账扣款导出", description = "记账扣款导出")
@PostMapping("/export-jz")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportJz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(WalletBizEnum.getTopUpCodes());
dto.setType(WalletTransactionDto.TYPE_EXPENSE);
walletTransactionService.prepareIdentityQuery(dto);
walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.ACCOUNTING);
}
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
List<WalletTransactionVo> list = walletTransactionService.scopeList(dto, DataScope.of());
walletTransactionService.exportByTemplate(response, dto, "file/个人记账扣款明细.xlsx", list);
/**
* 提交记账扣款异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。
*
* @param dto 页面当前记账扣款筛选条件
* @return 包含任务编号和初始进度的统一响应
*/
@PostMapping("/export-jz-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<AsyncTaskProgressVO> submitJzExport(@RequestBody WalletTransactionDto dto) {
return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.ACCOUNTING),
"记账扣款导出任务已提交");
}
@Operation(summary = "记账扣款统计", description = "记账扣款统计")
@@ -378,18 +404,30 @@ public class WalletTransactionController {
}
/**
* 使用自定义二级表头同步导出消费流水,保留旧接口供现有调用方兼容使用。
*
* @param response 当前 HTTP 文件下载响应
* @param dto 页面当前消费流水筛选条件
*/
@Operation(summary = "消费导出", description = "消费导出")
@PostMapping("/export-xf")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportXf(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.PAYMENT.getCode()));
walletTransactionService.prepareIdentityQuery(dto);
walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.CONSUMPTION);
}
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
List<WalletTransactionVo> list = walletTransactionService.scopeList(dto, DataScope.of());
walletTransactionService.exportByTemplate(response, dto, "file/个人消费明细.xlsx", list);
/**
* 提交消费流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。
*
* @param dto 页面当前消费流水筛选条件
* @return 包含任务编号和初始进度的统一响应
*/
@PostMapping("/export-xf-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<AsyncTaskProgressVO> submitXfExport(@RequestBody WalletTransactionDto dto) {
return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.CONSUMPTION),
"消费记录导出任务已提交");
}
@Operation(summary = "消费统计", description = "消费统计")
@@ -400,19 +438,30 @@ public class WalletTransactionController {
return R.ok(walletTransactionService.walletTypetotal(dto, dataScope));
}
/**
* 使用自定义二级表头同步导出退款流水,保留旧接口供现有调用方兼容使用。
*
* @param response 当前 HTTP 文件下载响应
* @param dto 页面当前退款流水筛选条件
*/
@Operation(summary = "退款导出", description = "退款导出")
@PostMapping("/export-tk")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportTk(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.REFUND.getCode()));
dto.setIsFindOrder(true);
walletTransactionService.prepareIdentityQuery(dto);
walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.REFUND);
}
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
List<WalletTransactionVo> list = walletTransactionService.scopeList(dto, DataScope.of());
walletTransactionService.exportByTemplate(response, dto, "file/个人退款明细.xlsx", list);
/**
* 提交退款流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。
*
* @param dto 页面当前退款流水筛选条件
* @return 包含任务编号和初始进度的统一响应
*/
@PostMapping("/export-tk-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<AsyncTaskProgressVO> submitTkExport(@RequestBody WalletTransactionDto dto) {
return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.REFUND),
"退款记录导出任务已提交");
}
@Operation(summary = "退款统计", description = "退款统计")
@@ -424,19 +473,44 @@ public class WalletTransactionController {
return R.ok(walletTransactionService.walletTypetotal(dto, dataScope));
}
/**
* 使用自定义二级表头同步导出食堂退还流水,保留旧接口供现有调用方兼容使用。
*
* @param response 当前 HTTP 文件下载响应
* @param dto 页面当前食堂退还流水筛选条件
*/
@Operation(summary = "后台退还各自食堂记录导出", description = "后台退还各自食堂记录导出")
@PostMapping("/export-th")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportTh(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.RETURN_CANTEEN.getCode()));
dto.setIsFindOrder(false);
walletTransactionService.prepareIdentityQuery(dto);
walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.RETURN_CANTEEN);
}
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
/**
* 提交食堂退还流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。
*
* @param dto 页面当前食堂退还流水筛选条件
* @return 包含任务编号和初始进度的统一响应
*/
@PostMapping("/export-th-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<AsyncTaskProgressVO> submitThExport(@RequestBody WalletTransactionDto dto) {
return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.RETURN_CANTEEN),
"食堂退还记录导出任务已提交");
}
List<WalletTransactionVo> list = walletTransactionService.scopeList(dto, DataScope.of());
walletTransactionService.exportByTemplate(response, dto, "file/退还各自食堂明细.xlsx", list);
/**
* 提交个人充值统计异步分片导出任务,并保留历史金额及排除部门统计口径。
*
* @param dto 页面当前充值金额、时间、标签和部门筛选条件;金额单位为元
* @return 包含任务编号和初始进度的统一响应
*/
@PostMapping("/export-cz-total-person-async")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public R<AsyncTaskProgressVO> submitCzTotalPersonExport(@RequestBody WalletTransactionDto dto) {
return R.ok(walletUserExportService.submitTransactionExport(dto,
WalletTransactionExportScene.RECHARGE_PERSON_TOTAL),
"个人充值统计导出任务已提交");
}
@Operation(summary = "退还各自食堂统计", description = "退还各自食堂统计")
@@ -1,31 +1,19 @@
package com.bsd.pjcloud.wallet.controller.recharge;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.exception.ServiceException;
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.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeImportMapper;
import com.bsd.pjcloud.wallet.service.WalletRechargeImportService;
import com.bsd.pjcloud.wallet.service.WalletUserExportService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springdoc.core.annotations.ParameterObject;
@@ -33,15 +21,9 @@ import org.springframework.http.HttpHeaders;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 充值导入表
*
@@ -56,7 +38,9 @@ import java.util.Map;
public class WalletRechargeImportController {
private final WalletRechargeImportService walletRechargeImportService;
private final WalletRechargeImportMapper walletRechargeImportMapper;
/** 钱包用户导出服务,负责充值水单的异步分片和用户身份补齐。 */
private final WalletUserExportService walletUserExportService;
@Operation(summary = "恢复", description = "恢复")
@Idempotent(expireTime = 5, info = "正在处理中,请勿重复操作")
@@ -112,51 +96,28 @@ public class WalletRechargeImportController {
return R.ok(walletRechargeImportService.removeBatchByIds(CollUtil.toList(ids)));
}
/**
* 使用自定义二级表头同步导出充值扣款水单,保留旧接口供现有调用方兼容使用。
*
* @param response 当前 HTTP 文件下载响应
* @param dto 页面当前水单、审批、用户身份及部门筛选条件
*/
@ResponseExcel
@PostMapping("/export")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeImport_export')")
public void export(HttpServletResponse response, @RequestBody WalletRechargeImportDto dto) throws IOException {
DataScope dataScope = DataScope.of();
walletRechargeImportService.prepareIdentityQuery(dto);
Long count = walletRechargeImportMapper.count(dto, dataScope);
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
List<WalletRechargeImportVo> list = walletRechargeImportService.scopeList(dto, dataScope);
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/批量充值扣款流水明细.xlsx")).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
Map<String, Object> map = new HashMap<String, Object>();
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
map.put("timeSpan", " 到 ");
map.put("startTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
map.put("endTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
}
if (ObjectUtil.isNotEmpty(list)) {
BigDecimal priceStrSum = BigDecimal.ZERO;
Long lineNumber = 1L;
for (WalletRechargeImportVo v : list) {
v.setLineNumber(lineNumber);
lineNumber++;
if (ObjectUtil.isNotEmpty(v.getPrice())) {
if (WalletBizRechargeEnum.reductionCodes().contains(v.getBizType()) && v.getPrice() > 0) {
v.setPrice(v.getPrice() * -1);
}
priceStrSum = priceStrSum.add(BigDecimal.valueOf(v.getPrice()));
v.setPriceStr(BigDecimal.valueOf(v.getPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
}
if (ObjectUtil.isNotEmpty(v.getImportTime())) {
v.setImportTimeStr(LocalDateTimeUtil.format(v.getImportTime(), DatePattern.NORM_MONTH_PATTERN));
}
if (ObjectUtil.isNotEmpty(v.getBizType())) {
v.setBizType(WalletBizRechargeEnum.valueOfByType(v.getBizType()).getDesc());
}
if (ObjectUtil.isNotEmpty(v.getWalletTypeUniqueCode())) {
v.setWalletTypeUniqueCode(WalletTypeEnum.valueOfByCode(v.getWalletTypeUniqueCode()).getDesc());
}
}
map.put("priceStrSum", priceStrSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
}
excelWriter.fill(map, writeSheet);
excelWriter.fill(list, fillConfig, writeSheet);
}
public void export(HttpServletResponse response, @RequestBody WalletRechargeImportDto dto) {
walletUserExportService.exportRechargeImport(response, dto);
}
/**
* 提交充值扣款水单异步分片导出任务,由后台补齐用户身份并实时上报生成进度。
*
* @param dto 页面当前水单、审批、用户身份及部门筛选条件
* @return 包含任务编号和初始进度的统一响应
*/
@PostMapping("/export-async")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeImport_export')")
public R<AsyncTaskProgressVO> submitExport(@RequestBody WalletRechargeImportDto dto) {
return R.ok(walletUserExportService.submitRechargeImportExport(dto), "充值扣款水单导出任务已提交");
}
}
@@ -25,6 +25,12 @@ import java.util.List;
@Mapper
public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity> {
/**
* 构建钱包账户通用筛选条件,并以钱包主键作为最终排序条件保证分片导出顺序稳定。
*
* @param dto 钱包账户、用户、部门和身份预筛选后的查询条件
* @return 已应用业务筛选和稳定排序的钱包查询包装器
*/
private LambdaQueryWrapper<WalletManageEntity> commonQuery(WalletManageDto dto) {
LambdaQueryWrapper<WalletManageEntity> wrapper = new LambdaQueryWrapper<WalletManageEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getUserId()), WalletManageEntity::getUserId, dto.getUserId())
@@ -43,7 +49,8 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
)
.orderByDesc(WalletManageEntity::getCreateTime)
.orderByDesc(WalletManageEntity::getUserId)
.orderByAsc(WalletManageEntity::getWalletTypeId);
.orderByAsc(WalletManageEntity::getWalletTypeId)
.orderByDesc(WalletManageEntity::getId);
if (ObjectUtil.isNotEmpty(dto.getIsInclude())) {
if (dto.getIsInclude()) {
wrapper.in(ObjectUtil.isNotEmpty(dto.getDeptIds()), WalletManageEntity::getDeptId, dto.getDeptIds());
@@ -29,6 +29,12 @@ import java.util.Objects;
@Mapper
public interface WalletTransactionMapper extends PjcloudBaseMapper<WalletTransactionEntity> {
/**
* 构建钱包流水通用筛选条件;排序末尾使用流水主键保证深分页期间顺序稳定。
*
* @param dto 钱包流水筛选条件
* @return 已应用业务、用户、部门、时间和标签条件的查询包装器
*/
default LambdaQueryWrapper<WalletTransactionEntity> commonWrapperQuery(WalletTransactionDto dto) {
LambdaQueryWrapper<WalletTransactionEntity> wrapper = new LambdaQueryWrapper<WalletTransactionEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getWalletId()), WalletTransactionEntity::getWalletId, dto.getWalletId())
@@ -44,7 +50,8 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper<WalletTransac
ObjectUtil.isNotEmpty(dto.getCreateTimeList()) ? dto.getCreateTimeList()[1] : null
)
.orderByDesc(WalletTransactionEntity::getCreateTime)
.orderByDesc(WalletTransactionEntity::getUserId);
.orderByDesc(WalletTransactionEntity::getUserId)
.orderByDesc(WalletTransactionEntity::getId);
if (ObjectUtil.isNotEmpty(dto.getIsInclude())) {
if (dto.getIsInclude()) {
wrapper.in(ObjectUtil.isNotEmpty(dto.getDeptIds()), WalletTransactionEntity::getDeptId,
@@ -84,6 +91,27 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper<WalletTransac
return list;
}
/**
* 按钱包流水通用条件分页查询个人充值统计导出数据,并排除固定非统计部门。
*
* @param page 分页参数;仅第一页由调用方开启总数查询
* @param dto 已冻结用户身份和个人充值统计筛选条件
* @param dataScope 当前操作者的数据权限范围
* @param excludedDeptIds 个人充值统计固定排除的部门主键
* @return 携带流水基础字段的导出分页,记录顺序与列表查询保持一致
*/
default Page<WalletTransactionVo> scopeRechargePersonExportPage(Page page, WalletTransactionDto dto,
DataScope dataScope, List<Long> excludedDeptIds) {
LambdaQueryWrapper<WalletTransactionEntity> wrapper = commonWrapperQuery(dto)
.eq(ObjectUtil.isNotEmpty(dto.getPrice()), WalletTransactionEntity::getPrice, dto.getPrice())
.in(ObjectUtil.isNotEmpty(dto.getPriceList()), WalletTransactionEntity::getPrice, dto.getPriceList())
.notIn(excludedDeptIds != null && !excludedDeptIds.isEmpty(),
WalletTransactionEntity::getDeptId, excludedDeptIds);
Page list = selectPageByScope(page, wrapper, dataScope);
list.setRecords(BeanUtils.toBean(list.getRecords(), WalletTransactionVo.class));
return list;
}
WalletTypeCountVo walletTypetotal(@Param("dto") WalletTransactionDto dto, @Param("dataScope") DataScope dataScope);
List<WalletTypeCountVo> walletTypetotalByDept(@Param("dto") WalletTransactionDto dto, @Param("dataScope") DataScope dataScope);
@@ -16,6 +16,14 @@ import java.util.List;
@Mapper
public interface WalletRechargeImportMapper extends PjcloudBaseMapper<WalletRechargeImportEntity> {
/**
* 按数据权限分页查询充值扣款水单,XML 使用创建时间和水单主键倒序保证分片顺序稳定。
*
* @param page 分页参数;异步导出仅在第一页查询总数
* @param dto 水单、审批、用户和部门筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 已关联充值申请字段的水单分页结果
*/
Page<WalletRechargeImportVo> scopePageList(Page page, @Param("dto") WalletRechargeImportDto dto,
DataScope dataScope);
@@ -82,12 +82,35 @@ public interface WalletManageService extends IService<WalletManageEntity> {
Page<WalletManageVo> scopePageList(Page page, WalletManageDto dto, DataScope dataScope);
/**
* 分页查询账户余额导出所需的钱包业务资料。
*
* <p>调用方必须先冻结身份筛选。本方法保留数据权限、部门和钱包类型装配,但不执行普通列表的
* UPMS 用户身份回填,避免统一导出服务在同一分片重复查询用户。</p>
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份的账户余额查询条件
* @param dataScope 当前操作者的数据权限范围
* @return 不重复查询用户资料的钱包分页
*/
Page<WalletManageVo> scopeExportPageList(Page page, WalletManageDto dto, DataScope dataScope);
Page<WalletManageVo> appPageList(PjcloudUser user, Page page, WalletManageDto dto, DataScope dataScope);
List<WalletManageVo> scopeList(WalletManageDto dto, DataScope dataScope);
Page<WalletManageVo> scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope);
/**
* 分页计算月末钱包余额并装配导出所需业务资料。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份且包含统计月份的钱包记录查询条件
* @param dataScope 当前操作者的数据权限范围
* @return 不重复查询用户资料的月度钱包记录分页
*/
Page<WalletManageVo> scopeQbExportPage(Page page, WalletManageBalanceDto dto, DataScope dataScope);
WalletQbTotalVo qbTotal(WalletManageBalanceDto dto, DataScope dataScope);
/**
@@ -25,6 +25,19 @@ public interface WalletRechargeImportService extends IService<WalletRechargeImpo
Page<WalletRechargeImportVo> scopePageList(Page page, WalletRechargeImportDto dto, DataScope dataScope);
/**
* 分页查询统一导出所需的充值扣款水单业务资料。
*
* <p>调用方必须先冻结身份筛选。本方法保留数据权限、审批关联字段和钱包类型装配,但不执行普通
* 列表的 UPMS 用户身份回填,完整用户资料由统一导出服务按同一分片一次性查询。</p>
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份的水单筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 不重复查询用户资料的水单分页
*/
Page<WalletRechargeImportVo> scopeExportPageList(Page page, WalletRechargeImportDto dto, DataScope dataScope);
List<WalletRechargeImportVo> scopeList(WalletRechargeImportDto dto, DataScope dataScope);
Boolean recover(Long id);
@@ -0,0 +1,107 @@
package com.bsd.pjcloud.wallet.service;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import java.util.List;
/**
* 钱包流水导出场景定义。
*
* <p>各场景固定 Redis 命名空间、任务名称、文件名和流水筛选条件;这些编码属于前后端异步进度协议,
* 已发布后不得随意修改。</p>
*/
public enum WalletTransactionExportScene {
/** 个人充值明细,限定充值类业务和正向收入流水。 */
RECHARGE("wallet:user:export:cz", "充值记录导出", "充值记录", List.copyOf(WalletBizEnum.getTopUpCodes()),
WalletTransactionDto.TYPE_INCOME, false),
/** 个人记账扣款明细,限定充值类业务和负向支出流水。 */
ACCOUNTING("wallet:user:export:jz", "记账扣款导出", "记账扣款记录", List.copyOf(WalletBizEnum.getTopUpCodes()),
WalletTransactionDto.TYPE_EXPENSE, false),
/** 充值统计页面的个人充值明细,沿用页面金额、时间、标签和部门筛选。 */
RECHARGE_PERSON_TOTAL("wallet:user:export:cz-total", "个人充值统计导出", "个人充值统计",
List.copyOf(WalletBizEnum.getTopUpCodes()), WalletTransactionDto.TYPE_INCOME, false),
/** 个人消费明细,限定钱包支付流水并保留关联订单号。 */
CONSUMPTION("wallet:user:export:xf", "消费记录导出", "消费记录",
List.of(WalletBizEnum.PAYMENT.getCode()), null, true),
/** 个人退款明细,限定钱包退款流水并保留关联订单号。 */
REFUND("wallet:user:export:tk", "退款记录导出", "退款记录",
List.of(WalletBizEnum.REFUND.getCode()), null, true),
/** 各自食堂退还明细,限定食堂退还业务且不解析为订单。 */
RETURN_CANTEEN("wallet:user:export:th", "食堂退还记录导出", "食堂退还记录",
List.of(WalletBizEnum.RETURN_CANTEEN.getCode()), null, false);
/** 前端进度轮询使用的固定 Redis Key 业务命名空间。 */
private final String redisKey;
/** 异步进度弹窗展示的任务名称。 */
private final String taskName;
/** 导出文件和工作表使用的业务名称,不包含时间戳和扩展名。 */
private final String fileNamePrefix;
/** 当前场景允许查询的钱包流水业务编码集合。 */
private final List<String> bizTypes;
/** 收支方向编码;为空表示不额外限制金额正负。 */
private final Integer transactionType;
/** 是否将关联业务编号按订单号语义补充到分页展示对象。 */
private final boolean findOrder;
/**
* 创建固定钱包流水导出场景。
*
* @param redisKey 前端进度轮询使用的固定命名空间
* @param taskName 异步任务展示名称
* @param fileNamePrefix 导出文件和工作表业务名称
* @param bizTypes 钱包流水业务编码集合
* @param transactionType 收支方向编码,允许为空
* @param findOrder 是否解析关联订单号
*/
WalletTransactionExportScene(String redisKey, String taskName, String fileNamePrefix, List<String> bizTypes,
Integer transactionType, boolean findOrder) {
this.redisKey = redisKey;
this.taskName = taskName;
this.fileNamePrefix = fileNamePrefix;
this.bizTypes = bizTypes;
this.transactionType = transactionType;
this.findOrder = findOrder;
}
/** @return 前端进度轮询使用的固定 Redis Key 业务命名空间 */
public String getRedisKey() {
return redisKey;
}
/** @return 异步进度弹窗展示的任务名称 */
public String getTaskName() {
return taskName;
}
/** @return 导出文件和工作表使用的业务名称 */
public String getFileNamePrefix() {
return fileNamePrefix;
}
/** @return 当前场景允许查询的钱包流水业务编码集合 */
public List<String> getBizTypes() {
return bizTypes;
}
/** @return 收支方向编码;为空表示不限制金额正负 */
public Integer getTransactionType() {
return transactionType;
}
/** @return 是否按订单语义解析关联业务编号 */
public boolean isFindOrder() {
return findOrder;
}
}
@@ -26,6 +26,31 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
Page<WalletTransactionVo> scopePageList(Page page, WalletTransactionDto dto, DataScope dataScope);
/**
* 分页查询统一导出所需的钱包流水业务资料。
*
* <p>调用方必须先冻结身份筛选。本方法保留数据权限、部门、钱包和订单号装配,但不调用普通列表的
* UPMS 用户回填,完整用户、岗位和身份资料由统一导出服务在同一分片内一次性查询。</p>
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份和固定业务场景的流水查询条件
* @param dataScope 当前操作者的数据权限范围
* @return 不重复查询用户资料的流水导出分页
*/
Page<WalletTransactionVo> scopeExportPageList(Page page, WalletTransactionDto dto, DataScope dataScope);
/**
* 分页查询个人充值统计导出流水,并沿用流水列表的数据权限和钱包资料装配。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份、充值金额和部门筛选条件
* @param dataScope 当前操作者的数据权限范围
* @param excludedDeptIds 统计场景固定排除的部门主键
* @return 携带部门、钱包类型和卡号业务资料且不重复查询用户资料的分页结果
*/
Page<WalletTransactionVo> scopeRechargePersonExportPage(Page page, WalletTransactionDto dto,
DataScope dataScope, List<Long> excludedDeptIds);
/**
* 分页查询登录用户本人或其有效被监护人的 EAT 饭卡流水。
*
@@ -0,0 +1,68 @@
package com.bsd.pjcloud.wallet.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto;
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto;
import jakarta.servlet.http.HttpServletResponse;
/**
* 钱包用户数据统一导出服务。
*
* <p>负责冻结页面筛选与数据权限、按页读取钱包业务数据、批量补齐 UPMS 用户及身份资料、流式写入
* EasyExcel 二级表头文件,并通过通用异步任务协议实时上报进度。</p>
*/
public interface WalletUserExportService {
/**
* 提交钱包流水异步分片导出任务。
*
* @param dto 页面当前流水筛选条件
* @param scene 固定流水导出场景
* @return 供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitTransactionExport(WalletTransactionDto dto, WalletTransactionExportScene scene);
/**
* 使用自定义二级表头执行兼容旧调用方的钱包流水同步导出。
*
* @param response 当前 HTTP 下载响应
* @param dto 页面当前流水筛选条件
* @param scene 固定流水导出场景
*/
void exportTransaction(HttpServletResponse response, WalletTransactionDto dto,
WalletTransactionExportScene scene);
/**
* 提交账户当前余额异步分片导出任务。
*
* @param dto 账户余额筛选条件
* @return 供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitWalletBalanceExport(WalletManageDto dto);
/**
* 提交指定月份钱包余额异步分片导出任务。
*
* @param dto 月度钱包余额筛选条件
* @return 供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitWalletRecordExport(WalletManageBalanceDto dto);
/**
* 提交批量充值扣款水单异步分片导出任务。
*
* @param dto 充值水单筛选条件
* @return 供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitRechargeImportExport(WalletRechargeImportDto dto);
/**
* 使用自定义二级表头执行兼容旧调用方的充值水单同步导出。
*
* @param response 当前 HTTP 下载响应
* @param dto 充值水单筛选条件
*/
void exportRechargeImport(HttpServletResponse response, WalletRechargeImportDto dto);
}
@@ -269,6 +269,21 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
return list;
}
/**
* 分页查询账户余额导出业务资料,并跳过普通列表的 UPMS 用户身份回填。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份的账户余额查询条件
* @param dataScope 当前操作者的数据权限范围
* @return 已装配部门和钱包类型的钱包分页
*/
@Override
public Page<WalletManageVo> scopeExportPageList(Page page, WalletManageDto dto, DataScope dataScope) {
Page<WalletManageVo> list = baseMapper.scopePage(page, dto, dataScope);
disposeExportList(list.getRecords());
return list;
}
@Override
public Page<WalletManageVo> appPageList(PjcloudUser user, Page page, WalletManageDto dto, DataScope dataScope) {
Page<WalletManageVo> list = baseMapper.scopePage(page, dto, dataScope);
@@ -332,10 +347,38 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
@Override
public Page<WalletManageVo> scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) {
return queryWalletRecordPage(page, dto, dataScope, true);
}
/**
* 分页计算月末钱包余额导出数据,并把完整用户资料留给统一导出服务装配。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份且包含统计月份的钱包记录查询条件
* @param dataScope 当前操作者的数据权限范围
* @return 已计算月末余额并装配部门、钱包类型的分页
*/
@Override
public Page<WalletManageVo> scopeQbExportPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) {
return queryWalletRecordPage(page, dto, dataScope, false);
}
/**
* 执行普通列表和统一导出共用的月末钱包余额分页计算。
*
* @param page 分页参数
* @param dto 钱包记录月份及用户、部门、钱包类型筛选条件
* @param dataScope 当前操作者的数据权限范围
* @param fillUserInfo 是否按普通列表协议回填 UPMS 用户和身份资料
* @return 已计算指定月份末余额的钱包分页
*/
private Page<WalletManageVo> queryWalletRecordPage(Page page, WalletManageBalanceDto dto,
DataScope dataScope, boolean fillUserInfo) {
WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class);
prepareIdentityQuery(walletDto);
Page<WalletManageVo> walletPage = baseMapper.scopePage(page, walletDto, dataScope);
if (walletPage.getTotal() == 0) return walletPage;
// 导出第二批起会关闭总数查询,此时 total 固定为零,只能按当前批记录判断是否为空。
if (ObjectUtil.isEmpty(walletPage.getRecords())) return walletPage;
walletPage.getRecords().forEach(v -> {
v.setBalance(0);
v.setTotalRecharge(0);
@@ -363,7 +406,11 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
});
}
}
disposeList(walletPage.getRecords());
if (fillUserInfo) {
disposeList(walletPage.getRecords());
} else {
disposeExportList(walletPage.getRecords());
}
return walletPage;
}
@@ -581,10 +628,27 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
return mchTakeOutSettingMapper.selectById(dto.getId());
}
/**
* 装配普通钱包列表的用户、身份及业务展示资料。
*
* @param list 当前钱包分页记录
*/
private void disposeList(List<WalletManageVo> list) {
if (ObjectUtil.isEmpty(list)) {
return;
}
// 普通列表保持既有用户资料契约,导出分页由专用导出服务获取岗位等完整资料。
userIdentitySupport.fillUserInfo(list, true);
disposeExportList(list);
}
/**
* 装配账户余额与月度钱包导出共用的部门和钱包类型资料。
*
* @param list 当前钱包分页记录;为空时不执行远程或数据库查询
*/
private void disposeExportList(List<WalletManageVo> list) {
if (ObjectUtil.isNotEmpty(list)) {
// 用户及身份:必须通过带数据权限的 UPMS 基础用户分页接口批量回填。
userIdentitySupport.fillUserInfo(list, true);
// 部门
List<Long> deptIds = list.stream().map(WalletManageVo::getDeptId).distinct().toList();
R<Map<Long, SysDept>> deptList = deptService.getDeptByDeptIds(deptIds);
@@ -885,6 +885,40 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
return list;
}
/**
* 分页查询统一导出所需的流水业务资料,并跳过普通列表的 UPMS 用户回填。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份和业务场景的流水查询条件
* @param dataScope 当前操作者的数据权限范围
* @return 已装配部门、钱包、钱包类型及订单号的流水分页
*/
@Override
public Page<WalletTransactionVo> scopeExportPageList(Page page, WalletTransactionDto dto,
DataScope dataScope) {
Page<WalletTransactionVo> list = baseMapper.scopePageList(page, dto, dataScope);
disposeExportList(list.getRecords(), dto);
return list;
}
/**
* 分页查询个人充值统计导出流水,并复用导出分页的部门、钱包和钱包类型装配逻辑。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份和充值统计筛选条件
* @param dataScope 当前操作者的数据权限范围
* @param excludedDeptIds 统计场景固定排除的部门主键
* @return 不重复查询 UPMS 用户资料的个人充值统计导出分页
*/
@Override
public Page<WalletTransactionVo> scopeRechargePersonExportPage(Page page, WalletTransactionDto dto,
DataScope dataScope, List<Long> excludedDeptIds) {
Page<WalletTransactionVo> list = baseMapper.scopeRechargePersonExportPage(page, dto, dataScope,
excludedDeptIds);
disposeExportList(list.getRecords(), dto);
return list;
}
/**
* 分页查询本人或有效被监护人的 EAT 饭卡流水。目标用户与钱包均由服务端重新解析,
* 避免客户端通过篡改用户 ID、用户集合或钱包 ID 越权读取其他账户流水。
@@ -1196,10 +1230,29 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
}
}
/**
* 装配普通钱包流水列表的用户、身份及业务展示资料。
*
* @param list 当前列表分页记录
* @param dto 当前流水查询条件,用于判断是否解析订单号
*/
private void disposeList(List<WalletTransactionVo> list, WalletTransactionDto dto) {
if (ObjectUtil.isEmpty(list)) {
return;
}
// 普通列表仍由统一身份支持器回填;导出分片改由专用导出服务一次性获取更完整的用户资料。
userIdentitySupport.fillUserInfo(list, true);
disposeExportList(list, dto);
}
/**
* 装配钱包流水导出与普通列表共用的部门、钱包、钱包类型及订单号资料。
*
* @param list 当前分页记录;为空时不执行远程或数据库查询
* @param dto 当前流水查询条件,用于判断是否解析订单号
*/
private void disposeExportList(List<WalletTransactionVo> list, WalletTransactionDto dto) {
if (ObjectUtil.isNotEmpty(list)) {
// 用户及身份:必须通过带数据权限的 UPMS 基础用户分页接口批量回填。
userIdentitySupport.fillUserInfo(list, true);
// 部门
List<Long> deptIds = list.stream().map(WalletTransactionVo::getDeptId).distinct().toList();
R<Map<Long, SysDept>> deptList = deptService.getDeptByDeptIds(deptIds);
@@ -88,6 +88,22 @@ public class WalletRechargeImportServiceImpl extends ServiceImpl<WalletRechargeI
return list;
}
/**
* 分页查询水单导出业务资料,并跳过普通列表的 UPMS 用户身份回填。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份的水单筛选条件
* @param dataScope 当前操作者的数据权限范围
* @return 已装配审批关联字段和钱包类型的水单分页
*/
@Override
public Page<WalletRechargeImportVo> scopeExportPageList(Page page, WalletRechargeImportDto dto,
DataScope dataScope) {
Page<WalletRechargeImportVo> list = baseMapper.scopePageList(page, dto, dataScope);
disposeExportList(list.getRecords());
return list;
}
@Override
public List<WalletRechargeImportVo> scopeList(WalletRechargeImportDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
@@ -118,10 +134,28 @@ public class WalletRechargeImportServiceImpl extends ServiceImpl<WalletRechargeI
return true;
}
/**
* 装配普通充值扣款水单列表的用户、身份和钱包类型资料。
*
* @param list 当前水单分页记录
* @param dto 当前水单查询条件,保留既有私有方法调用契约
*/
private void disposeList(List<WalletRechargeImportVo> list, WalletRechargeImportDto dto) {
if (ObjectUtil.isEmpty(list)) {
return;
}
// 普通列表保持既有用户资料契约,导出分页由专用导出服务获取岗位等完整资料。
userIdentitySupport.fillUserInfo(list, true);
disposeExportList(list);
}
/**
* 装配水单列表和统一导出共用的钱包类型资料。
*
* @param list 当前水单分页记录;为空时不查询钱包类型
*/
private void disposeExportList(List<WalletRechargeImportVo> list) {
if (ObjectUtil.isNotEmpty(list)) {
// 用户及身份:必须通过带数据权限的 UPMS 基础用户分页接口批量回填。
userIdentitySupport.fillUserInfo(list, true);
// 饭卡类型
List<Long> walletTypeId = list.stream().map(WalletRechargeImportVo::getWalletTypeId).distinct().toList();
if (ObjectUtil.isNotEmpty(walletTypeId)) {
@@ -110,7 +110,7 @@
and p.process_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]}
</if>
</where>
order by a.create_time desc
order by a.create_time desc, a.id desc
</sql>
<select id="scopePageList" resultType="com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo">