diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java index 828d157ca..e113b1308 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java @@ -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 userIds; /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ @@ -27,8 +29,22 @@ public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIde /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ private String hasFace; + /** 售后申请日期闭区间;为空时不限制申请日期。 */ private LocalDate[] createTimeList; + /** 当前账号数据范围内允许查询的订单商户主键集合;为空时不限制商户。 */ private List mchIds; + /** 关联订单的状态字典编码集合;为空时不限制订单状态。 */ + private List orderStatusCodes; + + /** 关联订单的类型字典编码;为空时不限制订单类型。 */ + private String orderType; + + /** 关联订单的用餐餐段字典编码集合;为空时不限制餐段。 */ + private List hourTypeCodes; + + /** 关联订单预计使用日期闭区间,页面按开始日零点至结束日最后一秒传入。 */ + private LocalDateTime[] apptUseDateTimeList; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java new file mode 100644 index 000000000..a0e120b22 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java new file mode 100644 index 000000000..bac72a757 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java index c977c2123..d1dc72869 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java @@ -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; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java index b67f20606..837f29058 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java @@ -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; + /** * 当前售后单申请时保存的商品快照,用于审批详情展示本次退款商品及份数;历史未保存明细时为空列表。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java new file mode 100644 index 000000000..3ff8bac2d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java @@ -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 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 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; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java index cff3371a7..4de7cf12f 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java @@ -21,6 +21,10 @@ public class HardCanteenUserApptDishStatisticsVo { @Schema(description = "菜品名称") private String productName; + /** 当前菜品统计项实际涉及的商品 ID;正常数据仅有一个,历史商品 ID 为空时不写入集合。 */ + @Schema(description = "商品ID集合") + private List productIds = new ArrayList<>(); + /** 下单时固化的菜品图片地址;历史订单未保存图片时为空。 */ @Schema(description = "菜品图片地址") private String productPic; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java index 0c85a2004..88ebbaea5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java @@ -47,7 +47,7 @@ public class HardCanteenUserApptStatisticsSummaryVo { @Schema(description = "餐段统计") private List meals = new ArrayList<>(); - /** 按餐段和菜品名称聚合的菜品份数。 */ + /** 按餐段和商品 ID 聚合的菜品份数;历史商品 ID 缺失时按菜品名称兜底。 */ @Schema(description = "菜品统计") private List dishes = new ArrayList<>(); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 833c0015e..9f8b3a75e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -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> appHistory(@RequestParam Long id) { + public R> 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 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 submitBatchRefund(@Valid @RequestBody HardBatchRefundSubmitDto dto) { + return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交"); + } + @GetMapping(value = "/history") @Operation(summary = "查询售后进度") public R> history(@RequestParam Long id) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java index 2ca224f65..eb5be4a09 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java @@ -100,14 +100,37 @@ public class HardCanteenApptTotalController { *

食堂主键不信任客户端输入,而是根据当前商户管理员重新计算;服务层只统计有效预约订单, * 并按已完成退款扣减后的净菜品份数聚合。

* - * @param query 预约用餐日期范围和餐段筛选条件 - * @return 当前账号可管理食堂内的菜品和点餐人员汇总;没有可管理食堂时返回空汇总 + * @param query 预约日期、餐段和核销状态筛选条件 + * @return 当前账号可管理食堂内与 PC 核销状态口径一致的菜品和点餐人员汇总 */ @Operation(summary = "App预约菜品人员统计", description = "按当前账号可管理食堂统计预约菜品和点餐人员净份数") @ApiCryptoAes @PostMapping("/app/user-reserv-statistics") public R 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 appUserReservationDailyStatistics( + @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return R.ok(canteenApptTotalService.appUserReservationDailyStatistics(query, resolveAppCanteenMerchantIds())); + } + + /** + * 解析当前 App 商户管理员可管理的食堂主键,客户端提交的食堂范围不参与授权判定。 + * + * @return 当前账号可管理的食堂主键;没有可管理食堂时返回空集合 + */ + private List 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 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 ************************************* */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java index db2cebc91..6451c2a53 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java @@ -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> pageListDept(@RequestBody HardDeptTotalDto dto) { + if (!applyCanteenDepartmentScope(dto)) { + return R.ok(List.of()); + } return R.ok(hardOrderTotalService.pageListDept(dto)); } + /** + * 将部门统计条件限制到当前用户可管理的食堂商户所属部门。 + * + *

钱包服务根据当前用户主键和食堂商户类型一次性返回其所属食堂覆盖的用户部门。页面传入的 + * 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合,避免空集合或排除 + * 条件导致底层统计退化为全量查询。

+ * + * @param dto 待收敛的部门统计条件,方法会写入最终允许查询的部门 ID 集合 + * @return 是否仍存在可查询部门;false 表示调用方必须直接返回空数据 + */ + private boolean applyCanteenDepartmentScope(HardDeptTotalDto dto) { + PjcloudUser user = SecurityUtils.getUser(); + if (user == null || user.getId() == null) { + return false; + } + + R> departmentResponse = mchService.getSelfMchUserDeptIdListByUserId( + user.getId(), List.of(MerchantTypeEnum.CANTEEN)); + if (departmentResponse == null || !departmentResponse.isOk() || ObjectUtil.isEmpty(departmentResponse.getData())) { + return false; + } + List allowedDeptIds = departmentResponse.getData().stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (ObjectUtil.isEmpty(allowedDeptIds)) { + return false; + } + + List requestedDeptIds = ObjectUtil.isNotEmpty(dto.getDeptIds()) + ? dto.getDeptIds() + : ObjectUtil.isNotEmpty(dto.getDeptId()) ? List.of(dto.getDeptId()) : List.of(); + List scopedDeptIds = allowedDeptIds; + if (ObjectUtil.isNotEmpty(requestedDeptIds)) { + Set 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 list = hardOrderTotalService.pageListDept(dto); + List 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(); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java index 26a42a9f3..e5fbe1cf0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java @@ -96,7 +96,7 @@ public interface HardCarMapper extends PjcloudBaseMapper { * * @param page 分页参数 * @param dto 单日、来源、食堂、餐段、部门和商品名称筛选 - * @return 按商品聚合的来源明细页 + * @return 按商品名称、商品 ID、统计日期和来源升序排列的商品聚合明细页 */ Page proudctQueryStats(Page page, @Param("dto") HardCarDto dto); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java index 77f024c45..17e8913e1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java @@ -47,6 +47,15 @@ public interface HardAfterSaleCarService extends IService getOccupiedByCarParentId(Long carParentId, List statuses); + /** + * 批量查询多个父购物车在指定售后状态下的商品占用明细,避免退款预览逐订单访问数据库。 + * + * @param carParentIds 父购物车主键集合,空集合不发起数据库查询 + * @param statuses 需要计入占用的售后状态编码集合 + * @return 父购物车主键到商品聚合明细的映射;无占用的购物车不出现在映射中 + */ + Map> getOccupiedByCarParentIds(List carParentIds, List statuses); + /** * 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 156b251b3..5913bdd38 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -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 { */ List getHistory(Long id); + /** + * 获取 App 售后进度,并仅在申请、拒绝或直接退款节点补充对应原因。 + * + * @param id 售后单 ID + * @return App 可见的售后进度记录 + */ + List getAppHistory(Long id); + /** * 退款 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java new file mode 100644 index 000000000..e863919f2 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java @@ -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); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java index adee4d5a1..4c3734c34 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java @@ -57,11 +57,21 @@ public interface HardCanteenApptTotalService { /** * 按 App 当前账号可管理的食堂范围统计有效预约菜品及点餐人员。 * - * @param query 预约用餐日期范围和餐段筛选条件 + * @param query 预约日期、餐段和核销状态筛选条件 * @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合 - * @return 已完成退款扣减后的菜品和点餐人员汇总;食堂范围为空时返回空汇总 + * @return 与 PC 端核销状态口径一致且已完成退款扣减的菜品和点餐人员汇总 */ HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( HardCanteenUserApptQueryDto query, List merchantIds); + /** + * 按 App 当前账号可管理的食堂范围统计每日预约及查询范围总计。 + * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合 + * @return 与 PC 端核销状态口径一致的逐日预约统计 + */ + HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics( + HardCanteenUserApptQueryDto query, List merchantIds); + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java index 539dc118f..b634f089b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleCarServiceImpl.java @@ -72,6 +72,26 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl> getOccupiedByCarParentIds(List carParentIds, + List statuses) { + if (ObjectUtil.isEmpty(carParentIds) || ObjectUtil.isEmpty(statuses)) { + return Map.of(); + } + Map> 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; + } + /** * 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index e1c1136bb..a5765cbf9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -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 getAppHistory(Long id) { + List 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(); + } + /** * 使用默认退款单号前缀执行售后退款。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java new file mode 100644 index 000000000..9254e9d8e --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchRefundServiceImpl.java @@ -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 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 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 normalizeOrderIds(List orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + throw new IllegalArgumentException("请选择需要退款的订单"); + } + List 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 orderIds, PjcloudUser user) { + List scopedOrders = loadScopedOrders(orderIds, user); + Map orderMap = scopedOrders.stream().collect(Collectors.toMap(HardOrderVo::getId, + item -> item, (first, second) -> first, LinkedHashMap::new)); + if (orderMap.size() != orderIds.size()) { + throw new IllegalStateException("部分订单不存在或已超出当前数据权限,请刷新列表后重试"); + } + + Map> afterSalesByOrderId = loadEffectiveAfterSales(orderIds); + List parentCarIds = scopedOrders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + Map> occupiedCarsByParentId = + hardAfterSaleCarService.getOccupiedByCarParentIds(parentCarIds, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())); + + List items = new ArrayList<>(orderIds.size()); + for (Long orderId : orderIds) { + HardOrderVo order = orderMap.get(orderId); + List afterSales = afterSalesByOrderId.getOrDefault(orderId, List.of()); + List 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 loadScopedOrders(List 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> loadEffectiveAfterSales(List orderIds) { + return hardAfterSaleService.list(new LambdaQueryWrapper() + .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 afterSales, + List 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 buildRemainingProducts(List originalCars, + List occupiedCars) { + if (ObjectUtil.isEmpty(originalCars)) return List.of(); + Map occupiedByProductId = occupiedCars.stream() + .filter(item -> item != null && item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item, + (first, second) -> first, LinkedHashMap::new)); + Map 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 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 afterSales, + List 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 items) { + HardBatchRefundPreviewVo.Summary summary = new HardBatchRefundPreviewVo.Summary(); + List refundableItems = items.stream() + .filter(item -> Boolean.TRUE.equals(item.getRefundable())).toList(); + Set userIds = refundableItems.stream().map(HardBatchRefundPreviewVo.OrderItem::getUserId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Set 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 orders, String remark, + Long operatorId, AsyncTaskProgressReporter reporter) { + List 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 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 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; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 91ee55f91..4d7c06eb1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -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 *

与 PC 人员预约统计不同,App 商户视角以可管理食堂中的有效订单为主数据源, * 再按订单用户主键批量补齐姓名和部门,避免商户账号的组织数据权限漏掉实际点餐人员。

* - * @param query 预约用餐日期范围和餐段筛选条件 + * @param query 预约日期、餐段和核销状态筛选条件 * @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围 - * @return 完成退款扣减后的菜品及点餐人员汇总 + * @return 与 PC 端核销状态口径一致且完成退款扣减后的菜品及点餐人员汇总 */ @Override public HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( HardCanteenUserApptQueryDto query, List merchantIds) { + return appUserReservationDailyStatistics(query, merchantIds).getTotal(); + } + + /** + * 按 App 当前账号可管理食堂统计每日预约及查询范围总计。 + * + *

订单先按商户范围、日期和餐段读取,再复用 PC 人员预约页的核销状态判定筛选用户日期行, + * 保证待核销、部分核销和已核销的协议值及统计日期口径一致。

+ * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围 + * @return 按统计日期升序排列的每日预约统计及范围总计 + */ + @Override + public HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics( + HardCanteenUserApptQueryDto query, List merchantIds) { validateQuery(query); List 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 orders = retainEffectiveReservationOrders( hardOrderMapper.scopeList(orderQuery, null), query); if (orders.isEmpty()) { - return new HardCanteenUserApptStatisticsSummaryVo(); + return new HardCanteenUserApptStatisticsVo(); } List userIds = orders.stream().map(HardOrderVo::getUserId) .filter(Objects::nonNull).distinct().toList(); List users = fetchUsersByIds(userIds); - List rows = buildRows(users, query, getQueryDates(query), orders).stream() + List queryDates = getQueryDates(query); + List rows = filterRows(buildRows(users, query, queryDates, orders), query).stream() .filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList(); Map orderMap = orders.stream().filter(order -> order.getId() != null) .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); Map> carMap = fetchReservationCars(orders, query); - StatisticsBucket bucket = new StatisticsBucket(); - rows.forEach(row -> bucket.addRow(row, orderMap, carMap)); - return bucket.toSummary(); + + Map 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 meals = new LinkedHashMap<>(); - /** 餐段和菜品名称到菜品聚合结果的有序映射。 */ + /** 餐段和商品身份到菜品聚合结果的有序映射;商品 ID 缺失时使用名称兼容历史数据。 */ private final Map 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 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()); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java index 735eaa76c..d41437794 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java @@ -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 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 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 refundList = hardOrderMapper.listRefundCs(refundDto, refundDataScope); - // 有退款没有流水的情况 - if (ObjectUtil.isAllNotEmpty(totalList, refundList)) { - List 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 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 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 mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList(); + List mchIds = statisticsList.stream().map(HardOrderCsVo::getMerchantId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { - List mchList = remoteMchService.getByIds(mchIds).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId)); + R> mchResponse = remoteMchService.getByIds(mchIds); + if (ObjectUtil.isNotEmpty(mchResponse) && mchResponse.isOk() && ObjectUtil.isNotEmpty(mchResponse.getData())) { + WalletDataUtil.convertMchList(statisticsList, CollectionUtils.convertMap(mchResponse.getData(), MchManageVo::getId)); } } // 商户部门 - List mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList(); + List mchDeptIds = statisticsList.stream().map(HardOrderCsVo::getMerchantDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(mchDeptIds)) { - Map deptMap = remoteDeptService.getDeptByDeptIds(mchDeptIds).getData(); - if (ObjectUtil.isNotEmpty(deptMap)) { - for (HardOrderCsVo v : totalList) { + R> deptResponse = remoteDeptService.getDeptByDeptIds(mchDeptIds); + if (ObjectUtil.isNotEmpty(deptResponse) && deptResponse.isOk() && ObjectUtil.isNotEmpty(deptResponse.getData())) { + Map 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 pageListDept(HardDeptTotalDto dto) { - // 查询部门 + List deptList = queryDepartmentList(dto); + if (ObjectUtil.isEmpty(deptList)) { + return List.of(); + } + + List walletBatchList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME)); + List walletReduceList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); + List walletReturnCanteenList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.RETURN_CANTEEN.getCode(), null)); + List walletOnceBatchList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME)); + List walletOnceReduceList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); + + List payList = hardOrderMapper.sumAndCount(buildOrderStatisticsDto(dto, false)); + List refundList = hardOrderMapper.refundSumAndCount(buildOrderStatisticsDto(dto, true)); + + Map walletBatchMap = indexWalletStatistics(walletBatchList); + Map walletReduceMap = indexWalletStatistics(walletReduceList); + Map walletReturnCanteenMap = indexWalletStatistics(walletReturnCanteenList); + Map walletOnceBatchMap = indexWalletStatistics(walletOnceBatchList); + Map walletOnceReduceMap = indexWalletStatistics(walletOnceReduceList); + Map payMap = indexOrderStatistics(payList); + Map refundMap = indexOrderStatistics(refundList); + + List 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 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 deptList = remoteDeptService.getInnerAllDept(deptDto).getData(); - if (ObjectUtil.isEmpty(deptList)) return null; - // List deptIds = deptList.stream().map(SysDept::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - List resultList = deptList.stream().map(v -> { - HardDeptTotalVo item = new HardDeptTotalVo(); - item.setDeptId(v.getDeptId()); - item.setDeptName(v.getName()); - return item; - }).toList(); - // 查询钱包批量充值流水 - CompletableFuture> 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> 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> 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> 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> 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> 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> 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 allFutures = CompletableFuture.allOf(walletBatchFuture, walletReduceFuture, payFuture, refundFuture, walletOnceBatchFuture, walletOnceReduceFuture); - allFutures.join(); - List walletBatchList = walletBatchFuture.join(); - List walletReduceList = walletReduceFuture.join(); - List walletRetrunCanteenList = walletRetrunCanteenFuture.join(); - List walletOnceBatchList = walletOnceBatchFuture.join(); - List walletOnceReduceList = walletOnceReduceFuture.join(); - List payList = payFuture.join(); - List 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> 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 queryWalletStatistics(WalletTransactionSumAndCountDto walletDto) { + R> 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 indexWalletStatistics(List statisticsList) { + Map 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 indexOrderStatistics(List statisticsList) { + Map 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); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml index 1d88329d5..cd0126088 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml @@ -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 - + and o.merchant_id in #{v} + + and o.order_status in + + #{v} + + + + and o.order_type = #{dto.orderType} + + + and o.hour_type in + + #{v} + + + + and o.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]} + order by a.create_time desc,a.id desc diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 9e25594e2..78fe0bb98 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -976,7 +976,7 @@ order by a.product_id desc, o.order_type asc, o.hour_type asc, SUM(a.product_count) desc - +