Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-08-28 10:43:03 +08:00
commit 87bec4ba73
28 files changed
+1775 -263

No files matched your search

@@ -10,9 +10,11 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 硬件售后订单后台查询条件,订单侧字段仅用于关联查询,不对应售后表持久化列。 */
@Data
public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface {
/** 售后申请用户主键集合;为空时不限制申请人。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
@@ -27,8 +29,22 @@ public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIde
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
/** 售后申请日期闭区间;为空时不限制申请日期。 */
private LocalDate[] createTimeList;
/** 当前账号数据范围内允许查询的订单商户主键集合;为空时不限制商户。 */
private List<Long> mchIds;
/** 关联订单的状态字典编码集合;为空时不限制订单状态。 */
private List<String> orderStatusCodes;
/** 关联订单的类型字典编码;为空时不限制订单类型。 */
private String orderType;
/** 关联订单的用餐餐段字典编码集合;为空时不限制餐段。 */
private List<String> hourTypeCodes;
/** 关联订单预计使用日期闭区间,页面按开始日零点至结束日最后一秒传入。 */
private LocalDateTime[] apptUseDateTimeList;
}
@@ -0,0 +1,25 @@
package com.bsd.pjcloud.hard.api.dto.order;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 后台批量退款预览请求,只接收列表页已经勾选的订单主键。
*/
@Data
@Schema(description = "后台批量退款预览请求")
public class HardBatchRefundPreviewDto {
/**
* 待预览的订单主键,服务端会去重并重新校验数据权限和退款资格,单次最多 1000 笔。
*/
@NotEmpty(message = "请选择需要退款的订单")
@Size(max = 1000, message = "单次最多选择1000个订单")
@Schema(description = "待退款订单主键,单次最多1000笔")
private List<@NotNull(message = "订单id不能为空") Long> orderIds;
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.hard.api.dto.order;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 后台批量退款提交请求,在预览订单集合之外补充本批次统一备注。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "后台批量退款提交请求")
public class HardBatchRefundSubmitDto extends HardBatchRefundPreviewDto {
/**
* 本批次退款备注,同时写入每笔售后单的申请原因和补充描述,去除首尾空白后不能为空。
*/
@NotBlank(message = "请输入退款备注")
@Size(max = 200, message = "退款备注不能超过200个字符")
@Schema(description = "本批次统一退款备注,最多200个字符")
private String remark;
}
@@ -1,8 +1,21 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* App 售后进度记录,在持久化日志之外补充当前节点对应的退款原因。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "App 售后进度记录")
public class HardAfterSaleLogVo extends HardAfterSaleLogEntity {
/**
* 当前进度节点对应的退款或拒绝原因;无可展示原因时为空,不写入售后日志表。
*/
@Schema(description = "当前进度节点对应的退款原因")
private String reason;
}
@@ -11,6 +11,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -101,6 +102,11 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte
private LocalDateTime orderPayTime;
/**
* 原订单预约用餐日期,来源于硬件订单已有的预约日期字段;非预约订单为空,不对应售后表持久化字段。
*/
private LocalDate orderApptUseDate;
/**
* 当前售后单申请时保存的商品快照,用于审批详情展示本次退款商品及份数;历史未保存明细时为空列表。
*/
@@ -0,0 +1,156 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 后台批量退款预览结果,按订单展示服务端实时核定的剩余可退商品和金额。
*/
@Data
@Schema(description = "后台批量退款预览结果")
public class HardBatchRefundPreviewVo {
/**
* 当前预览范围的订单、人员、商品和金额汇总,不包含无权限或不存在的订单。
*/
@Schema(description = "批量退款汇总")
private Summary summary;
/**
* 按服务端订单排序返回的预览明细;不可退款订单保留原因但不会进入实际退款任务。
*/
@Schema(description = "批量退款订单明细")
private List<OrderItem> orders;
/**
* 批量退款预览汇总,数量和金额仅累计当前仍可退款的订单。
*/
@Data
@Schema(description = "批量退款预览汇总")
public static class Summary {
/** 当前请求中通过数据权限校验的订单数量。 */
@Schema(description = "预览订单数")
private Integer selectedOrderCount;
/** 当前校验通过并会进入退款任务的订单数量。 */
@Schema(description = "可退款订单数")
private Integer refundableOrderCount;
/** 因状态、渠道或剩余金额不满足要求而跳过的订单数量。 */
@Schema(description = "不可退款订单数")
private Integer unavailableOrderCount;
/** 可退款订单中的去重订餐人数,未知用户不计入。 */
@Schema(description = "订餐人数")
private Integer userCount;
/** 可退款订单中按商品主键去重后的菜品种类数。 */
@Schema(description = "菜品种类数")
private Integer productKindCount;
/** 可退款数量商品的剩余总份数,不包含称重商品。 */
@Schema(description = "剩余可退商品总份数")
private Long productCount;
/** 可退款称重商品的剩余总重量,单位沿用购物车重量协议。 */
@Schema(description = "剩余可退商品总重量")
private BigDecimal productWeight;
/** 可退款订单的剩余可退总金额,单位为分。 */
@Schema(description = "剩余可退总金额,单位分")
private Long refundPrice;
}
/**
* 单笔订单的批量退款预览信息。
*/
@Data
@Schema(description = "批量退款订单预览明细")
public static class OrderItem {
/** Hardware 订单主键,也是异步任务逐笔退款的稳定业务标识。 */
@Schema(description = "订单主键")
private Long orderId;
/** 订单业务编号;历史数据可能为空。 */
@Schema(description = "订单业务编号")
private String orderNo;
/** 优先取预约用餐日期,其次取支付日期,最后取订单创建日期。 */
@Schema(description = "退款预览业务日期")
private LocalDate businessDate;
/** 用餐餐段字典编码;非餐饮或历史订单可能为空。 */
@Schema(description = "用餐餐段字典编码")
private String hourType;
/** 实际订餐用户主键;匿名或历史订单可能为空。 */
@Schema(description = "订餐用户主键")
private Long userId;
/** 实际订餐人姓名;无法关联用户资料时为空。 */
@Schema(description = "订餐人姓名")
private String username;
/** 订餐人下单时关联部门名称;历史数据可能为空。 */
@Schema(description = "订餐人部门名称")
private String userDeptName;
/** 订单所属商户名称;无法关联商户资料时为空。 */
@Schema(description = "商户名称")
private String merchantName;
/** 当前已完成退款后剩余可退金额,单位为分。 */
@Schema(description = "剩余可退金额,单位分")
private Integer refundPrice;
/** 是否通过当前批量退款资格校验。 */
@Schema(description = "是否可以退款")
private Boolean refundable;
/** 不可退款原因;可退款时为空。 */
@Schema(description = "不可退款原因")
private String unavailableReason;
/** 当前订单扣除待审核和已完成售后占用后的剩余商品。 */
@Schema(description = "剩余可退商品")
private List<ProductItem> products;
}
/**
* 单个商品的剩余可退数量、重量和商品金额。
*/
@Data
@Schema(description = "批量退款商品预览明细")
public static class ProductItem {
/** 原订单购物车快照中的商品主键。 */
@Schema(description = "商品主键")
private Long productId;
/** 原订单购物车快照中的商品名称,不回查可能已改名的商品主数据。 */
@Schema(description = "商品名称")
private String productName;
/** 商品性质字典编码,用于区分数量商品和称重商品。 */
@Schema(description = "商品性质字典编码")
private String productNature;
/** 数量商品的剩余可退份数;称重商品固定为零。 */
@Schema(description = "剩余可退份数")
private Integer productCount;
/** 称重商品的剩余可退重量;数量商品固定为零。 */
@Schema(description = "剩余可退重量")
private BigDecimal productWeight;
/** 原购物车商品总价扣除有效售后占用后的剩余可退金额,单位为分。 */
@Schema(description = "剩余可退商品金额,单位分")
private Long refundPrice;
}
}
@@ -21,6 +21,10 @@ public class HardCanteenUserApptDishStatisticsVo {
@Schema(description = "菜品名称")
private String productName;
/** 当前菜品统计项实际涉及的商品 ID;正常数据仅有一个,历史商品 ID 为空时不写入集合。 */
@Schema(description = "商品ID集合")
private List<Long> productIds = new ArrayList<>();
/** 下单时固化的菜品图片地址;历史订单未保存图片时为空。 */
@Schema(description = "菜品图片地址")
private String productPic;
@@ -47,7 +47,7 @@ public class HardCanteenUserApptStatisticsSummaryVo {
@Schema(description = "餐段统计")
private List<HardCanteenUserApptMealStatisticsVo> meals = new ArrayList<>();
/** 按餐段和菜品名称聚合的菜品份数。 */
/** 按餐段和商品 ID 聚合的菜品份数;历史商品 ID 缺失时按菜品名称兜底。 */
@Schema(description = "菜品统计")
private List<HardCanteenUserApptDishStatisticsVo> dishes = new ArrayList<>();
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order;
import cn.hutool.core.util.ObjectUtil;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.collection.CollUtil;
@@ -19,9 +20,12 @@ import com.bsd.pjcloud.hard.api.dto.order.*;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardBatchRefundService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -51,6 +55,9 @@ public class HardAfterSaleController {
private final HardAfterSaleService hardAfterSaleService;
/** 批量退款服务,负责权限预览和最多 1000 笔订单的异步逐笔退款。 */
private final HardBatchRefundService hardBatchRefundService;
private final RemoteMchService remoteMchService;
/**
@@ -99,12 +106,18 @@ public class HardAfterSaleController {
return R.ok(hardAfterSaleService.cancelAfterSale(user.getId(), id));
}
/**
* 查询当前 App 用户可见的售后进度,并在对应业务节点返回退款原因。
*
* @param id 售后单 ID
* @return 带节点退款原因的售后进度
*/
@ApiCryptoAes
@GetMapping(value = "/app/history")
@Operation(summary = "app查询售后进度")
public R<List<HardAfterSaleLogEntity>> appHistory(@RequestParam Long id) {
public R<List<HardAfterSaleLogVo>> appHistory(@RequestParam Long id) {
hardAfterSaleService.validateAppAfterSaleAccess(id, SecurityUtils.getUser().getId());
return R.ok(hardAfterSaleService.getHistory(id));
return R.ok(hardAfterSaleService.getAppHistory(id));
}
@ApiCryptoAes
@@ -168,6 +181,34 @@ public class HardAfterSaleController {
return R.ok(hardAfterSaleService.adminReinforceRefund(dto, user.getId()));
}
/**
* 按当前后台用户的数据范围预览勾选订单的日期、餐段、订餐人、剩余商品和退款金额。
*
* @param dto 列表页跨页勾选的订单主键,单次最多 1000 笔
* @return 当前时点服务端重新核定的批量退款预览
*/
@PostMapping(value = "/batch-refund/preview")
@Operation(summary = "后台批量退款预览")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
public R<HardBatchRefundPreviewVo> batchRefundPreview(@Valid @RequestBody HardBatchRefundPreviewDto dto) {
return R.ok(hardBatchRefundService.preview(dto, SecurityUtils.getUser()));
}
/**
* 提交批量退款异步任务,每笔订单沿用现有整单退款订单锁和独立事务。
*
* @param dto 订单主键和本批次必填退款备注
* @return 可通过 Hardware 通用进度接口轮询的初始任务状态
*/
@PostMapping(value = "/batch-refund/task")
@Operation(summary = "后台批量退款")
@SysLog("后台批量退款")
@Idempotent(expireTime = 5, info = "批量退款任务正在提交,请勿重复操作")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
public R<AsyncTaskProgressVO> submitBatchRefund(@Valid @RequestBody HardBatchRefundSubmitDto dto) {
return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交");
}
@GetMapping(value = "/history")
@Operation(summary = "查询售后进度")
public R<List<HardAfterSaleLogEntity>> history(@RequestParam Long id) {
@@ -100,14 +100,37 @@ public class HardCanteenApptTotalController {
* <p>食堂主键不信任客户端输入,而是根据当前商户管理员重新计算;服务层只统计有效预约订单,
* 并按已完成退款扣减后的净菜品份数聚合。</p>
*
* @param query 预约用餐日期范围和餐段筛选条件
* @return 当前账号可管理食堂内的菜品和点餐人员汇总;没有可管理食堂时返回空汇总
* @param query 预约日期、餐段和核销状态筛选条件
* @return 当前账号可管理食堂内与 PC 核销状态口径一致的菜品和点餐人员汇总
*/
@Operation(summary = "App预约菜品人员统计", description = "按当前账号可管理食堂统计预约菜品和点餐人员净份数")
@ApiCryptoAes
@PostMapping("/app/user-reserv-statistics")
public R<HardCanteenUserApptStatisticsSummaryVo> appUserReservationStatistics(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
return R.ok(canteenApptTotalService.appUserReservationStatistics(query, resolveAppCanteenMerchantIds()));
}
/**
* 查询 App 当前可管理食堂范围内按日拆分的预约统计。
*
* @param query 预约日期、餐段和核销状态筛选条件
* @return 每日预约统计和查询范围总计;核销状态及日期口径与 PC 人员预约页一致
*/
@Operation(summary = "App每日预约统计", description = "按当前账号可管理食堂及核销状态统计每日预约")
@ApiCryptoAes
@PostMapping("/app/user-reserv-daily-statistics")
public R<HardCanteenUserApptStatisticsVo> appUserReservationDailyStatistics(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
return R.ok(canteenApptTotalService.appUserReservationDailyStatistics(query, resolveAppCanteenMerchantIds()));
}
/**
* 解析当前 App 商户管理员可管理的食堂主键,客户端提交的食堂范围不参与授权判定。
*
* @return 当前账号可管理的食堂主键;没有可管理食堂时返回空集合
*/
private List<Long> resolveAppCanteenMerchantIds() {
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getId() == null) {
throw new IllegalStateException("未获取到当前登录用户");
@@ -119,10 +142,7 @@ public class HardCanteenApptTotalController {
throw new IllegalStateException(ObjectUtil.isEmpty(message) ? "查询可管理食堂范围失败" : message);
}
List<Long> merchantIds = merchantResponse.getData();
if (ObjectUtil.isEmpty(merchantIds)) {
return R.ok(new HardCanteenUserApptStatisticsSummaryVo());
}
return R.ok(canteenApptTotalService.appUserReservationStatistics(query, merchantIds));
return ObjectUtil.isEmpty(merchantIds) ? List.of() : merchantIds;
}
/* ************************************* end app end ************************************* */
@@ -47,8 +47,10 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@RestController
@RequiredArgsConstructor
@@ -151,12 +153,70 @@ public class HardOrderTotalController {
return R.ok(hardOrderTotalService.listPayType(dto));
}
/**
* 查询当前用户所属食堂商户范围内的部门交易统计。
*
* @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集
* @return 当前用户有权查看的部门交易统计,无可见食堂或部门时返回空列表
*/
@PostMapping("/page-list-dept")
@Operation(summary = "部门交易记录")
public R<List<HardDeptTotalVo>> pageListDept(@RequestBody HardDeptTotalDto dto) {
if (!applyCanteenDepartmentScope(dto)) {
return R.ok(List.of());
}
return R.ok(hardOrderTotalService.pageListDept(dto));
}
/**
* 将部门统计条件限制到当前用户可管理的食堂商户所属部门。
*
* <p>钱包服务根据当前用户主键和食堂商户类型一次性返回其所属食堂覆盖的用户部门。页面传入的
* 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合,避免空集合或排除
* 条件导致底层统计退化为全量查询。</p>
*
* @param dto 待收敛的部门统计条件,方法会写入最终允许查询的部门 ID 集合
* @return 是否仍存在可查询部门;false 表示调用方必须直接返回空数据
*/
private boolean applyCanteenDepartmentScope(HardDeptTotalDto dto) {
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getId() == null) {
return false;
}
R<List<Long>> departmentResponse = mchService.getSelfMchUserDeptIdListByUserId(
user.getId(), List.of(MerchantTypeEnum.CANTEEN));
if (departmentResponse == null || !departmentResponse.isOk() || ObjectUtil.isEmpty(departmentResponse.getData())) {
return false;
}
List<Long> allowedDeptIds = departmentResponse.getData().stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (ObjectUtil.isEmpty(allowedDeptIds)) {
return false;
}
List<Long> requestedDeptIds = ObjectUtil.isNotEmpty(dto.getDeptIds())
? dto.getDeptIds()
: ObjectUtil.isNotEmpty(dto.getDeptId()) ? List.of(dto.getDeptId()) : List.of();
List<Long> scopedDeptIds = allowedDeptIds;
if (ObjectUtil.isNotEmpty(requestedDeptIds)) {
Set<Long> requestedDeptIdSet = new HashSet<>(requestedDeptIds);
scopedDeptIds = Boolean.FALSE.equals(dto.getIsInclude())
? allowedDeptIds.stream().filter(id -> !requestedDeptIdSet.contains(id)).toList()
: allowedDeptIds.stream().filter(requestedDeptIdSet::contains).toList();
}
if (ObjectUtil.isEmpty(scopedDeptIds)) {
return false;
}
dto.setDeptId(null);
dto.setDeptIds(scopedDeptIds);
dto.setIsInclude(true);
return true;
}
@Operation(summary = "设备交易记录导出", description = "设备交易记录导出")
@PostMapping("/export-sb")
@SysLog("设备交易记录导出")
@@ -465,12 +525,21 @@ public class HardOrderTotalController {
}
}
/**
* 导出当前用户所属食堂商户范围内的部门交易统计。
*
* @param response Excel 文件响应
* @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集
* @throws IOException 写入 Excel 响应失败时抛出
*/
@Operation(summary = "部门交易记录导出", description = "部门交易记录导出")
@PostMapping("/export-dept")
@SysLog("部门交易记录导出")
@PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')")
public void exportDept(HttpServletResponse response, @RequestBody HardDeptTotalDto dto) throws IOException {
List<HardDeptTotalVo> list = hardOrderTotalService.pageListDept(dto);
List<HardDeptTotalVo> list = applyCanteenDepartmentScope(dto)
? hardOrderTotalService.pageListDept(dto)
: List.of();
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();
@@ -96,7 +96,7 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
*
* @param page 分页参数
* @param dto 单日、来源、食堂、餐段、部门和商品名称筛选
* @return 按商品聚合的来源明细页
* @return 按商品名称、商品 ID、统计日期和来源升序排列的商品聚合明细页
*/
Page<HardCarVo> proudctQueryStats(Page page, @Param("dto") HardCarDto dto);
@@ -47,6 +47,15 @@ public interface HardAfterSaleCarService extends IService<HardAfterSaleCarEntity
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId, List<String> statuses);
/**
* 批量查询多个父购物车在指定售后状态下的商品占用明细,避免退款预览逐订单访问数据库。
*
* @param carParentIds 父购物车主键集合,空集合不发起数据库查询
* @param statuses 需要计入占用的售后状态编码集合
* @return 父购物车主键到商品聚合明细的映射;无占用的购物车不出现在映射中
*/
Map<Long, List<HardAfterSaleCarEntity>> getOccupiedByCarParentIds(List<Long> carParentIds, List<String> statuses);
/**
* 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。
*
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardDisagreeDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
@@ -57,6 +58,14 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
*/
List<HardAfterSaleLogEntity> getHistory(Long id);
/**
* 获取 App 售后进度,并仅在申请、拒绝或直接退款节点补充对应原因。
*
* @param id 售后单 ID
* @return App 可见的售后进度记录
*/
List<HardAfterSaleLogVo> getAppHistory(Long id);
/**
* 退款
*/
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto;
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto;
import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo;
/**
* 后台订单批量退款服务,负责权限冻结、只读预览和逐笔独立退款任务。
*/
public interface HardBatchRefundService {
/**
* 按当前后台用户的数据范围生成批量退款预览。
*
* @param dto 列表页跨页勾选的订单主键
* @param user 当前后台登录用户,用于设备类型和商户数据范围校验
* @return 服务端实时计算的订单、菜品、人员和剩余金额明细
*/
HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user);
/**
* 冻结当前仍有权限访问的订单并提交异步逐笔退款任务。
*
* @param dto 订单主键和本批次统一退款备注
* @param user 当前后台登录用户,用户主键会记录为每笔退款的审核操作人
* @return 前端可通过通用进度接口轮询的初始任务状态
*/
AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user);
}
@@ -57,11 +57,21 @@ public interface HardCanteenApptTotalService {
/**
* 按 App 当前账号可管理的食堂范围统计有效预约菜品及点餐人员。
*
* @param query 预约用餐日期范围和餐段筛选条件
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合
* @return 已完成退款扣减后的菜品和点餐人员汇总;食堂范围为空时返回空汇总
* @return 与 PC 端核销状态口径一致且已完成退款扣减的菜品和点餐人员汇总
*/
HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics(
HardCanteenUserApptQueryDto query, List<Long> merchantIds);
/**
* 按 App 当前账号可管理的食堂范围统计每日预约及查询范围总计。
*
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合
* @return 与 PC 端核销状态口径一致的逐日预约统计
*/
HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics(
HardCanteenUserApptQueryDto query, List<Long> merchantIds);
}
@@ -72,6 +72,26 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl<HardAfterSaleCarMap
return baseMapper.getOccupiedByCarParentId(carParentId, statuses);
}
/**
* 一次查询全部父购物车的商品占用,再按父购物车主键分组供批量预览复用。
*
* @param carParentIds 父购物车主键集合
* @param statuses 需要计入占用的售后状态编码集合
* @return 父购物车主键到商品占用明细的有序映射
*/
@Override
public Map<Long, List<HardAfterSaleCarEntity>> getOccupiedByCarParentIds(List<Long> carParentIds,
List<String> statuses) {
if (ObjectUtil.isEmpty(carParentIds) || ObjectUtil.isEmpty(statuses)) {
return Map.of();
}
Map<Long, List<HardAfterSaleCarEntity>> result = new LinkedHashMap<>();
baseMapper.getOccupiedByCarParentIds(carParentIds.stream().filter(java.util.Objects::nonNull).distinct().toList(),
statuses)
.forEach(item -> result.computeIfAbsent(item.getParentCarId(), ignored -> new ArrayList<>()).add(item));
return result;
}
/**
* 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。
*
@@ -38,6 +38,7 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto;
import com.bsd.pjcloud.hard.api.entity.order.*;
import com.bsd.pjcloud.hard.api.utils.HardUtil;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
@@ -509,6 +510,61 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
);
}
/**
* 将售后单上的原因投影到 App 时间线的业务节点,避免所有日志重复展示同一退款原因。
* 会员申请节点展示申请原因,拒绝节点展示审核原因;后台或系统直接退款没有申请节点,
* 因此在退款完成节点展示申请原因,并兼容回退到历史审核原因。
*
* @param id 售后单 ID
* @return 带节点退款原因的 App 售后进度
*/
@Override
public List<HardAfterSaleLogVo> getAppHistory(Long id) {
List<HardAfterSaleLogEntity> history = getHistory(id);
if (history.isEmpty()) return List.of();
HardAfterSaleEntity afterSale = baseMapper.selectById(id);
return history.stream().map(log -> {
HardAfterSaleLogVo vo = BeanUtils.toBean(log, HardAfterSaleLogVo.class);
if (afterSale != null) {
vo.setReason(resolveAppHistoryReason(log.getOperateType(), afterSale));
}
return vo;
}).toList();
}
/**
* 按售后操作类型选择当前节点的原因,原因只用于 App 展示且不改变日志持久化内容。
*
* @param operateType 售后日志操作类型协议编码
* @param afterSale 当前售后单
* @return 去除首尾空白的节点原因;该节点无需展示时返回 {@code null}
*/
private String resolveAppHistoryReason(String operateType, HardAfterSaleEntity afterSale) {
if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.MEMBER_CREATE.getType())) {
return trimToNull(afterSale.getApplyReason());
}
if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_DISAGREE_APPLY.getType())) {
return trimToNull(afterSale.getAuditReason());
}
boolean directRefund = Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType())
|| Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getType())
|| Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType());
if (!directRefund) return null;
String applyReason = trimToNull(afterSale.getApplyReason());
return applyReason == null ? trimToNull(afterSale.getAuditReason()) : applyReason;
}
/**
* 统一过滤接口原因中的纯空白值,避免 App 渲染空的信息行。
*
* @param value 数据库存储的原因文本
* @return 去除首尾空白后的文本;空值或纯空白返回 {@code null}
*/
private String trimToNull(String value) {
if (value == null || value.isBlank()) return null;
return value.trim();
}
/**
* 使用默认退款单号前缀执行售后退款。
*
@@ -0,0 +1,733 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
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.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
import com.bsd.pjcloud.hard.api.dto.order.HardAdminRefundDto;
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto;
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleCarService;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardBatchRefundService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
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.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 后台批量退款服务实现,预览阶段批量聚合数据,执行阶段复用既有单笔整单退款事务。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HardBatchRefundServiceImpl implements HardBatchRefundService {
/** 批量退款日志标签,用于定位权限校验、逐笔退款和结果文件异常。 */
private static final String LOG_TAG = "订单批量退款";
/** 前后端共同遵守的单批订单上限,防止超大 IN 查询和失控的支付渠道调用。 */
private static final int MAX_BATCH_ORDER_COUNT = 1000;
/** 批量退款异步进度使用的稳定 Redis 业务命名空间。 */
private static final String BATCH_REFUND_REDIS_KEY = "hard:order:batch-refund";
/** 通用异步进度弹窗展示的任务名称。 */
private static final String BATCH_REFUND_TASK_NAME = "订单批量退款";
/** 每笔后台整单退款固定使用的仅退款售后方式协议编码。 */
private static final String REFUND_ONLY_WAY = "10";
/** 结果工作簿名称,包含每笔订单成功、失败或跳过原因。 */
private static final String RESULT_SHEET_NAME = "批量退款结果";
/** 文件中心保存批量退款结果的业务目录。 */
private static final String RESULT_FILE_DIR = "hard-batch-refund";
/** 文件中心区分批量退款结果文件的业务类型编码。 */
private static final String RESULT_FILE_TYPE = "HARD_BATCH_REFUND_RESULT";
/** XLSX 结果文件的标准 MIME 类型。 */
private static final String XLSX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/** 结果文件名时间格式,保证同一操作人连续执行时文件名仍可区分。 */
private static final DateTimeFormatter RESULT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 当前支付退款实现支持的第三方原路退款渠道。 */
private static final Set<HardPayTypeEnum> SUPPORTED_THIRD_PARTY_PAY_TYPES = Set.of(
HardPayTypeEnum.WECHANT_SELF_QR_CODE_PAY,
HardPayTypeEnum.WECHANT_APP_PAY,
HardPayTypeEnum.ALI_SELF_QR_CODE_PAY,
HardPayTypeEnum.ALI_APP_PAY
);
/** 订单服务,用于按后台数据范围批量读取订单和原购物车快照。 */
private final HardOrderService hardOrderService;
/** 售后服务,复用既有整单退款业务和读取历史售后金额。 */
private final HardAfterSaleService hardAfterSaleService;
/** 售后商品服务,用一次查询取得全部订单的待审核和已退款商品占用。 */
private final HardAfterSaleCarService hardAfterSaleCarService;
/** 设备权限服务,用于冻结当前后台用户允许查看的设备类型范围。 */
private final HardManagerService hardManagerService;
/** 通用异步任务执行器,负责恢复租户、登录和 Feign Token 上下文并保存进度。 */
private final AsyncTaskExecutorService asyncTaskExecutorService;
/** 文件中心服务,用于保存最多 1000 笔订单的退款执行结果工作簿。 */
private final RemoteFileService remoteFileService;
/** Spring 事务管理器,用于让每笔退款在异步任务外层事务中仍以独立事务提交。 */
private final PlatformTransactionManager transactionManager;
/**
* 生成只读退款预览,订单、购物车、售后金额和商品占用均按批量方式读取。
*
* @param dto 列表页勾选的订单主键
* @param user 当前后台登录用户
* @return 当前时点的批量退款预览
*/
@Override
public HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user) {
return buildPreview(normalizeOrderIds(dto == null ? null : dto.getOrderIds()), user);
}
/**
* 提交异步退款任务前同步冻结权限与预览状态,避免任务线程接收未经授权的订单主键。
*
* @param dto 订单主键和统一退款备注
* @param user 当前后台登录用户
* @return 通用异步任务初始进度
*/
@Override
public AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user) {
if (dto == null || StrUtil.isBlank(dto.getRemark())) {
throw new IllegalArgumentException("请输入退款备注");
}
String remark = dto.getRemark().trim();
HardBatchRefundPreviewVo preview = buildPreview(normalizeOrderIds(dto.getOrderIds()), user);
long refundableCount = preview.getOrders().stream().filter(item -> Boolean.TRUE.equals(item.getRefundable())).count();
if (refundableCount == 0) {
throw new IllegalStateException("所选订单当前均不可退款,请刷新列表后重试");
}
List<HardBatchRefundPreviewVo.OrderItem> orderSnapshot = List.copyOf(preview.getOrders());
Long operatorId = user == null ? null : user.getId();
if (operatorId == null) {
throw new IllegalStateException("无法取得当前退款操作人");
}
return asyncTaskExecutorService.submitWithResult(BATCH_REFUND_REDIS_KEY, BATCH_REFUND_TASK_NAME,
orderSnapshot.size(), reporter -> executeBatch(orderSnapshot, remark, operatorId, reporter));
}
/**
* 去重并保留页面勾选顺序,同时执行服务端数量上限兜底。
*
* @param orderIds 客户端提交的订单主键
* @return 非空、去重且不超过上限的订单主键
*/
private List<Long> normalizeOrderIds(List<Long> orderIds) {
if (orderIds == null || orderIds.isEmpty()) {
throw new IllegalArgumentException("请选择需要退款的订单");
}
List<Long> normalized = orderIds.stream().filter(Objects::nonNull).distinct().toList();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("请选择需要退款的订单");
}
if (normalized.size() > MAX_BATCH_ORDER_COUNT) {
throw new IllegalArgumentException("单次最多选择" + MAX_BATCH_ORDER_COUNT + "个订单");
}
return normalized;
}
/**
* 按订单主键一次装配订单、用户、商户、购物车及售后占用,再计算每笔剩余退款内容。
*
* @param orderIds 已规范化的订单主键
* @param user 当前后台登录用户
* @return 与请求主键顺序一致的退款预览
*/
private HardBatchRefundPreviewVo buildPreview(List<Long> orderIds, PjcloudUser user) {
List<HardOrderVo> scopedOrders = loadScopedOrders(orderIds, user);
Map<Long, HardOrderVo> orderMap = scopedOrders.stream().collect(Collectors.toMap(HardOrderVo::getId,
item -> item, (first, second) -> first, LinkedHashMap::new));
if (orderMap.size() != orderIds.size()) {
throw new IllegalStateException("部分订单不存在或已超出当前数据权限,请刷新列表后重试");
}
Map<Long, List<HardAfterSaleEntity>> afterSalesByOrderId = loadEffectiveAfterSales(orderIds);
List<Long> parentCarIds = scopedOrders.stream().map(HardOrderVo::getParentCarId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, List<HardAfterSaleCarEntity>> occupiedCarsByParentId =
hardAfterSaleCarService.getOccupiedByCarParentIds(parentCarIds,
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()));
List<HardBatchRefundPreviewVo.OrderItem> items = new ArrayList<>(orderIds.size());
for (Long orderId : orderIds) {
HardOrderVo order = orderMap.get(orderId);
List<HardAfterSaleEntity> afterSales = afterSalesByOrderId.getOrDefault(orderId, List.of());
List<HardAfterSaleCarEntity> occupiedCars = occupiedCarsByParentId.getOrDefault(order.getParentCarId(), List.of());
items.add(buildOrderItem(order, afterSales, occupiedCars));
}
HardBatchRefundPreviewVo result = new HardBatchRefundPreviewVo();
result.setOrders(items);
result.setSummary(buildSummary(items));
return result;
}
/**
* 复用订单列表的数据范围、设备类型范围和批量关联装配能力读取所选订单。
*
* @param orderIds 已规范化的订单主键
* @param user 当前后台登录用户
* @return 当前用户仍有权限访问的订单及购物车快照
*/
private List<HardOrderVo> loadScopedOrders(List<Long> orderIds, PjcloudUser user) {
if (user == null) {
throw new IllegalStateException("无法取得当前登录用户");
}
HardOrderDto query = new HardOrderDto();
query.setOrderIds(orderIds);
query.setIsFindCarList(true);
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null
? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList()));
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
return hardOrderService.scopeList(query, dataScope);
}
/**
* 一次读取全部待审核和已完成售后单,取消及拒绝记录不占用本次退款金额。
*
* @param orderIds 预览订单主键
* @return 订单主键到有效售后记录的映射
*/
private Map<Long, List<HardAfterSaleEntity>> loadEffectiveAfterSales(List<Long> orderIds) {
return hardAfterSaleService.list(new LambdaQueryWrapper<HardAfterSaleEntity>()
.in(HardAfterSaleEntity::getOrderId, orderIds)
.in(HardAfterSaleEntity::getStatus,
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()))
.orderByAsc(HardAfterSaleEntity::getId))
.stream().collect(Collectors.groupingBy(HardAfterSaleEntity::getOrderId,
LinkedHashMap::new, Collectors.toList()));
}
/**
* 生成单笔订单预览并给出不可退款原因;实际任务仍会在订单锁内重新校验全部条件。
*
* @param order 当前数据范围内的订单
* @param afterSales 待审核和已完成售后记录
* @param occupiedCars 待审核和已完成售后占用的商品汇总
* @return 单笔订单预览明细
*/
private HardBatchRefundPreviewVo.OrderItem buildOrderItem(HardOrderVo order,
List<HardAfterSaleEntity> afterSales,
List<HardAfterSaleCarEntity> occupiedCars) {
HardBatchRefundPreviewVo.OrderItem item = new HardBatchRefundPreviewVo.OrderItem();
item.setOrderId(order.getId());
item.setOrderNo(order.getOrderNo());
item.setBusinessDate(resolveBusinessDate(order));
item.setHourType(order.getHourType());
item.setUserId(order.getUserId());
item.setUsername(order.getUsername());
item.setUserDeptName(order.getUserDeptName());
item.setMerchantName(order.getMerchantName());
int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus());
item.setRefundPrice(Math.max(0, safePrice(order.getPayPrice()) - completedRefundPrice));
item.setProducts(buildRemainingProducts(order.getCarList(), occupiedCars));
String unavailableReason = resolveUnavailableReason(order, afterSales, item.getProducts(), completedRefundPrice);
item.setRefundable(unavailableReason == null);
item.setUnavailableReason(unavailableReason);
return item;
}
/**
* 业务日期优先体现预约用餐日期,非预约订单再回退到支付或创建日期。
*
* @param order 当前订单
* @return 可用于抽屉按日扫描的业务日期;历史时间均为空时返回 {@code null}
*/
private LocalDate resolveBusinessDate(HardOrderVo order) {
if (order.getApptUseDate() != null) return order.getApptUseDate();
if (order.getPayTime() != null) return order.getPayTime().toLocalDate();
return order.getCreateTime() == null ? null : order.getCreateTime().toLocalDate();
}
/**
* 按商品主键聚合原购物车重复行,并扣除待审核和已完成售后占用的数量、重量及商品金额。
*
* @param originalCars 原订单购物车快照
* @param occupiedCars 有效售后商品占用汇总
* @return 仍有数量或重量可退的商品明细,同时携带对应剩余商品金额
*/
private List<HardBatchRefundPreviewVo.ProductItem> buildRemainingProducts(List<HardCarVo> originalCars,
List<HardAfterSaleCarEntity> occupiedCars) {
if (ObjectUtil.isEmpty(originalCars)) return List.of();
Map<Long, HardAfterSaleCarEntity> occupiedByProductId = occupiedCars.stream()
.filter(item -> item != null && item.getProductId() != null)
.collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item,
(first, second) -> first, LinkedHashMap::new));
Map<String, ProductAccumulator> products = new LinkedHashMap<>();
for (HardCarVo car : originalCars) {
if (car == null) continue;
String key = car.getProductId() == null ? "car:" + car.getId() : "product:" + car.getProductId();
products.computeIfAbsent(key, ignored -> new ProductAccumulator(car)).add(car);
}
List<HardBatchRefundPreviewVo.ProductItem> result = new ArrayList<>();
for (ProductAccumulator product : products.values()) {
HardAfterSaleCarEntity occupied = occupiedByProductId.get(product.getProductId());
HardBatchRefundPreviewVo.ProductItem item = product.toRemainingItem(occupied);
if (item != null) result.add(item);
}
return result;
}
/**
* 按现有单笔退款能力判断渠道、订单类型、历史退款和剩余购物车是否满足批量执行条件。
*
* @param order 当前订单
* @param afterSales 当前有效售后记录
* @param products 扣除有效售后占用后的剩余商品
* @param completedRefundPrice 已完成退款金额,单位分
* @return 不可退款原因;返回 {@code null} 表示通过预览校验
*/
private String resolveUnavailableReason(HardOrderVo order, List<HardAfterSaleEntity> afterSales,
List<HardBatchRefundPreviewVo.ProductItem> products,
int completedRefundPrice) {
HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType());
if (!HardOrderTypeEnum.isRefund(orderType) || ObjectUtil.equals(orderType, HardOrderTypeEnum.ZY)) {
return "当前订单类型不支持后台退款";
}
HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType());
if (payType == null || !isSupportedRefundChannel(orderType, payType)) {
return "当前支付方式不支持原路退款";
}
if (safePrice(order.getPayPrice()) <= 0) {
return "订单实付金额异常";
}
if (afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) {
return "已有退款申请待审核";
}
if (completedRefundPrice >= safePrice(order.getPayPrice())) {
return "订单已全部退款";
}
boolean simplePay = HardPayTypeEnum.isSimplePayList.contains(payType)
|| HardOrderTypeEnum.isSimplePayList.contains(orderType);
if (!simplePay && products.isEmpty()) {
return "没有剩余可退商品";
}
return null;
}
/**
* 退款渠道严格跟随现有售后服务的分支:钱包支付或四类微信、支付宝原路退款。
*
* @param orderType 订单类型协议枚举
* @param payType 支付方式协议枚举
* @return {@code true} 表示现有服务存在对应渠道退款实现
*/
private boolean isSupportedRefundChannel(HardOrderTypeEnum orderType, HardPayTypeEnum payType) {
return hardOrderService.isPay(orderType, payType) || SUPPORTED_THIRD_PARTY_PAY_TYPES.contains(payType);
}
/**
* 汇总可退款订单的去重人数、菜品种类、份数、重量和剩余金额。
*
* @param items 全部预览订单明细
* @return 只累计可退款订单的汇总
*/
private HardBatchRefundPreviewVo.Summary buildSummary(List<HardBatchRefundPreviewVo.OrderItem> items) {
HardBatchRefundPreviewVo.Summary summary = new HardBatchRefundPreviewVo.Summary();
List<HardBatchRefundPreviewVo.OrderItem> refundableItems = items.stream()
.filter(item -> Boolean.TRUE.equals(item.getRefundable())).toList();
Set<Long> userIds = refundableItems.stream().map(HardBatchRefundPreviewVo.OrderItem::getUserId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Set<String> productKeys = new LinkedHashSet<>();
long productCount = 0L;
BigDecimal productWeight = BigDecimal.ZERO;
long refundPrice = 0L;
for (HardBatchRefundPreviewVo.OrderItem item : refundableItems) {
refundPrice += safePrice(item.getRefundPrice());
for (HardBatchRefundPreviewVo.ProductItem product : item.getProducts()) {
productKeys.add(product.getProductId() == null ? "name:" + product.getProductName()
: "id:" + product.getProductId());
productCount += product.getProductCount() == null ? 0 : Math.max(0, product.getProductCount());
productWeight = productWeight.add(safeWeight(product.getProductWeight()));
}
}
summary.setSelectedOrderCount(items.size());
summary.setRefundableOrderCount(refundableItems.size());
summary.setUnavailableOrderCount(items.size() - refundableItems.size());
summary.setUserCount(userIds.size());
summary.setProductKindCount(productKeys.size());
summary.setProductCount(productCount);
summary.setProductWeight(productWeight);
summary.setRefundPrice(refundPrice);
return summary;
}
/**
* 在通用异步线程中逐笔退款;每笔使用独立事务,失败会记录结果并继续后续订单。
*
* @param orders 提交时冻结的预览订单
* @param remark 去除首尾空白后的统一退款备注
* @param operatorId 后台退款操作人主键
* @param reporter 通用异步任务进度报告器
* @return 包含完整逐笔结果工作簿下载地址的任务结果
*/
private AsyncTaskResult executeBatch(List<HardBatchRefundPreviewVo.OrderItem> orders, String remark,
Long operatorId, AsyncTaskProgressReporter reporter) {
List<BatchRefundResultExcelRow> results = new ArrayList<>(orders.size());
int successCount = 0;
int failedCount = 0;
for (HardBatchRefundPreviewVo.OrderItem order : orders) {
if (!Boolean.TRUE.equals(order.getRefundable())) {
failedCount++;
results.add(toResultRow(order, "跳过", order.getUnavailableReason()));
} else {
try {
refundOneOrder(order.getOrderId(), remark, operatorId);
successCount++;
results.add(toResultRow(order, "成功", "退款已完成"));
} catch (Exception exception) {
failedCount++;
String message = safeErrorMessage(exception);
log.warn("|{}|单笔退款失败,orderId:{},message:{}", LOG_TAG, order.getOrderId(), message);
results.add(toResultRow(order, "失败", message));
}
}
reporter.advance("已处理 " + results.size() + " / " + orders.size() + " 笔,成功 "
+ successCount + " 笔,失败或跳过 " + failedCount + " 笔");
}
return createResultFile(results);
}
/**
* 使用 REQUIRES_NEW 包裹既有整单退款,使异步任务中的某笔失败不会回滚其他已完成退款。
*
* @param orderId 当前退款订单主键
* @param remark 本批次统一退款备注
* @param operatorId 后台退款操作人主键
*/
private void refundOneOrder(Long orderId, String remark, Long operatorId) {
HardAdminRefundDto refund = new HardAdminRefundDto();
refund.setOrderId(orderId);
refund.setWay(REFUND_ONLY_WAY);
refund.setApplyReason(remark);
refund.setApplyDescription(remark);
refund.setApplyPicUrls("");
TransactionTemplate transaction = new TransactionTemplate(transactionManager);
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
Boolean result = transaction.execute(status -> hardAfterSaleService.adminRefund(refund, operatorId));
if (!Boolean.TRUE.equals(result)) {
throw new IllegalStateException("退款服务未返回成功结果");
}
}
/**
* 将单笔执行结果转换为结果工作簿行,金额使用提交任务时的预览剩余金额。
*
* @param order 提交时冻结的订单预览
* @param status 执行状态:成功、失败或跳过
* @param message 面向后台操作人的业务结果说明
* @return 可直接交给 EasyExcel 的结果行
*/
private BatchRefundResultExcelRow toResultRow(HardBatchRefundPreviewVo.OrderItem order, String status,
String message) {
BatchRefundResultExcelRow row = new BatchRefundResultExcelRow();
row.setOrderId(String.valueOf(order.getOrderId()));
row.setOrderNo(order.getOrderNo());
row.setBusinessDate(order.getBusinessDate() == null ? null : order.getBusinessDate().toString());
row.setUsername(StrUtil.blankToDefault(order.getUsername(), "未知用户"));
row.setPreviewRefundPrice(BigDecimal.valueOf(safePrice(order.getRefundPrice()), 2)
.setScale(2, RoundingMode.HALF_UP).toPlainString());
row.setStatus(status);
row.setMessage(message);
return row;
}
/**
* 生成并上传结果工作簿;文件异常不改变已经逐笔提交的真实退款结果。
*
* @param results 全部订单执行结果
* @return 成功上传时携带下载地址,文件异常时返回无附件成功结果
*/
private AsyncTaskResult createResultFile(List<BatchRefundResultExcelRow> results) {
Path outputPath = null;
String fileName = "批量退款结果_" + LocalDateTime.now().format(RESULT_FILE_TIME_FORMATTER) + ".xlsx";
try {
outputPath = Files.createTempFile("hard-batch-refund-", ".xlsx");
EasyExcel.write(outputPath.toFile(), BatchRefundResultExcelRow.class)
.sheet(RESULT_SHEET_NAME).doWrite(results);
return AsyncTaskResult.file(uploadResultFile(outputPath, fileName), fileName);
} catch (Exception exception) {
log.error("|{}|结果文件生成或上传失败,fileName:{}", LOG_TAG, fileName, exception);
return AsyncTaskResult.empty();
} finally {
deleteTemporaryResultFile(outputPath);
}
}
/**
* 上传完整结果工作簿并校验文件中心统一业务响应。
*
* @param file 已关闭并可读取的临时 XLSX 文件
* @param fileName 用户下载时看到的文件名
* @return 文件中心下载地址
* @throws Exception 文件读取或远程上传失败时抛出
*/
private String uploadResultFile(Path file, String fileName) throws Exception {
MultipartFile resultFile = new MockMultipartFile("file", encodeOriginalFilename(fileName),
XLSX_CONTENT_TYPE, Files.readAllBytes(file));
R<?> uploadResult = remoteFileService.innerUpload(resultFile, RESULT_FILE_DIR, null, RESULT_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 文件中心上传接口使用的 ISO-8859-1 原始文件名
*/
private String encodeOriginalFilename(String fileName) {
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}
/**
* 删除批量退款临时结果文件,清理失败只记录告警且不覆盖退款结果。
*
* @param outputPath 待清理的临时路径,允许为空
*/
private void deleteTemporaryResultFile(Path outputPath) {
if (outputPath == null) return;
try {
Files.deleteIfExists(outputPath);
} catch (Exception exception) {
log.warn("|{}|清理临时结果文件失败,path:{}", LOG_TAG, outputPath, exception);
}
}
/**
* 提取可展示的业务异常信息并限制长度,避免把技术堆栈或超长下游响应写入结果文件。
*
* @param exception 单笔退款抛出的异常
* @return 最多 200 个字符的业务失败说明
*/
private String safeErrorMessage(Exception exception) {
String message = exception == null ? null : exception.getMessage();
if (StrUtil.isBlank(message)) return "退款失败,请查看服务日志";
return message.length() > 200 ? message.substring(0, 200) : message;
}
/**
* 汇总指定状态售后单金额,历史空金额按零处理并限制在整数分范围内。
*
* @param afterSales 当前订单有效售后记录
* @param status 需要汇总的售后状态编码
* @return 累计金额,单位分
*/
private int sumRefundPrice(List<HardAfterSaleEntity> afterSales, String status) {
long total = afterSales.stream().filter(item -> ObjectUtil.equals(item.getStatus(), status))
.map(HardAfterSaleEntity::getRefundPrice).filter(Objects::nonNull).mapToLong(Integer::longValue).sum();
return total > Integer.MAX_VALUE ? Integer.MAX_VALUE : Math.toIntExact(Math.max(0L, total));
}
/**
* 将可空金额转换为非负整数分,历史负值按零处理。
*
* @param price 数据库或预览中的金额
* @return 可参与退款计算的非负金额
*/
private int safePrice(Integer price) {
return price == null ? 0 : Math.max(0, price);
}
/**
* 将可空重量转换为非负值,避免历史负数扩大剩余可退重量。
*
* @param weight 购物车或售后商品重量
* @return 非负重量
*/
private static BigDecimal safeWeight(BigDecimal weight) {
return weight == null ? BigDecimal.ZERO : weight.max(BigDecimal.ZERO);
}
/**
* 将可空商品金额转换为非负整数分,历史负值按零处理。
*
* @param price 购物车或售后商品总价,单位分
* @return 可参与剩余商品金额计算的非负金额
*/
private static long safeProductPrice(Long price) {
return price == null ? 0L : Math.max(0L, price);
}
/**
* 同一商品多行购物车快照的原始数量、重量和金额累加器。
*/
@Data
private static class ProductAccumulator {
/** 商品主键;无法识别的历史购物车行允许为空。 */
private final Long productId;
/** 首行购物车快照中的商品名称。 */
private final String productName;
/** 商品性质协议编码,用于选择数量或重量口径。 */
private final String productNature;
/** 同一商品全部原购物车行的数量总和。 */
private int originalCount;
/** 同一商品全部原购物车行的重量总和。 */
private BigDecimal originalWeight = BigDecimal.ZERO;
/** 同一商品全部原购物车行的商品总价,单位为分。 */
private long originalRefundPrice;
/**
* 使用首行购物车快照建立商品身份和展示字段。
*
* @param car 当前商品第一条购物车快照
*/
private ProductAccumulator(HardCarVo car) {
this.productId = car.getProductId();
this.productName = car.getProductName();
this.productNature = car.getProductNature();
}
/**
* 累加同一商品购物车行的非负数量、重量和商品总价。
*
* @param car 同一商品的购物车快照
*/
private void add(HardCarVo car) {
this.originalCount += car.getProductCount() == null ? 0 : Math.max(0, car.getProductCount());
this.originalWeight = this.originalWeight.add(safeWeight(car.getProductWeight()));
this.originalRefundPrice = Math.addExact(this.originalRefundPrice, safeProductPrice(car.getTotalPrice()));
}
/**
* 扣除有效售后占用并转换为抽屉展示商品;无剩余数量或重量时返回空。
*
* @param occupied 待审核和已完成售后商品占用汇总
* @return 携带剩余数量、重量和商品金额的可退商品,已无剩余时返回 {@code null}
*/
private HardBatchRefundPreviewVo.ProductItem toRemainingItem(HardAfterSaleCarEntity occupied) {
int occupiedCount = occupied == null || occupied.getProductCount() == null
? 0 : Math.max(0, occupied.getProductCount());
BigDecimal occupiedWeight = occupied == null ? BigDecimal.ZERO : safeWeight(occupied.getProductWeight());
int remainingCount = Math.max(0, originalCount - occupiedCount);
BigDecimal remainingWeight = originalWeight.subtract(occupiedWeight).max(BigDecimal.ZERO);
long occupiedRefundPrice = occupied == null ? 0L : safeProductPrice(occupied.getTotalPrice());
long remainingRefundPrice = Math.max(0L, originalRefundPrice - occupiedRefundPrice);
boolean countProduct = ObjectUtil.equals(productNature, HardProductNatureEnum.DEFAULT.getType());
if ((countProduct && remainingCount <= 0)
|| (!countProduct && remainingWeight.compareTo(BigDecimal.ZERO) <= 0)) {
return null;
}
HardBatchRefundPreviewVo.ProductItem item = new HardBatchRefundPreviewVo.ProductItem();
item.setProductId(productId);
item.setProductName(productName);
item.setProductNature(productNature);
item.setProductCount(countProduct ? remainingCount : 0);
item.setProductWeight(countProduct ? BigDecimal.ZERO : remainingWeight);
item.setRefundPrice(remainingRefundPrice);
return item;
}
}
/**
* 异步批量退款结果工作簿行,保留预览信息和最终逐笔执行状态。
*/
@Data
public static class BatchRefundResultExcelRow {
/** Hardware 订单主键,文本格式避免 Excel 科学计数法丢失精度。 */
@ExcelProperty("订单ID")
private String orderId;
/** 订单业务编号;历史数据可能为空。 */
@ExcelProperty("订单编号")
private String orderNo;
/** 提交任务时冻结的预约、支付或创建业务日期。 */
@ExcelProperty("业务日期")
private String businessDate;
/** 提交任务时订单关联的订餐人姓名。 */
@ExcelProperty("订餐人")
private String username;
/** 提交任务时服务端计算的剩余可退金额,单位元。 */
@ExcelProperty("预览可退金额(元)")
private String previewRefundPrice;
/** 本次异步任务的逐笔状态:成功、失败或跳过。 */
@ExcelProperty("处理状态")
private String status;
/** 渠道退款结果或不可退款原因,不包含技术堆栈。 */
@ExcelProperty("处理说明")
private String message;
}
}
@@ -43,6 +43,7 @@ import java.time.LocalTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -305,18 +306,34 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
* <p>与 PC 人员预约统计不同,App 商户视角以可管理食堂中的有效订单为主数据源,
* 再按订单用户主键批量补齐姓名和部门,避免商户账号的组织数据权限漏掉实际点餐人员。</p>
*
* @param query 预约用餐日期范围和餐段筛选条件
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围
* @return 完成退款扣减后的菜品及点餐人员汇总
* @return 与 PC 端核销状态口径一致且完成退款扣减后的菜品及点餐人员汇总
*/
@Override
public HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics(
HardCanteenUserApptQueryDto query, List<Long> merchantIds) {
return appUserReservationDailyStatistics(query, merchantIds).getTotal();
}
/**
* 按 App 当前账号可管理食堂统计每日预约及查询范围总计。
*
* <p>订单先按商户范围、日期和餐段读取,再复用 PC 人员预约页的核销状态判定筛选用户日期行,
* 保证待核销、部分核销和已核销的协议值及统计日期口径一致。</p>
*
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围
* @return 按统计日期升序排列的每日预约统计及范围总计
*/
@Override
public HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics(
HardCanteenUserApptQueryDto query, List<Long> merchantIds) {
validateQuery(query);
List<Long> scopedMerchantIds = merchantIds == null
? List.of() : merchantIds.stream().filter(Objects::nonNull).distinct().toList();
if (scopedMerchantIds.isEmpty()) {
return new HardCanteenUserApptStatisticsSummaryVo();
return new HardCanteenUserApptStatisticsVo();
}
HardOrderDto orderQuery = buildReservationOrderQuery(query, null);
@@ -325,20 +342,33 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
List<HardOrderVo> orders = retainEffectiveReservationOrders(
hardOrderMapper.scopeList(orderQuery, null), query);
if (orders.isEmpty()) {
return new HardCanteenUserApptStatisticsSummaryVo();
return new HardCanteenUserApptStatisticsVo();
}
List<Long> userIds = orders.stream().map(HardOrderVo::getUserId)
.filter(Objects::nonNull).distinct().toList();
List<UserVO> users = fetchUsersByIds(userIds);
List<HardCanteenUserApptVo> rows = buildRows(users, query, getQueryDates(query), orders).stream()
List<LocalDate> queryDates = getQueryDates(query);
List<HardCanteenUserApptVo> rows = filterRows(buildRows(users, query, queryDates, orders), query).stream()
.filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList();
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, query);
StatisticsBucket bucket = new StatisticsBucket();
rows.forEach(row -> bucket.addRow(row, orderMap, carMap));
return bucket.toSummary();
Map<LocalDate, StatisticsBucket> dailyBuckets = new LinkedHashMap<>();
queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket()));
StatisticsBucket totalBucket = new StatisticsBucket();
rows.forEach(row -> {
StatisticsBucket dailyBucket = dailyBuckets.get(row.getApptUseDate());
if (dailyBucket == null) return;
dailyBucket.addRow(row, orderMap, carMap);
totalBucket.addRow(row, orderMap, carMap);
});
HardCanteenUserApptStatisticsVo result = new HardCanteenUserApptStatisticsVo();
result.setTotal(totalBucket.toSummary());
result.setDaily(queryDates.stream().map(date -> dailyBuckets.get(date).toDaily(date)).toList());
return result;
}
/**
@@ -1046,7 +1076,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
/** 餐段编码到餐段聚合结果的有序映射。 */
private final Map<String, MealStatisticsAccumulator> meals = new LinkedHashMap<>();
/** 餐段和菜品名称到菜品聚合结果的有序映射。 */
/** 餐段和商品身份到菜品聚合结果的有序映射;商品 ID 缺失时使用名称兼容历史数据。 */
private final Map<String, DishStatisticsAccumulator> dishes = new LinkedHashMap<>();
/**
@@ -1090,9 +1120,14 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
dishCount += count;
meal.dishCount += count;
String productName = normalize(car.getProductName(), "未命名菜品");
String dishKey = hourType + "\u0000" + productName;
// 同一商品的历史名称快照可能带有空格或不可见字符,优先按商品 ID 聚合可避免页面出现同 ID 重复行。
String productKey = car.getProductId() == null ? "name:" + productName : "id:" + car.getProductId();
String dishKey = hourType + "\u0000" + productKey;
DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey,
key -> new DishStatisticsAccumulator(hourType, productName, car.getProductPic()));
if (car.getProductId() != null) {
dish.productIds.add(car.getProductId());
}
// 同餐段同名菜品合并时优先保留首个非空快照图片,兼容早期订单未保存图片的情况。
if (StrUtil.isBlank(dish.productPic) && StrUtil.isNotBlank(car.getProductPic())) {
dish.productPic = car.getProductPic();
@@ -1214,6 +1249,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
/** 下单时固化的菜品名称。 */
private final String productName;
/** 当前统计项实际涉及的商品 ID,正常数据仅有一个;集合结构兼容历史无 ID 的名称兜底聚合。 */
private final Set<Long> productIds = new LinkedHashSet<>();
/** 下单时固化的菜品图片地址;同名菜品聚合时保留首个非空值。 */
private String productPic;
@@ -1247,6 +1285,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo();
vo.setHourType(hourType);
vo.setProductName(productName);
vo.setProductIds(new ArrayList<>(productIds));
vo.setProductPic(productPic);
vo.setProductCount(productCount);
vo.setOrderCount((long) orderIds.size());
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
@@ -47,6 +48,8 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -194,7 +197,10 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
}
/**
* 商户交易记录
* 汇总商户交易记录,并补全商户及商户部门展示信息。
*
* @param dto 商户、部门、用餐时段及交易时间等统计条件
* @return 按商户汇总的流水、退款和净消费数据;历史部门不存在时部门名为空
*/
@Override
public List<HardOrderCsVo> listCs(HardOrderDto dto) {
@@ -202,85 +208,88 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
if (ObjectUtil.isEmpty(dto.getMchTypeCodes())) {
CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
} else {
dto.setMchTypeCodes(dto.getMchTypeCodes());
}
// 查询总流水
// 流水与退款的业务发生时间不同,必须分别查询后再按商户合并,避免直接关联造成一笔订单被多笔售后重复放大。
HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class);
totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
DataScope totalDataScope = DataScope.of();
totalDataScope.setScopeDeptName("merchant_dept_id");
List<HardOrderCsVo> totalList = hardOrderMapper.listCs(totalDto, totalDataScope);
// 扣款总流水
HardOrderDto refundDto = BeanUtil.toBean(dto, HardOrderDto.class);
refundDto.setPayTimeList(null);
refundDto.setRefundTimeList(dto.getPayTimeList());
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes());
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
// refundStatusCodes 属于订单退款状态,售后完成状态必须使用 saleStatusCodes,二者混用会把有效退款全部过滤掉。
refundDto.setRefundStatusCodes(null);
refundDto.setSaleStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
// 部分退款后订单仍是已支付状态,退款统计不能只查询整单退款状态的订单。
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
DataScope refundDataScope = DataScope.of();
refundDataScope.setScopeDeptName("merchant_dept_id");
List<HardOrderCsVo> refundList = hardOrderMapper.listRefundCs(refundDto, refundDataScope);
// 有退款没有流水的情况
if (ObjectUtil.isAllNotEmpty(totalList, refundList)) {
List<Long> totalIds = totalList.stream().map(HardOrderCsVo::getMerchantId).toList();
for (HardOrderCsVo refund : refundList) {
if (!totalIds.contains(refund.getMerchantId())) {
HardOrderCsVo entity = BeanUtils.toBean(refund, HardOrderCsVo.class);
entity.setTotalPayPrice(BigDecimal.ZERO);
entity.setTotalPayCount(BigDecimal.ZERO);
totalList.add(entity);
}
}
}
for (HardOrderCsVo v : totalList) {
// 退款
if (ObjectUtil.isNotEmpty(refundList)) {
refundList.stream().filter(i -> ObjectUtil.equals(i.getMerchantId(), v.getMerchantId())).findFirst().ifPresent(i -> {
v.setTotalRefundPrice(i.getTotalPayPrice());
v.setTotalRefundCount(i.getTotalPayCount());
});
}
// 消费净金额
if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) {
BigDecimal refundPrice = BigDecimal.ZERO;
if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) {
refundPrice = v.getTotalRefundPrice();
}
v.setTotalProfitPrice(v.getTotalPayPrice().subtract(refundPrice));
}
// 消费净次数
if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) {
BigDecimal refundCount = BigDecimal.ZERO;
if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) {
refundCount = v.getTotalRefundCount();
}
v.setTotalProfitCount(v.getTotalPayCount().subtract(refundCount));
}
}
// 聚合一下数据
// 使用有序 Map 一次完成合并,同时保留原流水排序,并覆盖筛选期内只有退款、没有新支付的商户。
Map<Long, HardOrderCsVo> statisticsMap = new LinkedHashMap<>();
if (ObjectUtil.isNotEmpty(totalList)) {
for (HardOrderCsVo total : totalList) {
statisticsMap.put(total.getMerchantId(), total);
}
}
if (ObjectUtil.isNotEmpty(refundList)) {
for (HardOrderCsVo refund : refundList) {
HardOrderCsVo statistics = statisticsMap.computeIfAbsent(refund.getMerchantId(), merchantId -> {
HardOrderCsVo refundOnly = BeanUtils.toBean(refund, HardOrderCsVo.class);
refundOnly.setTotalPayPrice(BigDecimal.ZERO);
refundOnly.setTotalPayCount(BigDecimal.ZERO);
return refundOnly;
});
statistics.setTotalRefundPrice(ObjectUtil.defaultIfNull(refund.getTotalPayPrice(), BigDecimal.ZERO));
statistics.setTotalRefundCount(ObjectUtil.defaultIfNull(refund.getTotalPayCount(), BigDecimal.ZERO));
}
}
List<HardOrderCsVo> statisticsList = new ArrayList<>(statisticsMap.values());
for (HardOrderCsVo statistics : statisticsList) {
BigDecimal totalPayPrice = ObjectUtil.defaultIfNull(statistics.getTotalPayPrice(), BigDecimal.ZERO);
BigDecimal totalPayCount = ObjectUtil.defaultIfNull(statistics.getTotalPayCount(), BigDecimal.ZERO);
BigDecimal totalRefundPrice = ObjectUtil.defaultIfNull(statistics.getTotalRefundPrice(), BigDecimal.ZERO);
BigDecimal totalRefundCount = ObjectUtil.defaultIfNull(statistics.getTotalRefundCount(), BigDecimal.ZERO);
statistics.setTotalPayPrice(totalPayPrice);
statistics.setTotalPayCount(totalPayCount);
statistics.setTotalRefundPrice(totalRefundPrice);
statistics.setTotalRefundCount(totalRefundCount);
statistics.setTotalProfitPrice(totalPayPrice.subtract(totalRefundPrice));
statistics.setTotalProfitCount(totalPayCount.subtract(totalRefundCount));
}
if (ObjectUtil.isNotEmpty(statisticsList)) {
// 商户
List<Long> mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList();
List<Long> mchIds = statisticsList.stream().map(HardOrderCsVo::getMerchantId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(mchIds)) {
List<MchManageVo> mchList = remoteMchService.getByIds(mchIds).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId));
R<List<MchManageVo>> mchResponse = remoteMchService.getByIds(mchIds);
if (ObjectUtil.isNotEmpty(mchResponse) && mchResponse.isOk() && ObjectUtil.isNotEmpty(mchResponse.getData())) {
WalletDataUtil.convertMchList(statisticsList, CollectionUtils.convertMap(mchResponse.getData(), MchManageVo::getId));
}
}
// 商户部门
List<Long> mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList();
List<Long> mchDeptIds = statisticsList.stream().map(HardOrderCsVo::getMerchantDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(mchDeptIds)) {
Map<Long, SysDept> deptMap = remoteDeptService.getDeptByDeptIds(mchDeptIds).getData();
if (ObjectUtil.isNotEmpty(deptMap)) {
for (HardOrderCsVo v : totalList) {
R<Map<Long, SysDept>> deptResponse = remoteDeptService.getDeptByDeptIds(mchDeptIds);
if (ObjectUtil.isNotEmpty(deptResponse) && deptResponse.isOk() && ObjectUtil.isNotEmpty(deptResponse.getData())) {
Map<Long, SysDept> deptMap = deptResponse.getData();
for (HardOrderCsVo v : statisticsList) {
if (ObjectUtil.isEmpty(v.getMerchantDeptId())) continue;
v.setMerchantDeptName(deptMap.get(v.getMerchantDeptId()).getName());
SysDept dept = deptMap.get(v.getMerchantDeptId());
// 历史订单关联的商户部门可能已删除或已不在当前租户可见范围,不能因名称缺失阻断整页统计。
if (ObjectUtil.isNotEmpty(dept)) {
v.setMerchantDeptName(dept.getName());
}
}
}
}
}
return totalList;
return statisticsList;
}
@Override
@@ -393,11 +402,88 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
}
/**
* 部门交易记录
* 按用户部门汇总钱包操作、硬件消费、已完成退款和净消费数据。
*
* @param dto 当前用户权限范围内的部门、时间和钱包类型筛选条件
* @return 部门交易统计;筛选范围内没有部门时返回空列表
* @throws IllegalStateException 部门或钱包远程统计调用失败时抛出,避免返回不完整报表
*/
@Override
public List<HardDeptTotalVo> pageListDept(HardDeptTotalDto dto) {
// 查询部门
List<SysDept> deptList = queryDepartmentList(dto);
if (ObjectUtil.isEmpty(deptList)) {
return List.of();
}
List<WalletTransactionSumAndCountVo> walletBatchList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME));
List<WalletTransactionSumAndCountVo> walletReduceList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE));
List<WalletTransactionSumAndCountVo> walletReturnCanteenList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.RETURN_CANTEEN.getCode(), null));
List<WalletTransactionSumAndCountVo> walletOnceBatchList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME));
List<WalletTransactionSumAndCountVo> walletOnceReduceList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE));
List<HardOrderSumAndCountVo> payList = hardOrderMapper.sumAndCount(buildOrderStatisticsDto(dto, false));
List<HardOrderSumAndCountVo> refundList = hardOrderMapper.refundSumAndCount(buildOrderStatisticsDto(dto, true));
Map<Long, WalletTransactionSumAndCountVo> walletBatchMap = indexWalletStatistics(walletBatchList);
Map<Long, WalletTransactionSumAndCountVo> walletReduceMap = indexWalletStatistics(walletReduceList);
Map<Long, WalletTransactionSumAndCountVo> walletReturnCanteenMap = indexWalletStatistics(walletReturnCanteenList);
Map<Long, WalletTransactionSumAndCountVo> walletOnceBatchMap = indexWalletStatistics(walletOnceBatchList);
Map<Long, WalletTransactionSumAndCountVo> walletOnceReduceMap = indexWalletStatistics(walletOnceReduceList);
Map<Long, HardOrderSumAndCountVo> payMap = indexOrderStatistics(payList);
Map<Long, HardOrderSumAndCountVo> refundMap = indexOrderStatistics(refundList);
List<HardDeptTotalVo> resultList = new ArrayList<>(deptList.size());
for (SysDept dept : deptList) {
HardDeptTotalVo statistics = new HardDeptTotalVo();
statistics.setDeptId(dept.getDeptId());
statistics.setDeptName(dept.getName());
WalletTransactionSumAndCountVo walletBatch = walletBatchMap.get(dept.getDeptId());
if (walletBatch != null) {
statistics.setWalletPrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletBatch.getSum(), 0L)));
statistics.setWalletCount(ObjectUtil.defaultIfNull(walletBatch.getCount(), 0L));
}
WalletTransactionSumAndCountVo walletReduce = walletReduceMap.get(dept.getDeptId());
if (walletReduce != null) {
statistics.setWalletReducePrice(Math.abs(ObjectUtil.defaultIfNull(walletReduce.getSum(), 0L)));
statistics.setWalletReduceCount(ObjectUtil.defaultIfNull(walletReduce.getCount(), 0L));
}
WalletTransactionSumAndCountVo walletReturnCanteen = walletReturnCanteenMap.get(dept.getDeptId());
if (walletReturnCanteen != null) {
statistics.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs(
ObjectUtil.defaultIfNull(walletReturnCanteen.getSum(), 0L))));
statistics.setWalletReturnCanteenCount(ObjectUtil.defaultIfNull(walletReturnCanteen.getCount(), 0L));
}
WalletTransactionSumAndCountVo walletOnceBatch = walletOnceBatchMap.get(dept.getDeptId());
if (walletOnceBatch != null) {
statistics.setWalletOncePrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletOnceBatch.getSum(), 0L)));
statistics.setWalletOnceCount(ObjectUtil.defaultIfNull(walletOnceBatch.getCount(), 0L));
}
WalletTransactionSumAndCountVo walletOnceReduce = walletOnceReduceMap.get(dept.getDeptId());
if (walletOnceReduce != null) {
statistics.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs(
ObjectUtil.defaultIfNull(walletOnceReduce.getSum(), 0L))));
statistics.setWalletOnceReduceCount(ObjectUtil.defaultIfNull(walletOnceReduce.getCount(), 0L));
}
applyOrderStatistics(statistics, payMap.get(dept.getDeptId()), refundMap.get(dept.getDeptId()));
resultList.add(statistics);
}
return resultList;
}
/**
* 查询统计结果需要展示的部门,并校验 UPMS 远程调用结果。
*
* @param dto 部门包含或排除筛选条件
* @return 匹配的部门列表,远程响应数据为空时返回空列表
* @throws IllegalStateException UPMS 部门远程调用失败时抛出
*/
private List<SysDept> queryDepartmentList(HardDeptTotalDto dto) {
DeptAllListDto deptDto = new DeptAllListDto();
deptDto.setIsInclude(dto.getIsInclude());
if (ObjectUtil.isNotEmpty(dto.getDeptId())) {
@@ -407,186 +493,126 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
deptDto.setDeptId(null);
deptDto.setDeptIds(dto.getDeptIds());
}
List<SysDept> deptList = remoteDeptService.getInnerAllDept(deptDto).getData();
if (ObjectUtil.isEmpty(deptList)) return null;
// List<Long> deptIds = deptList.stream().map(SysDept::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
List<HardDeptTotalVo> resultList = deptList.stream().map(v -> {
HardDeptTotalVo item = new HardDeptTotalVo();
item.setDeptId(v.getDeptId());
item.setDeptName(v.getName());
return item;
}).toList();
// 查询钱包批量充值流水
CompletableFuture<List<WalletTransactionSumAndCountVo>> walletBatchFuture = CompletableFuture.supplyAsync(() -> {
WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto();
walletDto.setDeptIds(dto.getDeptIds());
walletDto.setIsInclude(dto.getIsInclude());
walletDto.setBizTypes(List.of(WalletBizEnum.BATCH.getCode()));
walletDto.setType(WalletTransactionSumAndCountDto.TYPE_INCOME);
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
walletDto.setCreateTimeList(dto.getCreateTimeList());
}
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
walletDto.setWalletTypeId(dto.getWalletTypeId());
}
return remoteWalletTransactionService.sumAndCount(walletDto).getData();
});
// 查询钱包批量扣减流水
CompletableFuture<List<WalletTransactionSumAndCountVo>> walletReduceFuture = CompletableFuture.supplyAsync(() -> {
WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto();
walletDto.setDeptIds(dto.getDeptIds());
walletDto.setIsInclude(dto.getIsInclude());
walletDto.setBizTypes(List.of(WalletBizEnum.BATCH.getCode()));
walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE);
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
walletDto.setCreateTimeList(dto.getCreateTimeList());
}
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
walletDto.setWalletTypeId(dto.getWalletTypeId());
}
return remoteWalletTransactionService.sumAndCount(walletDto).getData();
});
// 查询钱包退还各自食堂
CompletableFuture<List<WalletTransactionSumAndCountVo>> walletRetrunCanteenFuture = CompletableFuture.supplyAsync(() -> {
WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto();
walletDto.setDeptIds(dto.getDeptIds());
walletDto.setIsInclude(dto.getIsInclude());
walletDto.setBizTypes(List.of(WalletBizEnum.RETURN_CANTEEN.getCode()));
// walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE);
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
walletDto.setCreateTimeList(dto.getCreateTimeList());
}
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
walletDto.setWalletTypeId(dto.getWalletTypeId());
}
return remoteWalletTransactionService.sumAndCount(walletDto).getData();
});
// 查询钱包单次充值流水
CompletableFuture<List<WalletTransactionSumAndCountVo>> walletOnceBatchFuture = CompletableFuture.supplyAsync(() -> {
WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto();
walletDto.setDeptIds(dto.getDeptIds());
walletDto.setIsInclude(dto.getIsInclude());
walletDto.setBizTypes(List.of(WalletBizEnum.UPDATE.getCode()));
walletDto.setType(WalletTransactionSumAndCountDto.TYPE_INCOME);
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
walletDto.setCreateTimeList(dto.getCreateTimeList());
}
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
walletDto.setWalletTypeId(dto.getWalletTypeId());
}
return remoteWalletTransactionService.sumAndCount(walletDto).getData();
});
// 查询钱包单次扣减流水
CompletableFuture<List<WalletTransactionSumAndCountVo>> walletOnceReduceFuture = CompletableFuture.supplyAsync(() -> {
WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto();
walletDto.setDeptIds(dto.getDeptIds());
walletDto.setIsInclude(dto.getIsInclude());
walletDto.setBizTypes(List.of(WalletBizEnum.UPDATE.getCode()));
walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE);
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
walletDto.setCreateTimeList(dto.getCreateTimeList());
}
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
walletDto.setWalletTypeId(dto.getWalletTypeId());
}
return remoteWalletTransactionService.sumAndCount(walletDto).getData();
});
// 查询订单支付流水
CompletableFuture<List<HardOrderSumAndCountVo>> payFuture = CompletableFuture.supplyAsync(() -> {
HardOrderSumAndCountDto payDto = new HardOrderSumAndCountDto();
payDto.setUserDeptIds(dto.getDeptIds());
payDto.setIsInclude(dto.getIsInclude());
payDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
payDto.setCreateTimeList(dto.getCreateTimeList());
}
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
payDto.setWalletTypeId(dto.getWalletTypeId());
}
return hardOrderMapper.sumAndCount(payDto);
});
// 查询订单退款流水
CompletableFuture<List<HardOrderSumAndCountVo>> refundFuture = CompletableFuture.supplyAsync(() -> {
HardOrderSumAndCountDto refundDto = new HardOrderSumAndCountDto();
refundDto.setUserDeptIds(dto.getDeptIds());
refundDto.setIsInclude(dto.getIsInclude());
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes());
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
refundDto.setRefundTimeList(dto.getCreateTimeList());
}
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
refundDto.setWalletTypeId(dto.getWalletTypeId());
}
return hardOrderMapper.refundSumAndCount(refundDto);
});
CompletableFuture<Void> allFutures = CompletableFuture.allOf(walletBatchFuture, walletReduceFuture, payFuture, refundFuture, walletOnceBatchFuture, walletOnceReduceFuture);
allFutures.join();
List<WalletTransactionSumAndCountVo> walletBatchList = walletBatchFuture.join();
List<WalletTransactionSumAndCountVo> walletReduceList = walletReduceFuture.join();
List<WalletTransactionSumAndCountVo> walletRetrunCanteenList = walletRetrunCanteenFuture.join();
List<WalletTransactionSumAndCountVo> walletOnceBatchList = walletOnceBatchFuture.join();
List<WalletTransactionSumAndCountVo> walletOnceReduceList = walletOnceReduceFuture.join();
List<HardOrderSumAndCountVo> payList = payFuture.join();
List<HardOrderSumAndCountVo> refundList = refundFuture.join();
// 聚合一下数据
for (HardDeptTotalVo v : resultList) {
if (ObjectUtil.isNotEmpty(walletBatchList)) {
walletBatchList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> {
v.setWalletPrice(BigDecimal.valueOf(i.getSum()));
v.setWalletCount(i.getCount());
});
}
if (ObjectUtil.isNotEmpty(walletReduceList)) {
walletReduceList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> {
v.setWalletReducePrice(Math.abs(i.getSum()));
v.setWalletReduceCount(i.getCount());
});
}
// 退还各自食堂
if (ObjectUtil.isNotEmpty(walletRetrunCanteenList)) {
walletRetrunCanteenList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> {
v.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs(i.getSum())));
v.setWalletReturnCanteenCount(i.getCount());
});
}
if (ObjectUtil.isNotEmpty(walletOnceBatchList)) {
walletOnceBatchList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> {
v.setWalletOncePrice(BigDecimal.valueOf(i.getSum()));
v.setWalletOnceCount(i.getCount());
});
}
if (ObjectUtil.isNotEmpty(walletOnceReduceList)) {
walletOnceReduceList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> {
v.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs(i.getSum())));
v.setWalletOnceReduceCount(i.getCount());
});
}
if (ObjectUtil.isNotEmpty(payList)) {
payList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> {
v.setOrderPayPrice(BigDecimal.valueOf(i.getSum()));
v.setOrderPayCount(i.getCount());
});
}
if (ObjectUtil.isNotEmpty(refundList)) {
refundList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> {
v.setOrderRefundPrice(BigDecimal.valueOf(i.getSum()));
v.setOrderRefundCont(i.getCount());
});
R<List<SysDept>> response = remoteDeptService.getInnerAllDept(deptDto);
if (response == null || !response.isOk()) {
throw new IllegalStateException("查询部门交易统计的部门信息失败");
}
return response.getData() == null ? List.of() : response.getData();
}
/**
* 构建钱包部门统计条件,保证五类钱包统计使用完全一致的权限、时间和钱包类型范围。
*
* @param dto 部门统计页面筛选条件
* @param bizType 钱包流水业务类型编码
* @param type 收支方向;为空时不限制流水正负方向
* @return 钱包流水汇总条件
*/
private WalletTransactionSumAndCountDto buildWalletStatisticsDto(HardDeptTotalDto dto, String bizType, Integer type) {
WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto();
walletDto.setDeptIds(dto.getDeptIds());
walletDto.setIsInclude(dto.getIsInclude());
walletDto.setBizTypes(List.of(bizType));
walletDto.setType(type);
walletDto.setCreateTimeList(dto.getCreateTimeList());
walletDto.setWalletTypeId(dto.getWalletTypeId());
return walletDto;
}
/**
* 调用钱包服务查询部门流水统计;远程业务失败时终止本次汇总,避免把失败数据误展示为零。
*
* @param walletDto 钱包流水汇总条件
* @return 钱包部门统计,远程响应数据为空时返回空列表
* @throws IllegalStateException 钱包流水远程调用失败时抛出
*/
private List<WalletTransactionSumAndCountVo> queryWalletStatistics(WalletTransactionSumAndCountDto walletDto) {
R<List<WalletTransactionSumAndCountVo>> response = remoteWalletTransactionService.sumAndCount(walletDto);
if (response == null || !response.isOk()) {
throw new IllegalStateException("查询钱包部门交易统计失败");
}
return response.getData() == null ? List.of() : response.getData();
}
/**
* 构建硬件订单部门统计条件。退款按售后完成时间统计,并覆盖支付、核销及整单退款状态,
* 因为部分退款不会把原订单状态改成整单退款。
*
* @param dto 部门统计页面筛选条件
* @param refund 是否查询已完成退款
* @return 支付或退款汇总条件
*/
private HardOrderSumAndCountDto buildOrderStatisticsDto(HardDeptTotalDto dto, boolean refund) {
HardOrderSumAndCountDto orderDto = new HardOrderSumAndCountDto();
orderDto.setUserDeptIds(dto.getDeptIds());
orderDto.setIsInclude(dto.getIsInclude());
orderDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
orderDto.setWalletTypeId(dto.getWalletTypeId());
if (refund) {
orderDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
orderDto.setRefundTimeList(dto.getCreateTimeList());
} else {
orderDto.setCreateTimeList(dto.getCreateTimeList());
}
return orderDto;
}
/**
* 将钱包统计按部门建立索引,避免每个部门重复遍历全部统计结果。
*
* @param statisticsList 钱包部门统计列表
* @return 以部门 ID 为键的统计索引
*/
private Map<Long, WalletTransactionSumAndCountVo> indexWalletStatistics(List<WalletTransactionSumAndCountVo> statisticsList) {
Map<Long, WalletTransactionSumAndCountVo> statisticsMap = new LinkedHashMap<>();
if (ObjectUtil.isNotEmpty(statisticsList)) {
for (WalletTransactionSumAndCountVo statistics : statisticsList) {
if (statistics != null && statistics.getDeptId() != null) {
statisticsMap.put(statistics.getDeptId(), statistics);
}
}
}
// 计算净流水
for (HardDeptTotalVo v : resultList) {
// 流水金额
if (ObjectUtil.isNotEmpty(v.getOrderPayPrice())) {
v.setOrderProfitPrice(v.getOrderPayPrice().subtract(v.getOrderRefundPrice()));
}
// 流水次数
if (ObjectUtil.isNotEmpty(v.getOrderPayCount())) {
v.setOrderProfitCont(v.getOrderPayCount() - v.getOrderRefundCont());
return statisticsMap;
}
/**
* 将订单统计按部门建立索引,供支付和退款结果按相同键合并。
*
* @param statisticsList 订单部门统计列表
* @return 以部门 ID 为键的统计索引
*/
private Map<Long, HardOrderSumAndCountVo> indexOrderStatistics(List<HardOrderSumAndCountVo> statisticsList) {
Map<Long, HardOrderSumAndCountVo> statisticsMap = new LinkedHashMap<>();
if (ObjectUtil.isNotEmpty(statisticsList)) {
for (HardOrderSumAndCountVo statistics : statisticsList) {
if (statistics != null && statistics.getDeptId() != null) {
statisticsMap.put(statistics.getDeptId(), statistics);
}
}
}
return resultList;
return statisticsMap;
}
/**
* 合并部门的订单支付与已完成退款,并按原接口口径计算净金额和净次数。
*
* @param target 部门统计结果
* @param pay 支付统计,允许为空
* @param refund 已完成售后退款统计,允许为空
*/
private void applyOrderStatistics(HardDeptTotalVo target, HardOrderSumAndCountVo pay, HardOrderSumAndCountVo refund) {
long payPrice = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getSum(), 0L);
long payCount = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getCount(), 0L);
long refundPrice = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getSum(), 0L);
long refundCount = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getCount(), 0L);
BigDecimal orderPayPrice = BigDecimal.valueOf(payPrice);
BigDecimal orderRefundPrice = BigDecimal.valueOf(refundPrice);
target.setOrderPayPrice(orderPayPrice);
target.setOrderPayCount(payCount);
target.setOrderRefundPrice(orderRefundPrice);
target.setOrderRefundCont(refundCount);
target.setOrderProfitPrice(orderPayPrice.subtract(orderRefundPrice));
target.setOrderProfitCont(payCount - refundCount);
}
/**
@@ -87,17 +87,36 @@
o.total_price as orderTotalPrice,
o.extra_price as orderExtraPrice,
o.pay_price as orderPayPrice,
o.pay_time as orderPayTime
o.pay_time as orderPayTime,
o.appt_use_date as orderApptUseDate
from hard_after_sale a
left join hard_order o on o.id = a.order_id
<where>
<include refid="whereA"/>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderType != null and dto.orderType != ''">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.hourTypeCodes != null and dto.hourTypeCodes.size() != 0">
and o.hour_type in
<foreach collection="dto.hourTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
and o.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]}
</if>
</where>
order by a.create_time desc,a.id desc
</select>
@@ -976,7 +976,7 @@
order by a.product_id desc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc
</select>
<!-- 新版菜品明细按来源查询,供主表“菜品详情”弹窗使用。 -->
<!-- 新版菜品明细按来源查询,供主表“菜品详情”弹窗使用,并按商品名称升序稳定展示。 -->
<select id="proudctQueryStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
select sale_stats.product_id,
sale_stats.product_name,
@@ -995,7 +995,10 @@
</where>
group by sale_stats.stat_date, sale_stats.sale_source, sale_stats.product_id,
sale_stats.product_name, sale_stats.product_pic, sale_stats.product_price
order by SUM(sale_stats.product_count) desc
order by sale_stats.product_name asc,
sale_stats.product_id asc,
sale_stats.stat_date asc,
sale_stats.sale_source asc
</select>
<select id="proudctMchQuery" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
@@ -656,9 +656,10 @@
<if test="dto.refundTimeList != null and dto.refundTimeList.length == 2">
and r.audit_time between #{dto.refundTimeList[0]} and #{dto.refundTimeList[1]}
</if>
<if test="dto.refundStatusCodes != null and dto.refundStatusCodes.size() != 0">
<!-- refundStatusCodes 由 whereA 专门筛选订单退款状态;售后单状态必须使用独立的 saleStatusCodes。 -->
<if test="dto.saleStatusCodes != null and dto.saleStatusCodes.size() != 0">
and r.status in
<foreach collection="dto.refundStatusCodes" item="v" open="(" close=")" separator=",">
<foreach collection="dto.saleStatusCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
@@ -14,60 +14,190 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* 钱包服务商户管理远程调用接口。
*
* <p>用于跨服务查询商户详情、部门与商户映射关系,以及当前用户可管理的商户范围。
* 调用方必须同时检查 {@link R} 的业务状态和数据内容,不能仅以 Feign 调用未抛异常作为成功依据。</p>
*/
@FeignClient(contextId = "remoteMchService", value = ServiceNameConstants.WALLET_SERVICE)
public interface RemoteMchService {
/**
* 按商户条件查询列表,不应用当前登录用户的数据权限。
*
* @param body 商户筛选条件,可按主键、部门、类型、父商户等字段筛选
* @return 商户列表响应;未匹配到数据时响应成功且数据可能为空
*/
@NoToken
@PostMapping("/mch-manage/list")
R<List<MchManageVo>> list(@RequestBody MchManageDto body);
/**
* 按商户条件查询当前登录用户数据权限范围内的商户列表。
*
* @param body 商户筛选条件,可按主键、部门、类型、父商户等字段筛选
* @return 权限范围内的商户列表响应;未匹配到数据时响应成功且数据可能为空
*/
@PostMapping("/mch-manage/scope-list")
R<List<MchManageVo>> scopeList(@RequestBody MchManageDto body);
/**
* 按多个商户类型编码查询商户列表。
*
* @param types 商户类型持久化编码集合,对应 {@link MerchantTypeEnum#getType()}
* @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-by-types")
R<List<MchManageVo>> getByTypes(@RequestParam List<String> types);
/**
* 按商户主键查询商户完整信息。
*
* <p>返回内容包含部门名称、启用的支付配置以及钱包别名等关联信息。</p>
*
* @param id 商户主键
* @return 商户详情响应;商户不存在时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-by-id")
R<MchManageVo> getById(@RequestParam Long id);
/**
* 查询指定父商户的直属子商户。
*
* @param parentId 父商户主键
* @return 直属子商户列表响应;没有子商户时返回成功的空集合
*/
@NoToken
@GetMapping("/mch-manage/get-by-parent-id")
R<List<MchManageVo>> getByParentId(@RequestParam Long parentId);
/**
* 按商户主键集合批量查询商户。
*
* @param ids 商户主键集合
* @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-by-ids")
R<List<MchManageVo>> getByIds(@RequestParam List<Long> ids);
/**
* 查询直接绑定在指定部门上的指定类型商户。
*
* <p>该方法只匹配商户自身绑定的部门,不沿部门父级或子级关系查找。</p>
*
* @param deptId 商户直接绑定的系统部门主键
* @param type 商户类型枚举
* @return 匹配的商户响应;商户不存在时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-by-dept-id")
R<MchManageVo> getByDeptId(@RequestParam("deptId") Long deptId, @RequestParam("type") MerchantTypeEnum type);
/**
* 按商户直接绑定的部门主键集合批量查询商户。
*
* @param deptIds 系统部门主键集合
* @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-by-dept-ids")
R<List<MchManageVo>> getByDeptIds(@RequestParam List<Long> deptIds);
/**
* 按传入部门顺序查询第一个直接绑定且类型匹配的商户。
*
* <p>该方法不会自行查询部门层级,调用方需要传入已经按业务优先级排列的部门集合。</p>
*
* @param deptIds 待匹配的系统部门主键集合,集合顺序决定商户匹配优先级
* @param mchType 允许匹配的商户类型
* @return 首个匹配商户的响应;没有匹配商户时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-first-mch")
R<MchManageVo> getFirstMch(@RequestParam List<Long> deptIds, @RequestParam MerchantTypeEnum mchType);
/**
* 沿指定部门的父级链查询第一个类型匹配的商户。
*
* <p>服务端先解析部门父级链,再按部门链顺序选择直接绑定的商户;返回内容包含启用的支付配置。</p>
*
* @param deptId 起始系统部门主键
* @param mchType 允许匹配的商户类型
* @return 首个匹配商户的响应;没有匹配商户时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-first-mch-by-dept-id")
R<MchManageVo> getFirstMchByDeptId(@RequestParam Long deptId, @RequestParam MerchantTypeEnum mchType);
/**
* 通过商户部门关系视图查询指定部门归属的食堂商户。
*
* <p>可从商户绑定部门的下级部门反向定位食堂商户,并优先返回关系层级最近的商户;
* 返回内容包含启用的支付配置。</p>
*
* @param deptId 待解析归属关系的系统部门主键
* @return 归属食堂商户响应;不存在归属食堂时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-views-canteen-mch-by-dept-id")
R<MchManageVo> getViewsCanteenMchByDeptId(@RequestParam Long deptId);
/**
* 通过商户部门关系视图查询指定部门归属的指定类型商户。
*
* <p>可从商户绑定部门的下级部门反向定位商户,并优先返回关系层级最近的匹配项;
* 返回内容包含启用的支付配置。</p>
*
* @param deptId 待解析归属关系的系统部门主键
* @param mchTypeList 允许匹配的商户类型集合
* @return 归属商户响应;不存在匹配商户时返回业务失败响应
*/
@NoToken
@GetMapping("/mch-manage/get-views-mch-by-dept-id")
R<MchManageVo> getViewsMchByDeptId(@RequestParam Long deptId, @RequestParam List<MerchantTypeEnum> mchTypeList);
/**
* 查询指定食堂商户覆盖的用户部门主键集合。
*
* <p>服务端以商户直接绑定部门及其下级部门为候选范围,再通过商户部门关系视图筛选
* 与该商户类型关联的用户部门。商户不存在或没有关联部门时返回业务失败响应。</p>
*
* @param mchId 食堂商户主键
* @return 去重后的用户部门主键集合响应
*/
@NoToken
@GetMapping("/mch-manage/get-self-canteen-user-dept-id-list")
R<List<Long>> getSelfCanteenUserDeptIdList(@RequestParam Long mchId);
/**
* 查询指定用户所属目标类型商户覆盖的用户部门主键集合。
*
* <p>钱包服务内部先根据用户所属部门和商户类型定位商户,再复用商户部门范围查询逻辑,
* 调用方只需发起一次远程调用。用户、所属部门、匹配商户或关联部门不存在时返回业务失败响应。</p>
*
* @param userId 系统用户主键
* @param mchTypeList 允许匹配的商户类型集合
* @return 去重后的目标商户用户部门主键集合响应
*/
@NoToken
@GetMapping("/mch-manage/get-self-mch-user-dept-id-list-by-user-id")
R<List<Long>> getSelfMchUserDeptIdListByUserId(@RequestParam Long userId, @RequestParam List<MerchantTypeEnum> mchTypeList);
/**
* 查询指定用户可管理的指定类型商户主键集合。
*
* <p>服务端以用户所属部门及其下级部门作为权限范围,通过商户部门关系视图匹配商户。
* 当用户信息不存在或没有匹配商户时,当前实现返回包含 {@code -1L} 的占位集合,调用方
* 不应将该值作为有效商户主键使用。</p>
*
* @param userId 系统用户主键
* @param mchTypeList 允许匹配的商户类型集合
* @return 用户可管理的商户主键集合响应;无匹配数据时可能仅包含 {@code -1L}
*/
@NoToken
@GetMapping("/mch-manage/get-self-mch-id-list")
R<List<Long>> getSelfMchIdList(@RequestParam Long userId, @RequestParam List<MerchantTypeEnum> mchTypeList);
@@ -160,6 +160,22 @@ public class MchManageController {
return R.ok(userDeptIdList);
}
/**
* 查询指定用户所属目标类型商户覆盖的用户部门主键集合。
*
* @param userId 系统用户主键
* @param mchTypeList 允许匹配的商户类型集合
* @return 目标商户覆盖的用户部门主键集合;无法定位用户、匹配商户或关联部门时返回业务失败响应
*/
@Inner
@GetMapping("/get-self-mch-user-dept-id-list-by-user-id")
public R<List<Long>> getSelfMchUserDeptIdListByUserId(@RequestParam Long userId,
@RequestParam List<MerchantTypeEnum> mchTypeList) {
List<Long> userDeptIdList = mchManageService.getSelfMchUserDeptIdListByUserId(userId, mchTypeList);
if (ObjectUtil.isEmpty(userDeptIdList)) return R.failed("商户不存在");
return R.ok(userDeptIdList);
}
@Inner
@GetMapping("/get-self-mch-id-list")
public R<List<Long>> getSelfMchIdList(@RequestParam Long userId, @RequestParam List<MerchantTypeEnum> mchTypeList) {
@@ -72,6 +72,15 @@ public interface MchManageService extends IService<MchManageEntity> {
*/
List<Long> getSelfCanteenUserDeptIdList(Long mchId);
/**
* 根据用户主键和商户类型查询所属商户覆盖的用户部门主键集合。
*
* @param userId 系统用户主键
* @param mchTypeList 允许匹配的商户类型集合
* @return 目标商户覆盖的用户部门主键集合;用户、匹配商户或关联部门不存在时返回空集合
*/
List<Long> getSelfMchUserDeptIdListByUserId(Long userId, List<MerchantTypeEnum> mchTypeList);
/**
* 判断用户是否能查看到所有食堂
*/
@@ -560,6 +560,33 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
return selfMchIdList;
}
/**
* 根据用户主键和商户类型查询所属商户覆盖的用户部门主键集合。
*
* <p>先通过用户所属部门和允许的商户类型定位最近的匹配商户,再复用按商户查询覆盖部门的逻辑,
* 将原本由调用方完成的两段查询收敛到钱包服务内部。</p>
*
* @param userId 系统用户主键
* @param mchTypeList 允许匹配的商户类型集合
* @return 目标商户覆盖的用户部门主键集合;用户、所属部门、匹配商户或关联部门不存在时返回空集合
*/
@Override
public List<Long> getSelfMchUserDeptIdListByUserId(Long userId, List<MerchantTypeEnum> mchTypeList) {
if (userId == null || ObjectUtil.isEmpty(mchTypeList)) {
return List.of();
}
R<UserInfo> userResponse = remoteUserService.getInnerByUserId(userId);
if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null
|| userResponse.getData().getSysUser() == null || userResponse.getData().getSysUser().getDeptId() == null) {
return List.of();
}
MchManageVo merchant = getViewsCanteenMch(userResponse.getData().getSysUser().getDeptId(), mchTypeList);
if (merchant == null || merchant.getId() == null) {
return List.of();
}
return getSelfCanteenUserDeptIdList(merchant.getId());
}
/**
* 判断用户是否能查看到所有食堂
*/