diff --git a/.gitignore b/.gitignore
index 9e2781708..7e9076a93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,9 @@ gen_code
### node ###
node_modules
+
+### AI 辅助工具 ###
+.video_agent
+.mimosa
+.v2c
+.zcode
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
index be6fef78e..c168bf846 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
@@ -105,6 +105,16 @@ public interface RemoteCanteenSpuService {
@PostMapping("/canteenSpu/ad/getComboItemsBySpuIds")
R
+ *
+ * @param order 已完成核销的预约订单
+ * @param printNo 当前订单打印序号
+ * @return 文件服务返回的兑换券 PNG 地址
*/
private String generateVoucher(HardOrderEntity order, Integer printNo) {
+ List voucherOrders = loadVoucherBatchOrders(order);
MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
- R codeResult = remoteCodeUseService.getByBizId(order.getId());
- WalletCodeUseEntity code = codeResult == null ? null : codeResult.getData();
- List cars = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of);
- Map metadata = loadProductMetadata(cars);
+ UserVO user = loadVoucherUser(order.getUserId());
ThermalReceiptImage image = new ThermalReceiptImage().title("预约核销兑换券")
- .banner(String.format("第 %d 次打印", ObjectUtil.defaultIfNull(printNo, 1)))
- .keyValue("商户", merchant.getName()).sectionTitle("核销短码")
- .verificationCode(code == null ? null : code.getShortCode()).divider()
- .keyValue("订单号", displayOrderNo(order))
- .keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
- .keyValue("餐段", resolveMealName(order.getHourType())).divider().sectionTitle("剩余可履约商品");
- boolean hasProduct = false;
- for (HardCarVo car : cars) {
- int count = effectiveCount(car);
- if (count <= 0) continue;
- hasProduct = true;
- ProductMetadata product = metadata.get(car.getProductId());
- boolean combo = product != null && product.combo();
- image.item(car.getProductName(), "x" + count, false, combo);
- if (combo) {
- product.items().stream().sorted(Comparator.comparing(item ->
- ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE)))
- .forEach(item -> image.item(item.getName(),
- "x" + ObjectUtil.defaultIfNull(item.getQuantity(), 1) * count, true, false));
- }
- }
+ .banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size()))
+ .keyValue("商户", merchant.getName());
+ appendVoucherUser(image, user);
+ boolean showComboItems = shouldShowVoucherComboItems();
+ List orderProducts = loadVoucherOrderProducts(voucherOrders, showComboItems);
+ boolean mergeMultiOrderProducts = voucherOrders.size() > 1 && shouldMergeMultiOrderVoucherProducts();
+ if (mergeMultiOrderProducts) appendMergedVoucherLayout(image, orderProducts);
+ else appendOrderGroupedVoucherLayout(image, orderProducts);
+
+ boolean hasProduct = orderProducts.stream().anyMatch(item -> !item.products().isEmpty());
+ long totalAmount = orderProducts.stream().flatMap(item -> item.products().stream())
+ .mapToLong(VoucherProductLine::subtotal).sum();
if (!hasProduct) image.item("无可履约商品", "x0", false, false);
- image.divider().keyValue("打印时间", formatDateTime(LocalDateTime.now()));
+ image.voucherTotal(money(totalAmount)).keyValue("打印时间", formatDateTime(LocalDateTime.now()));
try {
return uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes());
} catch (IOException exception) {
@@ -687,21 +778,294 @@ public class HardInvoiceServiceImpl extends ServiceImpl公共参数不存在、返回失败或远程服务异常时均按关闭处理,避免配置服务波动阻断已经完成核销的
+ * 订单出票。仅参数值 {@code true} 或 {@code 1}(忽略前后空格和 true 的大小写)代表开启。
+ *
+ * @return {@code true} 显示组合内容;{@code false} 隐藏横线、标题和组成商品列表
*/
- private Map loadProductMetadata(List cars) {
+ private boolean shouldShowVoucherComboItems() {
+ return isPublicParamEnabled(VOUCHER_SHOW_COMBO_ITEMS_PARAM_KEY, "兑换券套餐组合内容显示");
+ }
+
+ /**
+ * 查询当前租户是否对多订单兑换券合并商品明细。
+ *
+ * 该开关只由多订单生成流程调用;单订单始终保持现有版式。关闭或读取失败时,每笔订单继续展示
+ * 自己的商品列表,确保上线后不改变未配置租户的打印结果。
+ *
+ * @return {@code true} 集中展示订单摘要并合并商品;{@code false} 按订单分别展示商品
+ */
+ private boolean shouldMergeMultiOrderVoucherProducts() {
+ return isPublicParamEnabled(VOUCHER_MERGE_MULTI_ORDER_PRODUCTS_PARAM_KEY, "多订单兑换券商品合并");
+ }
+
+ /**
+ * 读取布尔型租户公共参数,统一兑换券版式开关的容错语义。
+ *
+ * 仅参数值 {@code true} 或 {@code 1}(忽略前后空格和 true 的大小写)代表开启;参数不存在、
+ * 返回失败或远程服务异常均按关闭处理,不能因配置服务波动阻断已完成核销订单出票。
+ *
+ * @param paramKey 公共参数键
+ * @param settingName 日志中使用的开关名称
+ * @return 公共参数是否明确开启
+ */
+ private boolean isPublicParamEnabled(String paramKey, String settingName) {
+ try {
+ R result = remoteParamService.getByKey(paramKey);
+ if (result == null || !result.isOk()) {
+ log.warn("读取{}开关失败,按关闭处理,paramKey:{}", settingName, paramKey);
+ return false;
+ }
+ String value = StrUtil.trim(result.getData());
+ return "true".equalsIgnoreCase(value) || "1".equals(value);
+ } catch (Exception exception) {
+ log.warn("读取{}开关异常,按关闭处理,paramKey:{}", settingName, paramKey, exception);
+ return false;
+ }
+ }
+
+ /**
+ * 加载批量兑换券的订单商品快照及套餐元数据,供两种票据版式复用。
+ *
+ * @param voucherOrders 同一次核销的有序订单
+ * @param showComboItems 是否加载并显示套餐组成内容
+ * @return 与订单顺序一致的商品快照分组
+ */
+ private List loadVoucherOrderProducts(List voucherOrders,
+ boolean showComboItems) {
+ List result = new ArrayList<>();
+ for (HardOrderEntity voucherOrder : voucherOrders) {
+ List cars = Optional.ofNullable(carService.getByOrderId(voucherOrder.getId()))
+ .orElseGet(List::of);
+ Map metadata = loadProductMetadata(cars, showComboItems);
+ List products = new ArrayList<>();
+ for (HardCarVo car : cars) {
+ int count = effectiveCount(car);
+ if (count <= 0) continue;
+ // 历史购物车允许商品 ID 为空,Map.of() 不接受空键查询,因此空 ID 直接按单品降级。
+ ProductMetadata product = car.getProductId() == null ? null : metadata.get(car.getProductId());
+ boolean combo = product != null && product.combo();
+ products.add(new VoucherProductLine(car.getProductId(), car.getProductName(), count,
+ voucherUnitPrice(car), voucherSubtotal(car), combo,
+ combo && showComboItems ? product.items() : List.of()));
+ }
+ result.add(new VoucherOrderProducts(voucherOrder, List.copyOf(products)));
+ }
+ return List.copyOf(result);
+ }
+
+ /**
+ * 绘制原有按订单分组的兑换券版式。
+ *
+ * @param image 当前兑换券图片绘制器
+ * @param orderProducts 按订单分组的商品快照
+ */
+ private static void appendOrderGroupedVoucherLayout(ThermalReceiptImage image,
+ List orderProducts) {
+ for (VoucherOrderProducts group : orderProducts) {
+ HardOrderEntity order = group.order();
+ image.divider().sectionTitle("订单 " + displayOrderNo(order))
+ .keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
+ .keyValue("餐段", resolveMealName(order.getHourType()))
+ .sectionTitle("剩余可履约商品");
+ appendVoucherProducts(image, group.products());
+ }
+ }
+
+ /**
+ * 绘制多订单商品合并版式,先集中展示订单识别信息,再展示一份聚合商品明细。
+ *
+ * @param image 当前兑换券图片绘制器
+ * @param orderProducts 按订单分组的商品快照
+ */
+ private static void appendMergedVoucherLayout(ThermalReceiptImage image,
+ List orderProducts) {
+ image.divider().sectionTitle("订单信息(" + orderProducts.size() + "笔)");
+ for (int index = 0; index < orderProducts.size(); index++) {
+ HardOrderEntity order = orderProducts.get(index).order();
+ image.voucherOrderSummary(index + 1, orderProducts.size(), displayOrderNo(order),
+ order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString(),
+ resolveMealName(order.getHourType()));
+ }
+ image.divider().sectionTitle("合并商品明细");
+ appendVoucherProducts(image, mergeVoucherProducts(orderProducts));
+ }
+
+ /**
+ * 绘制一组结构化兑换券商品,套餐组成数量随商品合计数量同步计算。
+ *
+ * @param image 当前兑换券图片绘制器
+ * @param products 待绘制商品;可以是单订单明细或多订单合并结果
+ */
+ private static void appendVoucherProducts(ThermalReceiptImage image, List products) {
+ for (VoucherProductLine product : products) {
+ image.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()),
+ money(product.subtotal()), product.combo(), voucherComponentItems(product));
+ }
+ }
+
+ /**
+ * 将套餐组成快照转换为票据说明,并按套餐商品的剩余总份数放大组成数量。
+ *
+ * @param product 当前兑换券商品
+ * @return 已包含总数量且按组成排序稳定排列的说明文本
+ */
+ private static List voucherComponentItems(VoucherProductLine product) {
+ if (!product.combo() || product.comboItems().isEmpty()) return List.of();
+ return product.comboItems().stream().filter(Objects::nonNull)
+ .sorted(Comparator.comparing(item ->
+ ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE)))
+ .map(item -> StrUtil.blankToDefault(item.getName(), "未命名组成商品") + " x"
+ + (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * product.quantity())
+ .toList();
+ }
+
+ /**
+ * 合并多订单中业务快照完全一致的商品,并保持商品第一次出现的顺序。
+ *
+ * 商品 ID、快照名称、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并;
+ * 历史商品 ID 为空时,自然按名称、单价和套餐类型合并。数量与小计均使用各原始行求和。
+ *
+ * @param orderProducts 按订单分组的商品快照
+ * @return 可直接绘制的有序合并商品列表
+ */
+ static List mergeVoucherProducts(List orderProducts) {
+ Map merged = new LinkedHashMap<>();
+ for (VoucherOrderProducts group : orderProducts) {
+ for (VoucherProductLine product : group.products()) {
+ VoucherProductKey key = new VoucherProductKey(product.productId(), product.productName(),
+ product.unitPrice(), product.combo());
+ VoucherProductLine current = merged.get(key);
+ if (current == null) {
+ merged.put(key, product);
+ continue;
+ }
+ List comboItems = current.comboItems().isEmpty()
+ ? product.comboItems() : current.comboItems();
+ merged.put(key, new VoucherProductLine(current.productId(), current.productName(),
+ current.quantity() + product.quantity(), current.unitPrice(),
+ current.subtotal() + product.subtotal(), current.combo(), comboItems));
+ }
+ }
+ return List.copyOf(merged.values());
+ }
+
+ /**
+ * 按首订单核销快照恢复同一次批量核销的全部订单;历史没有独立核销时间的任务退化为单订单。
+ *
+ * @param anchor 首笔打印任务锚定订单
+ * @return 按订单主键稳定排序的批量订单
+ */
+ private List loadVoucherBatchOrders(HardOrderEntity anchor) {
+ if (anchor.getVerificationTime() == null) return List.of(anchor);
+ var query = Wrappers.lambdaQuery()
+ .eq(HardOrderEntity::getUserId, anchor.getUserId())
+ .eq(HardOrderEntity::getMerchantId, anchor.getMerchantId())
+ .eq(HardOrderEntity::getOrderType, HardOrderTypeEnum.RESERVE.getType())
+ .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CANCEL.getType())
+ .eq(HardOrderEntity::getApptUseDate, anchor.getApptUseDate())
+ .eq(HardOrderEntity::getHourType, anchor.getHourType())
+ .eq(HardOrderEntity::getSysType, anchor.getSysType())
+ .eq(HardOrderEntity::getVerificationTime, anchor.getVerificationTime())
+ .orderByAsc(HardOrderEntity::getId);
+ if (anchor.getHardId() == null) query.isNull(HardOrderEntity::getHardId);
+ else query.eq(HardOrderEntity::getHardId, anchor.getHardId());
+ List orders = orderService.list(query);
+ return orders == null || orders.isEmpty() ? List.of(anchor) : orders;
+ }
+
+ /**
+ * 查询兑换券用户的主资料和身份扩展;查询失败时记录告警并返回空值降级。
+ *
+ * @param userId 订单发起用户主键
+ * @return 包含姓名、学生资料和老师资料的用户;用户不存在或远程服务异常时返回 {@code null}
+ */
+ private UserVO loadVoucherUser(Long userId) {
+ if (userId == null) return null;
+ try {
+ R result = userService.getInnerUserVoByUserId(userId);
+ if (result != null && result.isOk() && result.getData() != null) return result.getData();
+ } catch (Exception exception) {
+ log.debug("兑换券完整用户资料查询异常,尝试兼容用户接口,userId:{}", userId, exception);
+ }
+ // 旧版 UPMS 或服务间 NoToken 头未及时刷新时,兼容接口仍可返回姓名,至少保证订单人可识别。
+ try {
+ R legacyResult = userService.getInnerByUserId(userId);
+ UserInfo legacy = legacyResult == null ? null : legacyResult.getData();
+ if (legacy != null && legacy.getSysUser() != null) {
+ UserVO fallback = new UserVO();
+ fallback.setUserId(legacy.getSysUser().getUserId());
+ fallback.setName(legacy.getSysUser().getName());
+ fallback.setPhone(legacy.getSysUser().getPhone());
+ fallback.setDeptId(legacy.getSysUser().getDeptId());
+ return fallback;
+ }
+ } catch (Exception exception) {
+ log.warn("兑换券用户身份资料查询异常,跳过用户信息打印,userId:{}", userId, exception);
+ }
+ log.warn("兑换券用户身份资料查询失败,跳过用户信息打印,userId:{}", userId);
+ return null;
+ }
+
+ /**
+ * 按非空字段绘制兑换券用户身份资料,学生学号优先于老师学号。
+ *
+ * @param image 当前兑换券图片绘制器
+ * @param user UPMS 返回的用户及身份扩展;为空时不绘制用户区
+ */
+ private static void appendVoucherUser(ThermalReceiptImage image, UserVO user) {
+ Map fields = voucherUserFields(user);
+ if (fields.isEmpty()) return;
+ image.divider().sectionTitle("用户信息");
+ fields.forEach(image::keyValue);
+ }
+
+ /**
+ * 提取兑换券允许打印的非空身份字段,保持姓名、学号、班级的固定顺序。
+ *
+ * @param user UPMS 返回的用户及身份扩展;为空表示没有可打印身份信息
+ * @return 有序的非空身份字段;学生学号优先,学生学号为空时回退老师学号
+ */
+ static Map voucherUserFields(UserVO user) {
+ if (user == null) return Map.of();
+ String studentNo = user.getStudentInfo() == null ? null : user.getStudentInfo().getStudentNo();
+ String teacherNo = user.getTeacherInfo() == null ? null : user.getTeacherInfo().getTeacherNo();
+ String userNo = StrUtil.isNotBlank(studentNo) ? studentNo : teacherNo;
+ String className = user.getStudentInfo() == null ? null : user.getStudentInfo().getClassName();
+ Map fields = new LinkedHashMap<>();
+ if (StrUtil.isNotBlank(user.getName())) fields.put("姓名", user.getName().trim());
+ if (StrUtil.isNotBlank(userNo)) fields.put("学号", userNo.trim());
+ if (StrUtil.isNotBlank(className)) fields.put("班级", className.trim());
+ return fields;
+ }
+
+ /**
+ * 批量加载套餐类型,并按兑换券显示开关决定是否继续查询组成项。
+ *
+ * 异步任务没有用户令牌,必须通过内部接口查询。关闭组成内容时不发起明细请求,既减少远程调用,
+ * 也避免组成接口波动影响“组合商品”标签的识别;商品类型接口失败时才按普通单品降级。
+ *
+ * @param cars 订单中的剩余可履约商品快照
+ * @param includeComboItems 是否加载套餐组成商品明细
+ * @return 按商品 ID 索引的套餐类型和组成项;类型查询失败时返回空 Map
+ */
+ private Map loadProductMetadata(List cars, boolean includeComboItems) {
List productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList();
if (productIds.isEmpty()) return Map.of();
try {
- R> result = remoteCanteenSpuService.getSpuListByIds(productIds);
+ R> result = remoteCanteenSpuService.getInnerSpuListByIds(productIds);
if (result == null || !result.isOk() || result.getData() == null) return Map.of();
List comboIds = result.getData().stream()
.filter(item -> item != null && Objects.equals(item.getSpuType(), 2))
.map(CanteenSpuEntity::getId).toList();
Map> comboItems = Map.of();
- if (!comboIds.isEmpty()) {
- R>> comboResult = remoteCanteenSpuService.getComboItemsBySpuIds(comboIds);
- if (comboResult != null && comboResult.isOk() && comboResult.getData() != null) comboItems = comboResult.getData();
+ if (includeComboItems && !comboIds.isEmpty()) {
+ R>> comboResult =
+ remoteCanteenSpuService.getInnerComboItemsBySpuIds(comboIds);
+ if (comboResult != null && comboResult.isOk() && comboResult.getData() != null)
+ comboItems = comboResult.getData();
}
Map metadata = new LinkedHashMap<>();
for (CanteenSpuEntity spu : result.getData()) {
@@ -776,7 +1140,9 @@ public class HardInvoiceServiceImpl extends ServiceImpllambdaQuery()
.eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType())
.orderByDesc(HardInvoiceEntity::getPrintNo).orderByDesc(HardInvoiceEntity::getId).last("limit 1"));
}
- /** 按打印序号查询并发创建结果。 */
+ /**
+ * 按打印序号查询并发创建结果。
+ */
private HardInvoiceEntity findTaskByPrintNo(Long orderId, HardPrintTypeEnum printType, Integer printNo) {
return baseMapper.selectOne(Wrappers.lambdaQuery()
.eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType())
.eq(HardInvoiceEntity::getPrintNo, printNo).last("limit 1"));
}
- /** 判断任务是否仍应复用而不能创建新打印序号。 */
+ /**
+ * 判断任务是否仍应复用而不能创建新打印序号。
+ */
private static boolean isOpenStatus(String status) {
return List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), HardPrintStatusEnum.GENERATE.getType(),
HardPrintStatusEnum.FAILED.getType()).contains(status);
}
- /** 兼容历史空打印类型。 */
+ /**
+ * 兼容历史空打印类型。
+ */
private static HardPrintTypeEnum normalizePrintType(String printType) {
return Optional.ofNullable(HardPrintTypeEnum.valueOfByType(printType)).orElse(HardPrintTypeEnum.INVOICE);
}
- /** 计算部分退款后的剩余可履约数量。 */
+ /**
+ * 计算部分退款后的剩余可履约数量。
+ */
private static int effectiveCount(HardCarVo car) {
if (car.getEffectiveProductCount() != null) return Math.max(0, car.getEffectiveProductCount());
return Math.max(0, ObjectUtil.defaultIfNull(car.getProductCount(), 0)
- ObjectUtil.defaultIfNull(car.getRefundedProductCount(), 0));
}
- /** 解析订单餐段显示名。 */
+ /**
+ * 读取兑换券商品的订单快照单价。
+ *
+ * @param car 订单商品快照
+ * @return 商品单价,单位分;历史空值按零处理
+ */
+ static long voucherUnitPrice(HardCarVo car) {
+ return ObjectUtil.defaultIfNull(car.getProductPrice(), 0).longValue();
+ }
+
+ /**
+ * 按剩余可履约数量计算兑换券商品小计,避免把已完成退款商品计入金额。
+ *
+ * @param car 已补充退款统计的订单商品快照
+ * @return 剩余商品小计,单位分
+ */
+ static long voucherSubtotal(HardCarVo car) {
+ return voucherUnitPrice(car) * effectiveCount(car);
+ }
+
+ /**
+ * 解析订单餐段显示名。
+ */
private static String resolveMealName(String hourType) {
HardHourTypeEnum type = HardHourTypeEnum.valueOfByType(hourType);
return type == null ? StrUtil.blankToDefault(hourType, "--") : type.getDescription();
}
- /** 上传 PNG 并校验文件服务返回地址。 */
+ /**
+ * 上传 PNG 并校验文件服务返回地址。
+ */
private String uploadPng(String filePrefix, byte[] bytes) {
String fileName = filePrefix + "_" + System.currentTimeMillis() + ".png";
MultipartFile multipartFile = new MockMultipartFile("file", fileName, "image/png", bytes);
@@ -841,7 +1241,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl T requireData(R result, String message) {
if (result == null || !result.isOk() || result.getData() == null) throw new RuntimeException(message);
return result.getData();
}
- /** 截断错误摘要以适配数据库字段。 */
+ /**
+ * 截断错误摘要以适配数据库字段。
+ */
private static String truncateError(String error) {
return StrUtil.sub(StrUtil.blankToDefault(error, "未知错误"), 0, ERROR_MAX_LENGTH);
}
- /** 菜品是否为套餐及其组成项。 */
+ /**
+ * 菜品是否为套餐及其组成项。
+ */
private record ProductMetadata(boolean combo, List items) {
}
+
+ /**
+ * 一笔订单及其剩余可履约商品快照。
+ *
+ * @param order 订单识别信息和预约信息
+ * @param products 已扣除退款成功数量的商品行
+ */
+ record VoucherOrderProducts(HardOrderEntity order, List products) {
+ }
+
+ /**
+ * 可直接绘制或参与多订单聚合的兑换券商品行。
+ *
+ * @param productId 商品主键;历史快照允许为空
+ * @param productName 下单时商品名称快照
+ * @param quantity 剩余可履约数量
+ * @param unitPrice 下单时单价快照,单位分
+ * @param subtotal 当前商品行小计,单位分
+ * @param combo 是否为组合商品
+ * @param comboItems 套餐组成快照;显示开关关闭时为空
+ */
+ record VoucherProductLine(Long productId, String productName, int quantity, long unitPrice, long subtotal,
+ boolean combo, List comboItems) {
+ }
+
+ /**
+ * 多订单商品合并键。
+ *
+ * @param productId 商品主键;为空时依靠其余快照字段区分
+ * @param productName 商品名称快照
+ * @param unitPrice 商品单价快照,单位分
+ * @param combo 是否为组合商品
+ */
+ private record VoucherProductKey(Long productId, String productName, long unitPrice, boolean combo) {
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImpl.java
new file mode 100644
index 000000000..a8cd752fc
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImpl.java
@@ -0,0 +1,125 @@
+package com.bsd.pjcloud.hard.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.bsd.pjcloud.common.core.util.BeanUtils;
+import com.bsd.pjcloud.hard.api.constant.HardPrintTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
+import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
+import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
+import com.bsd.pjcloud.hard.service.HardInvoiceService;
+import com.bsd.pjcloud.hard.service.HardManagerService;
+import com.bsd.pjcloud.hard.service.HardTerminalPrintService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * 通过 DEVICE-ID 限定服务端打印凭证读取与兑换券重打范围的实现。
+ */
+@Service
+@RequiredArgsConstructor
+public class HardTerminalPrintServiceImpl implements HardTerminalPrintService {
+
+ /** 单个订单返回给终端的打印记录上限,防止异常历史数据形成无界响应。 */
+ private static final int RECORD_LIMIT = 100;
+
+ /** 服务端打印任务服务,继续复用原有序号分配、状态校验和异步下发能力。 */
+ private final HardInvoiceService hardInvoiceService;
+
+ /** 设备服务,用于校验 DEVICE-ID 类型并补充来源终端和目标打印机名称。 */
+ private final HardManagerService hardManagerService;
+
+ /** {@inheritDoc} */
+ @Override
+ public List listRecords(Long sourceHardId, Long orderId) {
+ requireSourceTerminal(sourceHardId);
+ if (orderId == null) throw new RuntimeException("订单ID不能为空");
+ List records = hardInvoiceService.list(
+ Wrappers.lambdaQuery()
+ .eq(HardInvoiceEntity::getHardId, sourceHardId)
+ .eq(HardInvoiceEntity::getOrderId, orderId)
+ .orderByDesc(HardInvoiceEntity::getCreateTime)
+ .orderByDesc(HardInvoiceEntity::getId)
+ .last("limit " + RECORD_LIMIT));
+ Map hardNames = loadHardNames(records);
+ return records.stream().map(record -> toTerminalRecord(record, hardNames)).toList();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public HardInvoiceEntity reprintVoucher(Long sourceHardId, Long historyId, String reason) {
+ requireSourceTerminal(sourceHardId);
+ if (historyId == null) throw new RuntimeException("打印任务ID不能为空");
+ if (StrUtil.isBlank(reason)) throw new RuntimeException("人工重打必须填写原因");
+ HardInvoiceEntity history = hardInvoiceService.getById(historyId);
+ if (history == null) throw new RuntimeException("打印任务不存在");
+ if (!Objects.equals(history.getHardId(), sourceHardId)) {
+ throw new RuntimeException("只能重打当前终端产生的兑换券");
+ }
+ if (!Objects.equals(history.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType())) {
+ throw new RuntimeException("当前任务不是核销兑换券");
+ }
+ return hardInvoiceService.reprintTask(historyId, reason.trim());
+ }
+
+ /**
+ * 校验请求头设备存在且属于食堂或超市业务终端。
+ *
+ * @param sourceHardId DEVICE-ID 请求头中的设备 ID
+ * @return 已通过终端类型校验的设备
+ */
+ private HardManagerEntity requireSourceTerminal(Long sourceHardId) {
+ if (sourceHardId == null) throw new RuntimeException("DEVICE-ID不能为空");
+ HardManagerEntity source = hardManagerService.getById(sourceHardId);
+ HardTypeEnum type = source == null ? null : HardTypeEnum.valueOfByType(source.getType());
+ if (source == null || (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN)) {
+ throw new RuntimeException("当前设备不是超市或食堂业务终端");
+ }
+ return source;
+ }
+
+ /**
+ * 批量读取记录中来源终端和目标打印机名称,已删除的历史设备保留空名称。
+ *
+ * @param records 当前订单的服务端打印记录
+ * @return 设备 ID 到当前名称的映射
+ */
+ private Map loadHardNames(List records) {
+ Set hardIds = new LinkedHashSet<>();
+ for (HardInvoiceEntity record : records) {
+ if (record.getHardId() != null) hardIds.add(record.getHardId());
+ if (record.getPrinterHardId() != null) hardIds.add(record.getPrinterHardId());
+ }
+ if (hardIds.isEmpty()) return Map.of();
+ Map names = new LinkedHashMap<>();
+ for (HardManagerEntity hard : hardManagerService.listByIds(hardIds)) {
+ if (hard != null && hard.getId() != null) names.put(hard.getId(), hard.getName());
+ }
+ return names;
+ }
+
+ /**
+ * 把持久化任务转换为终端只读视图,并补充 PC 详情页同口径的设备名称。
+ *
+ * @param record 服务端打印任务
+ * @param hardNames 已批量读取的设备名称
+ * @return 不含额外用户资料的终端打印记录
+ */
+ private HardInvoiceVo toTerminalRecord(HardInvoiceEntity record, Map hardNames) {
+ HardInvoiceVo result = BeanUtils.toBean(record, HardInvoiceVo.class);
+ result.setHistoryId(record.getId());
+ result.setHardName(hardNames.get(record.getHardId()));
+ result.setPrinterHardName(hardNames.get(record.getPrinterHardId()));
+ return result;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java
index 74e319f0e..a8c480d32 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java
@@ -12,11 +12,13 @@ import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.ReservationRecognitionTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto;
+import com.bsd.pjcloud.hard.api.dto.hard.ReservationMenuBatchCancelDto;
import com.bsd.pjcloud.hard.api.dto.hard.ReservationMenuCancelDto;
import com.bsd.pjcloud.hard.api.dto.hard.ReservationMenuQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
@@ -123,6 +125,79 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
return hardOrderService.handCancelCode(cancelDto);
}
+ /**
+ * 校验同一识别用户在同一终端一次选中的预约订单,并交给订单服务执行原子批量核销。
+ *
+ * 客户端只提交订单 ID;这里再次从订单快照确认用户、商户、设备类型、预约日期和餐段完全一致,
+ * 防止人为拼接跨用户或跨商户订单后共用一张兑换券。
+ *
+ * @param dto 批量订单 ID 参数
+ * @param hardId 当前业务终端 ID,可为空
+ * @return 整批订单核销成功时返回 {@code true}
+ */
+ @Override
+ public Boolean cancelBatch(ReservationMenuBatchCancelDto dto, Long hardId) {
+ if (dto == null || dto.getOrderIds() == null || dto.getOrderIds().isEmpty()) {
+ throw new RuntimeException("预约订单不能为空");
+ }
+ List orderIds = new LinkedHashSet<>(dto.getOrderIds()).stream().toList();
+ if (orderIds.stream().anyMatch(Objects::isNull)) throw new RuntimeException("预约订单ID不能为空");
+ if (orderIds.size() > 100) throw new RuntimeException("一次最多核销100笔预约订单");
+ if (hardId == null) throw new RuntimeException("DEVICE-ID不能为空");
+
+ List orders = orderIds.stream().map(hardOrderService::getById).toList();
+ HardOrderEntity first = orders.stream().findFirst().orElseThrow(() -> new RuntimeException("预约订单不存在"));
+ validateBatchOrder(first, hardId, null);
+ for (HardOrderEntity order : orders) {
+ validateBatchOrder(order, hardId, first);
+ }
+
+ List cancelDtos = orderIds.stream().map(orderId -> {
+ HandCancelCodeDto cancelDto = new HandCancelCodeDto();
+ cancelDto.setOrderId(orderId);
+ cancelDto.setHardwareId(hardId);
+ return cancelDto;
+ }).toList();
+ return hardOrderService.handCancelReservationCodes(cancelDtos);
+ }
+
+ /**
+ * 校验批量兑换券必须共享的预约订单维度。
+ *
+ * @param order 待校验订单
+ * @param hardId 请求头设备 ID
+ * @param first 批次首订单;为空时只校验订单自身
+ */
+ private void validateBatchOrder(HardOrderEntity order, Long hardId, HardOrderEntity first) {
+ if (order == null) throw new RuntimeException("预约订单不存在");
+ if (!Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
+ || (order.getApptId() == null && order.getApptRuleId() == null)
+ || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) {
+ throw new RuntimeException("该预约订单当前不可核销");
+ }
+ if (!isCancelableRefundStatus(order.getRefundStatus())) throw new RuntimeException("该预约订单正在退款,不能核销");
+ if (order.getApptUseDate() == null || order.getApptUseDate().isBefore(LocalDate.now())) {
+ throw new RuntimeException("该预约订单已失效");
+ }
+ if (!hasFulfillableProduct(order.getId())) throw new RuntimeException("该预约订单商品已全部退款,不能核销");
+ HardTypeEnum type = HardTypeEnum.valueOfByType(order.getSysType());
+ if (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN) {
+ throw new RuntimeException("该订单设备类型不支持预约核销");
+ }
+ resolveVerificationHardId(order, hardId);
+ if (first == null) {
+ validateBatchDevice(order, hardId);
+ }
+ if (first == null) return;
+ if (!Objects.equals(first.getUserId(), order.getUserId())
+ || !Objects.equals(first.getMerchantId(), order.getMerchantId())
+ || !Objects.equals(first.getApptUseDate(), order.getApptUseDate())
+ || !Objects.equals(first.getHourType(), order.getHourType())
+ || !Objects.equals(first.getSysType(), order.getSysType())) {
+ throw new RuntimeException("只能合并核销同一用户、同一商户、同一日期餐段的预约订单");
+ }
+ }
+
/**
* 校验订单至少还有一份退款成功后可履约的商品,防止旧终端绕过查询结果直接核销空订单。
*
@@ -150,6 +225,22 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
return hard.getId();
}
+ /**
+ * 批量核销专用的来源设备白名单校验;单笔旧接口保留原有商户归属兼容语义。
+ *
+ * @param order 批次订单
+ * @param hardId 请求头来源设备 ID
+ */
+ private void validateBatchDevice(HardOrderEntity order, Long hardId) {
+ if (hardId == null) return;
+ HardManagerEntity hard = hardManagerService.getById(hardId);
+ HardTypeEnum hardType = hard == null ? null : HardTypeEnum.valueOfByType(hard.getType());
+ if (hard == null || (hardType != HardTypeEnum.SUPERMARKET && hardType != HardTypeEnum.CANTEEN)
+ || !Objects.equals(hard.getMerchantId(), order.getMerchantId())) {
+ throw new RuntimeException("当前设备不能核销该预约订单");
+ }
+ }
+
/**
* 判断预约订单退款聚合状态是否允许继续履约,历史空值按未售后兼容,未知状态按不可核销处理。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/TerminalOrderRecordServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/TerminalOrderRecordServiceImpl.java
new file mode 100644
index 000000000..dc802b734
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/TerminalOrderRecordServiceImpl.java
@@ -0,0 +1,128 @@
+package com.bsd.pjcloud.hard.service.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
+import com.bsd.pjcloud.hard.api.dto.hard.TerminalOrderRecordQueryDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
+import com.bsd.pjcloud.hard.api.vo.android.TerminalOrderRecordVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import com.bsd.pjcloud.hard.service.HardManagerService;
+import com.bsd.pjcloud.hard.service.HardOrderService;
+import com.bsd.pjcloud.hard.service.TerminalOrderRecordService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+
+/** 终端交易记录查询服务,复用订单管理端分页查询保证范围、分页和用户信息口径一致。 */
+@Service
+@RequiredArgsConstructor
+public class TerminalOrderRecordServiceImpl implements TerminalOrderRecordService {
+
+ /** 全部数据范围:按终端所属商户查询商户下全部设备的交易记录。 */
+ private static final String SCOPE_MERCHANT = "MERCHANT";
+
+ /** 终端不区分用餐时段的编码。 */
+ private static final String HOUR_TYPE_ALL = "-1";
+
+ /** 设备服务,用于校验请求设备存在并解析所属商户。 */
+ private final HardManagerService hardManagerService;
+
+ /** 订单服务,用于按设备/商户、订单状态和下单时间分页查询。 */
+ private final HardOrderService hardOrderService;
+
+ @Override
+ public Page page(Page> page, Long hardId, TerminalOrderRecordQueryDto dto) {
+ if (!Objects.equals(hardId, dto.getHardId())) {
+ throw new RuntimeException("请求设备与查询设备不一致");
+ }
+ HardManagerEntity hard = hardManagerService.getById(hardId);
+ if (hard == null) {
+ throw new RuntimeException("设备不存在或无权访问");
+ }
+
+ LocalDate startDate = ObjectUtil.defaultIfNull(dto.getStartDate(), LocalDate.now());
+ LocalDate endDate = ObjectUtil.defaultIfNull(dto.getEndDate(), startDate);
+ if (endDate.isBefore(startDate)) {
+ throw new RuntimeException("结束日期不能早于开始日期");
+ }
+
+ HardOrderDto query = new HardOrderDto();
+ if (SCOPE_MERCHANT.equalsIgnoreCase(StrUtil.trimToEmpty(dto.getScope()))) {
+ // 全部数据:范围来自服务端解析的终端所属商户,客户端不能自行指定商户。
+ query.setMerchantId(hard.getMerchantId());
+ } else {
+ query.setHardId(hard.getId());
+ }
+ // 终端交易记录只承载本机产生的线下购买订单,与本地数据库口径一致,排除预约、外卖等其他类型。
+ query.setOrderType(HardOrderTypeEnum.BELOW.getType());
+ query.setCreateTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.atTime(23, 59, 59)});
+ query.setOrderStatusCodes(dto.getOrderStatusCodes());
+ if (StrUtil.isNotBlank(dto.getHourType()) && !HOUR_TYPE_ALL.equals(dto.getHourType().trim())) {
+ query.setHourType(dto.getHourType().trim());
+ }
+
+ long current = Math.max(1, page.getCurrent());
+ long size = Math.min(50, Math.max(1, page.getSize()));
+ // 订单范围、分页、用户和部门信息统一由订单服务处理,交易记录只负责转换展示字段。
+ Page source = hardOrderService.scopePageList(new Page<>(current, size), query, null);
+ List records = source.getRecords().stream()
+ .map(TerminalOrderRecordServiceImpl::convert)
+ .toList();
+ Page result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal());
+ result.setRecords(records);
+ return result;
+ }
+
+ /**
+ * 将订单查询结果转换为终端交易记录展示对象。
+ *
+ * @param order 订单分页查询结果
+ * @return 终端交易记录
+ */
+ private static TerminalOrderRecordVo convert(HardOrderVo order) {
+ TerminalOrderRecordVo vo = new TerminalOrderRecordVo();
+ vo.setOrderId(order.getId());
+ vo.setOrderNo(StrUtil.blankToDefault(order.getOrderNo(), String.valueOf(order.getId())));
+ vo.setOrderType(order.getOrderType());
+ HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType());
+ vo.setOrderTypeName(orderType == null ? "其他类型" : orderType.getDescription());
+ vo.setOrderStatus(order.getOrderStatus());
+ // 状态枚举查找对未知编码抛异常,外卖/商城等终端外状态需安全降级。
+ vo.setOrderStatusName(resolveStatusName(order.getOrderStatus()));
+ vo.setPayType(order.getPayType());
+ HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType());
+ vo.setPayTypeName(payType == null ? "其他方式" : payType.getDescription());
+ vo.setUsername(StrUtil.blankToDefault(order.getUsername(), "未知用户"));
+ vo.setUserDeptName(order.getUserDeptName());
+ vo.setProductDesc(StrUtil.blankToDefault(order.getProductDesc(), "未知商品"));
+ vo.setHourType(order.getHourType());
+ vo.setTotalPrice(ObjectUtil.defaultIfNull(order.getTotalPrice(), 0));
+ vo.setExtraPrice(ObjectUtil.defaultIfNull(order.getExtraPrice(), 0));
+ vo.setPayPrice(ObjectUtil.defaultIfNull(order.getPayPrice(), 0));
+ vo.setHardName(order.getHardName());
+ vo.setCreateTime(order.getCreateTime());
+ return vo;
+ }
+
+ /** 状态枚举 {@code valueOfByType} 对未知编码会抛异常,这里安全降级为通用名称。 */
+ private static String resolveStatusName(String statusCode) {
+ if (StrUtil.isBlank(statusCode)) {
+ return "未知状态";
+ }
+ for (HardOrderStatusEnum status : HardOrderStatusEnum.values()) {
+ if (Objects.equals(statusCode, status.getType())) {
+ return status.getDescription();
+ }
+ }
+ return "其他状态";
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java
index b9364e4de..d0631c532 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java
@@ -30,6 +30,9 @@ public class VerificationRecordServiceImpl implements VerificationRecordService
/** 当前接口支持的预约核销类型协议值。 */
private static final String TYPE_RESERVATION = "RESERVATION";
+ /** 全部数据范围:按终端所属商户查询商户下全部设备的核销记录。 */
+ private static final String SCOPE_MERCHANT = "MERCHANT";
+
/** 设备服务,用于校验请求设备存在且与查询设备一致。 */
private final HardManagerService hardManagerService;
@@ -64,7 +67,12 @@ public class VerificationRecordServiceImpl implements VerificationRecordService
}
HardOrderDto query = new HardOrderDto();
- query.setHardId(hard.getId());
+ if (SCOPE_MERCHANT.equalsIgnoreCase(StrUtil.trimToEmpty(dto.getScope()))) {
+ // 全部数据:范围来自服务端解析的终端所属商户,客户端不能自行指定商户。
+ query.setMerchantId(hard.getMerchantId());
+ } else {
+ query.setHardId(hard.getId());
+ }
query.setOrderType(HardOrderTypeEnum.RESERVE.getType());
query.setApptIdOrApptRuleIdNotNull(true);
query.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType()));
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index 99536c2e2..2c07fb8b4 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -3942,11 +3942,9 @@ public class HardOrderServiceImpl extends ServiceImpl所有订单先按主键顺序加行锁并完成完整校验,再逐笔消费 Wallet 预约码和更新订单状态;
+ * 任意远程或本地步骤失败都会补偿已经消费的预约码。事务成功后只创建一个以首笔订单为锚点的
+ * 合并兑换券任务,图片生成服务再按共享核销时间恢复整个批次。
+ *
+ * @param dtos 预约订单、来源设备及可选终端订单号参数
+ * @return 全部订单核销并创建合并打印任务时返回 {@code true}
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Boolean handCancelReservationCodes(List dtos) {
+ if (dtos == null || dtos.isEmpty()) throw new RuntimeException("预约订单不能为空");
+ Map requestByOrderId = new LinkedHashMap<>();
+ for (HandCancelCodeDto dto : dtos) {
+ if (dto == null || dto.getOrderId() == null) throw new RuntimeException("预约订单ID不能为空");
+ requestByOrderId.putIfAbsent(dto.getOrderId(), dto);
+ }
+ if (requestByOrderId.isEmpty()) throw new RuntimeException("预约订单不能为空");
+ Long sourceHardId = requestByOrderId.values().iterator().next().getHardwareId();
+ if (requestByOrderId.values().stream().anyMatch(dto -> !Objects.equals(sourceHardId, dto.getHardwareId()))) {
+ throw new RuntimeException("批量核销必须使用同一来源设备");
+ }
+ if (sourceHardId == null) throw new RuntimeException("批量核销来源设备不能为空");
+
+ Long firstOrderId = requestByOrderId.keySet().stream().min(Long::compareTo).orElseThrow();
+ HardOrderEntity firstSnapshot = baseMapper.selectById(firstOrderId);
+ if (firstSnapshot == null) throw new I18nBizException(ErrorCodes.COMMON_ORDER_NOT_FOUND);
+ validateReservationVerificationSource(firstSnapshot, sourceHardId);
+ LockInfo verificationLock = acquireReservationVerificationLock(firstSnapshot, sourceHardId);
+ boolean releaseByTransaction = false;
+ List canceledOrders = new ArrayList<>();
+ LocalDateTime verificationTime = LocalDateTime.now();
+ try {
+ List orders = new ArrayList<>();
+ for (Long orderId : requestByOrderId.keySet().stream().sorted().toList()) {
+ HardOrderEntity order = baseMapper.selectByIdForUpdate(orderId);
+ validateReservationBatchOrder(order, firstSnapshot, sourceHardId);
+ orders.add(order);
+ }
+
+ Map users = new HashMap<>();
+ for (HardOrderEntity order : orders) {
+ R userResult = userService.getInnerByUserId(order.getUserId());
+ UserInfo userInfo = userResult == null ? null : userResult.getData();
+ if (userInfo == null) throw new I18nBizException(ErrorCodes.COMMON_USER_NOT_EXIST);
+ users.put(order.getId(), userInfo);
+ }
+
+ for (HardOrderEntity order : orders) {
+ // 在远程消费前登记订单,远程服务已消费但本地随后失败时也会执行幂等恢复。
+ canceledOrders.add(order);
+ getSelf().cancelCode(order, users.get(order.getId()));
+ HandCancelCodeDto request = requestByOrderId.get(order.getId());
+ HardOrderEntity updateObj = HardOrderEntity.builder()
+ .id(order.getId())
+ .androidOrderCode(request.getAndroidOrderCode())
+ .hardId(sourceHardId)
+ .orderStatus(HardOrderStatusEnum.CANCEL.getType())
+ .verificationTime(verificationTime)
+ .build();
+ int updated = baseMapper.update(updateObj, new LambdaUpdateWrapper()
+ .eq(HardOrderEntity::getId, order.getId())
+ .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()));
+ if (updated == 0) throw new I18nBizException(ErrorCodes.HARD_VERIFY_STATUS_CHANGED);
+ order.setVerificationTime(verificationTime);
+ order.setHardId(sourceHardId);
+ order.setOrderStatus(HardOrderStatusEnum.CANCEL.getType());
+ if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) {
+ createMealVerifiedOutbox(order, verificationTime);
+ }
+ }
+ hardInvoiceService.createRedemptionVoucherBatchTask(orders, sourceHardId);
+ registerReservationVerificationLockRelease(verificationLock);
+ releaseByTransaction = true;
+ return true;
+ } catch (RuntimeException exception) {
+ for (HardOrderEntity order : canceledOrders) {
+ rollbackReservationCodeAfterLocalFailure(order, exception);
+ }
+ throw exception;
+ } finally {
+ if (!releaseByTransaction) releaseReservationVerificationLock(verificationLock);
+ }
+ }
+
+ /**
+ * 校验批量核销订单的预约、退款、履约和共享批次维度。
+ *
+ * @param order 已加锁订单
+ * @param firstSnapshot 批次首订单快照
+ * @param sourceHardId 来源终端 ID
+ */
+ private void validateReservationBatchOrder(HardOrderEntity order, HardOrderEntity firstSnapshot,
+ Long sourceHardId) {
+ if (!isReservationVerification(order)) throw new RuntimeException("该预约订单当前不可核销");
+ if (order.getApptId() == null && order.getApptRuleId() == null) throw new RuntimeException("该订单不是预约订单");
+ if (order.getApptUseDate() == null || order.getApptUseDate().isBefore(LocalDate.now())) {
+ throw new RuntimeException("该预约订单已失效");
+ }
+ if (!isReservationRefundStatusFulfillable(order.getRefundStatus())) {
+ throw new I18nBizException(ErrorCodes.HARD_RESERVE_REFUNDING_NO_VERIFY);
+ }
+ if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) {
+ validateCanteenReservationBeforeCancel(order);
+ } else if (!hasReservationFulfillableProduct(order.getId())) {
+ throw new I18nBizException(ErrorCodes.HARD_RESERVE_FULL_REFUND_NO_VERIFY);
+ }
+ if (sourceHardId != null && !Objects.equals(sourceHardId, order.getHardId())
+ && order.getHardId() != null && order.getOrderStatus() != null) {
+ // 待核销预约通常尚未写入设备;若历史数据已有来源设备,仍禁止跨设备批量合并。
+ throw new RuntimeException("预约订单已有其他核销设备归属");
+ }
+ if (!Objects.equals(firstSnapshot.getUserId(), order.getUserId())
+ || !Objects.equals(firstSnapshot.getMerchantId(), order.getMerchantId())
+ || !Objects.equals(firstSnapshot.getApptUseDate(), order.getApptUseDate())
+ || !Objects.equals(firstSnapshot.getHourType(), order.getHourType())
+ || !Objects.equals(firstSnapshot.getSysType(), order.getSysType())) {
+ throw new RuntimeException("只能合并核销同一用户、同一商户、同一日期餐段的预约订单");
+ }
+ }
+
+ /**
+ * 对订单服务内部调用再次校验来源设备类型和商户,避免绕过预约菜单服务时使用其他硬件类型核销。
+ *
+ * @param order 批次首订单快照
+ * @param sourceHardId 来源业务终端 ID;为空表示历史无设备来源调用
+ */
+ private void validateReservationVerificationSource(HardOrderEntity order, Long sourceHardId) {
+ if (sourceHardId == null) return;
+ HardManagerEntity source = hardManagerService.getById(sourceHardId);
+ HardTypeEnum sourceType = source == null ? null : HardTypeEnum.valueOfByType(source.getType());
+ if (source == null || (sourceType != HardTypeEnum.SUPERMARKET && sourceType != HardTypeEnum.CANTEEN)
+ || !Objects.equals(source.getMerchantId(), order.getMerchantId())) {
+ throw new RuntimeException("当前设备不能核销该预约订单");
+ }
+ }
+
+ /**
+ * 判断预约订单是否仍有可履约商品,兼容历史商品查询返回空值的情况。
+ *
+ * @param orderId 待核销预约订单主键
+ * @return 至少有一份剩余商品时返回 {@code true}
+ */
+ private boolean hasReservationFulfillableProduct(Long orderId) {
+ List cars = hardCarService.getByOrderId(orderId);
+ return cars != null && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0);
+ }
+
+ /**
+ * 按用户、商户、日期、餐段和来源设备串行化预约核销批次,避免共享核销时间误合并并发批次。
+ *
+ * @param order 批次首订单
+ * @param sourceHardId 请求头来源设备
+ * @return 已持有的分布式锁
+ */
+ private LockInfo acquireReservationVerificationLock(HardOrderEntity order, Long sourceHardId) {
+ String lockKey = String.format("hard:reserve:verification:%s:%s:%s:%s:%s",
+ sourceHardId, order.getUserId(), order.getMerchantId(), order.getApptUseDate(), order.getHourType());
+ LockInfo lock = lockTemplate.lock(lockKey, RESERVATION_SLOT_LOCK_EXPIRE_MILLIS,
+ RESERVATION_SLOT_LOCK_WAIT_MILLIS);
+ if (lock == null) throw new I18nBizException(ErrorCodes.HARD_APPT_PROCESSING);
+ return lock;
+ }
+
+ /** 将批量核销锁绑定到事务完成时释放。 */
+ private void registerReservationVerificationLockRelease(LockInfo lock) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ throw new I18nBizException(ErrorCodes.HARD_RESERVE_TX_BAD);
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCompletion(int status) {
+ releaseReservationVerificationLock(lock);
+ }
+ });
+ }
+
+ /** 释放批量核销分布式锁并记录异常,不覆盖原始业务异常。 */
+ private void releaseReservationVerificationLock(LockInfo lock) {
+ try {
+ if (lock != null && !lockTemplate.releaseLock(lock)) {
+ log.warn("|{}|预约批量核销锁释放失败,lockKey:|{}|", TAG, lock.getLockKey());
+ }
+ } catch (Exception exception) {
+ log.error("|{}|预约批量核销锁释放异常", TAG, exception);
+ }
+ }
+
/**
* Hardware 本地核销事务失败后补偿已经提交的 Wallet 预约码状态,使订单仍可重新核销。补偿失败时
* 保留原始业务异常,并把恢复异常作为 suppressed exception 记录,便于运维识别需要人工修复的订单。
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
index a8f120e5f..3efb973bf 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
@@ -439,13 +439,13 @@
order by a.verification_time desc, a.id desc
- order by a.appt_use_date desc, a.hour_type asc, a.id desc
+ order by a.appt_use_date desc, a.pay_time desc, a.id desc
order by a.update_time desc, a.id desc
- order by a.create_time desc, a.user_id desc
+ order by a.appt_use_date desc, a.pay_time desc, a.id desc
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java
index bbd4e028e..cce119758 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
+import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -37,12 +38,136 @@ class ThermalReceiptImageTest {
BufferedImage invoice = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.title("发票").invoiceItem("独立单品", "2", "¥12.00").toPngBytes()));
BufferedImage voucher = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
- .title("兑换券").banner("第 1 次打印").verificationCode("123456").toPngBytes()));
+ .title("兑换券").banner("第 1 次打印")
+ .voucherProduct("独立单品", 2, "6.00", "12.00", false, List.of()).toPngBytes()));
assertEquals(576, invoice.getWidth());
assertEquals(576, voucher.getWidth());
}
+ /** 放大后的打印提示必须按纸宽自动换行,不能裁掉多订单数量等关键信息。 */
+ @Test
+ void largeBannerWrapsLongTextAndGrowsCanvas() throws Exception {
+ BufferedImage shortBanner = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .banner("第 1 次打印").toPngBytes()));
+ BufferedImage longBanner = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .banner("第 12 次打印 · 100 笔预约订单合并打印").toPngBytes()));
+
+ assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, longBanner.getWidth());
+ assertTrue(longBanner.getHeight() > shortBanner.getHeight(),
+ "长提示换行后必须增加票据高度,不能越过右侧纸张边界");
+ }
+
+ /** 兑换券商品卡片应完整换行长名称和套餐组成,并随内容动态增高。 */
+ @Test
+ void voucherProductCardsWrapNamesAndComboComponents() throws Exception {
+ String longProductName = "超长组合商品名称".repeat(18);
+ String longComponentName = "组合内容中的超长菜品名称".repeat(10) + " x12";
+ ThermalReceiptImage receipt = new ThermalReceiptImage()
+ .title("预约核销兑换券")
+ .voucherProduct(longProductName, 2, "12.00", "24.00", true,
+ List.of(longComponentName, "红糖发糕 x4"))
+ .voucherProduct("独立单品", 3, "2.50", "7.50", false, List.of())
+ .voucherTotal("31.50");
+
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
+
+ assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
+ assertTrue(image.getHeight() > 512, "长商品名和套餐组成应触发画布动态扩容");
+ assertTrue(hasDarkPixel(image, 20, 36, 0, image.getHeight()), "商品卡片左边框应清晰可见");
+ assertTrue(hasDarkPixel(image, 430, image.getWidth(), 0, image.getHeight()),
+ "单价、小计和合计金额应保留右侧数字区域");
+ }
+
+ /** 组合商品的黑底标签和粗边框应比同内容单品形成更强的黑白视觉层级。 */
+ @Test
+ void comboProductCardHasStrongerVisualWeightThanSingleProductCard() throws Exception {
+ BufferedImage combo = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherProduct("测试商品", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
+ BufferedImage single = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherProduct("测试商品", 1, "10.00", "10.00", false, List.of()).toPngBytes()));
+
+ assertTrue(countDarkPixels(combo) > countDarkPixels(single),
+ "组合商品的黑底标签和粗边框应产生更多深色像素");
+ }
+
+ /** 套餐组成开关关闭时应删除分隔线、标题和明细占用的整段高度,但保留组合商品卡片。 */
+ @Test
+ void comboProductCardOmitsEntireComponentSectionWhenItemsAreHidden() throws Exception {
+ BufferedImage hidden = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherProduct("测试套餐", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
+ BufferedImage visible = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherProduct("测试套餐", 1, "10.00", "10.00", true,
+ List.of("糖醋排骨 x1", "红糖发糕 x1")).toPngBytes()));
+
+ assertTrue(hidden.getHeight() < visible.getHeight(),
+ "隐藏套餐组成时不应保留分隔线、组合内容标题或明细列表的空白高度");
+ }
+
+ /** 多订单合并版式应完整绘制每笔订单摘要和一份合并商品区。 */
+ @Test
+ void mergedVoucherLayoutDrawsCompactOrderSummariesAndProducts() throws Exception {
+ ThermalReceiptImage receipt = new ThermalReceiptImage()
+ .title("预约核销兑换券")
+ .sectionTitle("订单信息(3笔)")
+ .voucherOrderSummary(1, 3, "2096780000000000001", "2026-09-07", "午餐")
+ .voucherOrderSummary(2, 3, "2096780000000000002", "2026-09-07", "午餐")
+ .voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
+ .divider()
+ .sectionTitle("合并商品明细")
+ .voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
+ .voucherTotal("36.00");
+
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
+
+ assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
+ assertTrue(image.getHeight() > 500, "三笔订单摘要和合并商品区应全部保留在动态画布中");
+ assertTrue(hasDarkPixel(image, 24, 150, 80, image.getHeight()),
+ "订单序号黑底标签应形成清晰的左侧扫描锚点");
+ }
+
+ /** 超长订单号必须换行并推动后续内容,不能与日期餐段相互遮挡。 */
+ @Test
+ void voucherOrderSummaryWrapsLongOrderNumberAndGrowsCanvas() throws Exception {
+ BufferedImage shortOrder = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherOrderSummary(1, 2, "2096780000000000001", "2026-09-07", "午餐")
+ .toPngBytes()));
+ BufferedImage longOrder = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherOrderSummary(1, 2, "2096780000000000001".repeat(5), "2026-09-07", "午餐")
+ .toPngBytes()));
+
+ assertTrue(longOrder.getHeight() > shortOrder.getHeight(),
+ "超长订单号换行后必须增加票据高度,不能覆盖日期餐段");
+ }
+
+ /** 集中订单摘要并只绘制一次商品列表应比逐订单重复商品区更节省票据长度。 */
+ @Test
+ void mergedMultiOrderLayoutIsShorterThanRepeatedProductSections() throws Exception {
+ ThermalReceiptImage grouped = new ThermalReceiptImage().title("预约核销兑换券");
+ for (int index = 1; index <= 3; index++) {
+ grouped.divider().sectionTitle("订单 209678000000000000" + index)
+ .keyValue("预约日期", "2026-09-07")
+ .keyValue("餐段", "午餐")
+ .sectionTitle("剩余可履约商品")
+ .voucherProduct("A套餐-糖醋排骨", 1, "12.00", "12.00", true, List.of())
+ .voucherProduct("红糖发糕", 1, "3.00", "3.00", false, List.of());
+ }
+ ThermalReceiptImage merged = new ThermalReceiptImage().title("预约核销兑换券")
+ .divider().sectionTitle("订单信息(3笔)")
+ .voucherOrderSummary(1, 3, "2096780000000000001", "2026-09-07", "午餐")
+ .voucherOrderSummary(2, 3, "2096780000000000002", "2026-09-07", "午餐")
+ .voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
+ .divider().sectionTitle("合并商品明细")
+ .voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
+ .voucherProduct("红糖发糕", 3, "3.00", "9.00", false, List.of());
+
+ BufferedImage groupedImage = ImageIO.read(new ByteArrayInputStream(grouped.toPngBytes()));
+ BufferedImage mergedImage = ImageIO.read(new ByteArrayInputStream(merged.toPngBytes()));
+
+ assertTrue(mergedImage.getHeight() < groupedImage.getHeight(),
+ "合并版式不应重复绘制每笔订单的整套商品卡片");
+ }
+
private static boolean hasDarkPixel(BufferedImage image, int fromX, int toX, int fromY, int toY) {
for (int y = fromY; y < toY; y++) {
for (int x = fromX; x < toX; x++) {
@@ -52,4 +177,20 @@ class ThermalReceiptImageTest {
}
return false;
}
+
+ /**
+ * 统计图片中的深色像素,用于比较不同商品类型的黑白视觉权重。
+ *
+ * @param image 待统计的热敏票据图片
+ * @return RGB 值低于深灰阈值的像素数量
+ */
+ private static long countDarkPixels(BufferedImage image) {
+ long count = 0L;
+ for (int y = 0; y < image.getHeight(); y++) {
+ for (int x = 0; x < image.getWidth(); x++) {
+ if ((image.getRGB(x, y) & 0xFFFFFF) < 0x606060) count++;
+ }
+ }
+ return count;
+ }
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/hard/HardApiControllerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/hard/HardApiControllerTest.java
index 003300aca..354b321fa 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/hard/HardApiControllerTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/hard/HardApiControllerTest.java
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.hard.service.HardOrderTotalService;
import com.bsd.pjcloud.hard.service.HardPrinterService;
import com.bsd.pjcloud.hard.service.HardTerminalStatusService;
import com.bsd.pjcloud.hard.service.HardTerminalFaceService;
+import com.bsd.pjcloud.hard.service.HardTerminalPrintService;
import com.bsd.pjcloud.hard.service.ReservationMenuQueryService;
import com.bsd.pjcloud.hard.service.VerificationRecordService;
import org.junit.jupiter.api.Test;
@@ -69,11 +70,14 @@ class HardApiControllerTest {
/** 终端人脸删除服务,本组购物车测试不调用。 */
private final HardTerminalFaceService hardTerminalFaceService = mock(HardTerminalFaceService.class);
+ /** 终端打印凭证服务,本组购物车测试不调用。 */
+ private final HardTerminalPrintService hardTerminalPrintService = mock(HardTerminalPrintService.class);
+
/** 被测 Android 硬件接口控制器。 */
private final HardApiController controller = new HardApiController(hardManagerService, hardInvoiceService,
hardPrinterService, hardCarService, hardOrderService, hardOrderSyncService, hardApiService, hardOrderTotalService,
reservationMenuQueryService, verificationRecordService, hardTerminalFaceService,
- hardTerminalStatusService);
+ hardTerminalPrintService, hardTerminalStatusService);
/**
* 验证食堂预约部分退款后数量和行金额按剩余份数投影,已全部退款商品不会返回消费机。
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java
index 0b4619d4a..3864d789c 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java
@@ -4,6 +4,14 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.feign.RemoteParamService;
+import com.bsd.pjcloud.admin.api.vo.UserVO;
+import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
+import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
+import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
+import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.HardPrintStatusEnum;
import com.bsd.pjcloud.hard.api.constant.HardPrintTypeEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
@@ -13,6 +21,7 @@ import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.mapper.HardInvoiceMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardManagerService;
@@ -25,10 +34,12 @@ import org.mockito.ArgumentCaptor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
+import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
@@ -49,6 +60,13 @@ class HardInvoiceServiceImplTest {
private final HardOrderService orderService = mock(HardOrderService.class);
private final HardManagerService managerService = mock(HardManagerService.class);
private final HardPrinterService printerService = mock(HardPrinterService.class);
+
+ /** 无用户上下文的食堂商品内部查询客户端。 */
+ private final RemoteCanteenSpuService remoteCanteenSpuService = mock(RemoteCanteenSpuService.class);
+
+ /** 兑换券套餐组合内容显示开关的租户公共参数客户端。 */
+ private final RemoteParamService remoteParamService = mock(RemoteParamService.class);
+
private final ThreadPoolTaskExecutor taskExecutor = mock(ThreadPoolTaskExecutor.class);
private final HardInvoiceServiceImpl service = mock(HardInvoiceServiceImpl.class, CALLS_REAL_METHODS);
@@ -62,6 +80,8 @@ class HardInvoiceServiceImplTest {
ReflectionTestUtils.setField(service, "orderService", orderService);
ReflectionTestUtils.setField(service, "hardManagerService", managerService);
ReflectionTestUtils.setField(service, "printerService", printerService);
+ ReflectionTestUtils.setField(service, "remoteCanteenSpuService", remoteCanteenSpuService);
+ ReflectionTestUtils.setField(service, "remoteParamService", remoteParamService);
ReflectionTestUtils.setField(service, "hardTaskExecutor", taskExecutor);
}
@@ -83,6 +103,20 @@ class HardInvoiceServiceImplTest {
verify(invoiceMapper, never()).insert(any(HardInvoiceEntity.class));
}
+ /** App 预约订单没有下单设备部门时,打印任务必须使用商户部门快照。 */
+ @Test
+ void redemptionVoucherUsesMerchantDeptForOnlineReservation() {
+ HardOrderEntity order = order();
+ order.setDeptId(null);
+ order.setMerchantDeptId(60L);
+ when(invoiceMapper.insert(any(HardInvoiceEntity.class))).thenReturn(1);
+
+ HardInvoiceEntity result = service.createRedemptionVoucherTask(order, 10L);
+
+ assertEquals(60L, result.getDeptId());
+ verify(invoiceMapper).insert(result);
+ }
+
/** 两个重试请求读到同一失败版本时,未抢到更新的请求不得再次唤醒调度。 */
@Test
void retryTaskSchedulesOnlyWhenConditionalUpdateWins() {
@@ -170,6 +204,230 @@ class HardInvoiceServiceImplTest {
verify(invoiceMapper, never()).update(isNull(), any(Wrapper.class));
}
+ /** 部分退款后的兑换券小计必须只计算剩余可履约数量。 */
+ @Test
+ void voucherSubtotalUsesEffectiveCountAfterCompletedRefund() {
+ HardCarVo car = new HardCarVo();
+ car.setProductPrice(1250);
+ car.setProductCount(4);
+ car.setRefundedProductCount(1);
+ car.setEffectiveProductCount(3);
+ car.setTotalPrice(5000L);
+
+ assertEquals(3750L, HardInvoiceServiceImpl.voucherSubtotal(car));
+ }
+
+ /** 用户身份字段只返回非空值,学生学号优先且缺失时回退老师学号。 */
+ @Test
+ void voucherUserFieldsSkipBlankValuesAndFallbackToTeacherNumber() {
+ UserVO user = new UserVO();
+ user.setName("张三");
+ SysUserStudentDto student = new SysUserStudentDto();
+ student.setStudentNo(" ");
+ student.setClassName(null);
+ user.setStudentInfo(student);
+ SysUserTeacherDto teacher = new SysUserTeacherDto();
+ teacher.setTeacherNo("T-2026001");
+ user.setTeacherInfo(teacher);
+
+ Map fields = HardInvoiceServiceImpl.voucherUserFields(user);
+
+ assertEquals("张三", fields.get("姓名"));
+ assertEquals("T-2026001", fields.get("学号"));
+ assertFalse(fields.containsKey("班级"));
+ }
+
+ /** 学生身份完整时应按姓名、学号、班级的稳定顺序提供打印字段。 */
+ @Test
+ void voucherUserFieldsPreferStudentNumberAndIncludeClassName() {
+ UserVO user = new UserVO();
+ user.setName("李四");
+ SysUserStudentDto student = new SysUserStudentDto();
+ student.setStudentNo("S-2026002");
+ student.setClassName("三年级二班");
+ user.setStudentInfo(student);
+ SysUserTeacherDto teacher = new SysUserTeacherDto();
+ teacher.setTeacherNo("T-2026002");
+ user.setTeacherInfo(teacher);
+
+ Map fields = HardInvoiceServiceImpl.voucherUserFields(user);
+
+ assertEquals(List.of("姓名", "学号", "班级"), List.copyOf(fields.keySet()));
+ assertEquals("S-2026002", fields.get("学号"));
+ assertEquals("三年级二班", fields.get("班级"));
+ }
+
+ /** 兑换券异步生成必须使用无用户令牌的内部接口识别套餐及其组成,避免统一降级为单品。 */
+ @Test
+ void voucherMetadataUsesInnerSpuAndComboQueries() {
+ HardCarVo car = new HardCarVo();
+ car.setProductId(501L);
+ CanteenSpuEntity combo = new CanteenSpuEntity();
+ combo.setId(501L);
+ combo.setSpuType(2);
+ OnlineSpuComboItemVO item = new OnlineSpuComboItemVO();
+ item.setName("糖醋排骨");
+ item.setQuantity(1);
+ when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(501L))).thenReturn(R.ok(List.of(combo)));
+ when(remoteCanteenSpuService.getInnerComboItemsBySpuIds(List.of(501L)))
+ .thenReturn(R.ok(Map.of(501L, List.of(item))));
+
+ @SuppressWarnings("unchecked")
+ Map metadata = ReflectionTestUtils.invokeMethod(service, "loadProductMetadata", List.of(car), true);
+ Object product = metadata.get(501L);
+
+ assertNotNull(product);
+ assertTrue((Boolean) ReflectionTestUtils.invokeMethod(product, "combo"));
+ assertEquals(List.of(item), ReflectionTestUtils.invokeMethod(product, "items"));
+ verify(remoteCanteenSpuService).getInnerSpuListByIds(List.of(501L));
+ verify(remoteCanteenSpuService).getInnerComboItemsBySpuIds(List.of(501L));
+ verify(remoteCanteenSpuService, never()).getSpuListByIds(any());
+ verify(remoteCanteenSpuService, never()).getComboItemsBySpuIds(any());
+ }
+
+ /** 商品内部查询暂时不可用时应保持原有降级策略,避免已完成核销因打印资料失败而回滚。 */
+ @Test
+ void voucherMetadataFallsBackToSingleProductWhenInnerQueryFails() {
+ HardCarVo car = new HardCarVo();
+ car.setProductId(501L);
+ when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(501L)))
+ .thenThrow(new RuntimeException("canteen service unavailable"));
+
+ @SuppressWarnings("unchecked")
+ Map metadata = ReflectionTestUtils.invokeMethod(service, "loadProductMetadata", List.of(car), true);
+
+ assertTrue(metadata.isEmpty());
+ verify(remoteCanteenSpuService, never()).getInnerComboItemsBySpuIds(any());
+ }
+
+ /** 关闭组成内容时仍须识别组合商品,但不应请求或携带组成明细。 */
+ @Test
+ void hiddenVoucherComboItemsKeepComboTypeWithoutLoadingDetails() {
+ HardCarVo car = new HardCarVo();
+ car.setProductId(501L);
+ CanteenSpuEntity combo = new CanteenSpuEntity();
+ combo.setId(501L);
+ combo.setSpuType(2);
+ when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(501L))).thenReturn(R.ok(List.of(combo)));
+
+ @SuppressWarnings("unchecked")
+ Map metadata = ReflectionTestUtils.invokeMethod(service, "loadProductMetadata", List.of(car), false);
+ Object product = metadata.get(501L);
+
+ assertNotNull(product);
+ assertTrue((Boolean) ReflectionTestUtils.invokeMethod(product, "combo"));
+ assertEquals(List.of(), ReflectionTestUtils.invokeMethod(product, "items"));
+ verify(remoteCanteenSpuService, never()).getInnerComboItemsBySpuIds(any());
+ }
+
+ /** 公共参数尚未配置时,套餐组合内容开关必须默认关闭。 */
+ @Test
+ void voucherComboItemsDefaultToHiddenWhenParamIsMissing() {
+ when(remoteParamService.getByKey("HARD_VOUCHER_SHOW_COMBO_ITEMS")).thenReturn(R.ok((String) null));
+
+ Boolean visible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
+
+ assertFalse(Boolean.TRUE.equals(visible));
+ }
+
+ /** 公共参数显式设为 true 或 1 时,兑换券允许显示套餐组合内容。 */
+ @Test
+ void voucherComboItemsCanBeEnabledByPublicParam() {
+ when(remoteParamService.getByKey("HARD_VOUCHER_SHOW_COMBO_ITEMS"))
+ .thenReturn(R.ok(" true "), R.ok("1"));
+
+ Boolean textValueVisible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
+ Boolean numericValueVisible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
+
+ assertTrue(Boolean.TRUE.equals(textValueVisible));
+ assertTrue(Boolean.TRUE.equals(numericValueVisible));
+ }
+
+ /** 公共参数服务异常时必须回退为关闭,不能阻断已核销订单出票。 */
+ @Test
+ void voucherComboItemsStayHiddenWhenParamServiceFails() {
+ when(remoteParamService.getByKey("HARD_VOUCHER_SHOW_COMBO_ITEMS"))
+ .thenThrow(new RuntimeException("upms unavailable"));
+
+ Boolean visible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
+
+ assertFalse(Boolean.TRUE.equals(visible));
+ }
+
+ /** 多订单商品合并参数尚未配置时必须保留原有按订单展示版式。 */
+ @Test
+ void multiOrderVoucherProductsDefaultToOrderGroupedLayout() {
+ when(remoteParamService.getByKey("HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS"))
+ .thenReturn(R.ok((String) null));
+
+ Boolean merged = ReflectionTestUtils.invokeMethod(service, "shouldMergeMultiOrderVoucherProducts");
+
+ assertFalse(Boolean.TRUE.equals(merged));
+ }
+
+ /** 多订单商品合并参数显式设为 true 或 1 时均应启用合并版式。 */
+ @Test
+ void multiOrderVoucherProductsCanBeMergedByPublicParam() {
+ when(remoteParamService.getByKey("HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS"))
+ .thenReturn(R.ok(" TRUE "), R.ok("1"));
+
+ Boolean textValueMerged = ReflectionTestUtils.invokeMethod(service,
+ "shouldMergeMultiOrderVoucherProducts");
+ Boolean numericValueMerged = ReflectionTestUtils.invokeMethod(service,
+ "shouldMergeMultiOrderVoucherProducts");
+
+ assertTrue(Boolean.TRUE.equals(textValueMerged));
+ assertTrue(Boolean.TRUE.equals(numericValueMerged));
+ }
+
+ /** 多订单版式参数服务异常时必须回退原版,不能阻断已经完成核销的订单出票。 */
+ @Test
+ void multiOrderVoucherProductsStayGroupedWhenParamServiceFails() {
+ when(remoteParamService.getByKey("HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS"))
+ .thenThrow(new RuntimeException("upms unavailable"));
+
+ Boolean merged = ReflectionTestUtils.invokeMethod(service, "shouldMergeMultiOrderVoucherProducts");
+
+ assertFalse(Boolean.TRUE.equals(merged));
+ }
+
+ /** 相同快照商品应合并数量和小计,不同价格商品保持独立且顺序按首次出现稳定排列。 */
+ @Test
+ void mergeVoucherProductsUsesSnapshotIdentityAndKeepsFirstAppearanceOrder() {
+ OnlineSpuComboItemVO component = new OnlineSpuComboItemVO();
+ component.setName("糖醋排骨");
+ component.setQuantity(1);
+ HardInvoiceServiceImpl.VoucherProductLine firstCombo =
+ new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1200L, 1200L,
+ true, List.of(component));
+ HardInvoiceServiceImpl.VoucherProductLine single =
+ new HardInvoiceServiceImpl.VoucherProductLine(601L, "红糖发糕", 1, 300L, 300L,
+ false, List.of());
+ HardInvoiceServiceImpl.VoucherProductLine repeatedCombo =
+ new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 2, 1200L, 2400L,
+ true, List.of(component));
+ HardInvoiceServiceImpl.VoucherProductLine changedPrice =
+ new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1300L, 1300L,
+ true, List.of(component));
+ HardOrderEntity firstOrder = order();
+ HardOrderEntity secondOrder = order();
+ secondOrder.setId(2L);
+ List orders = List.of(
+ new HardInvoiceServiceImpl.VoucherOrderProducts(firstOrder, List.of(firstCombo, single)),
+ new HardInvoiceServiceImpl.VoucherOrderProducts(secondOrder, List.of(repeatedCombo, changedPrice)));
+
+ List merged =
+ HardInvoiceServiceImpl.mergeVoucherProducts(orders);
+
+ assertEquals(List.of("A套餐", "红糖发糕", "A套餐"),
+ merged.stream().map(HardInvoiceServiceImpl.VoucherProductLine::productName).toList());
+ assertEquals(3, merged.get(0).quantity());
+ assertEquals(3600L, merged.get(0).subtotal());
+ assertSame(component, merged.get(0).comboItems().get(0));
+ assertEquals(1200L, merged.get(0).unitPrice());
+ assertEquals(1300L, merged.get(2).unitPrice());
+ }
+
private static HardOrderEntity order() {
return HardOrderEntity.builder().id(1L).hardId(10L).merchantId(30L).deptId(40L).tenantId(50L).build();
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImplTest.java
new file mode 100644
index 000000000..332b02f7b
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImplTest.java
@@ -0,0 +1,115 @@
+package com.bsd.pjcloud.hard.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.bsd.pjcloud.hard.api.constant.HardPrintStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.HardPrintTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
+import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
+import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
+import com.bsd.pjcloud.hard.service.HardInvoiceService;
+import com.bsd.pjcloud.hard.service.HardManagerService;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 终端打印记录的设备范围与兑换券重打边界测试。
+ */
+class HardTerminalPrintServiceImplTest {
+
+ /** 服务端打印任务服务替身,不执行真实任务调度。 */
+ private final HardInvoiceService invoiceService = mock(HardInvoiceService.class);
+
+ /** 设备服务替身,用于构造食堂终端和打印机资产。 */
+ private final HardManagerService managerService = mock(HardManagerService.class);
+
+ /** 当前被测的终端打印边界服务。 */
+ private final HardTerminalPrintServiceImpl service =
+ new HardTerminalPrintServiceImpl(invoiceService, managerService);
+
+ /** 查询结果应保留服务端任务字段并补充来源终端和打印机名称。 */
+ @Test
+ void listRecordsAddsDeviceNames() {
+ HardInvoiceEntity record = voucher(100L, 10L);
+ record.setPrinterHardId(20L);
+ when(managerService.getById(10L)).thenReturn(source(10L));
+ when(invoiceService.list(any(Wrapper.class))).thenReturn(List.of(record));
+ when(managerService.listByIds(any())).thenReturn(List.of(
+ HardManagerEntity.builder().id(10L).name("食堂终端").build(),
+ HardManagerEntity.builder().id(20L).name("一号打印机").build()));
+
+ List result = service.listRecords(10L, 1L);
+
+ assertEquals(1, result.size());
+ assertEquals("食堂终端", result.get(0).getHardName());
+ assertEquals("一号打印机", result.get(0).getPrinterHardName());
+ assertEquals(100L, result.get(0).getHistoryId());
+ }
+
+ /** 当前终端不得借用其他终端的打印任务发起兑换券重打。 */
+ @Test
+ void reprintVoucherRejectsAnotherTerminalTask() {
+ when(managerService.getById(10L)).thenReturn(source(10L));
+ when(invoiceService.getById(100L)).thenReturn(voucher(100L, 11L));
+
+ RuntimeException error = assertThrows(
+ RuntimeException.class,
+ () -> service.reprintVoucher(10L, 100L, "纸张破损"));
+
+ assertEquals("只能重打当前终端产生的兑换券", error.getMessage());
+ }
+
+ /** 当前终端自己的兑换券应继续交给既有服务分配新序号和异步下发。 */
+ @Test
+ void reprintVoucherDelegatesToExistingPrintFlow() {
+ HardInvoiceEntity history = voucher(100L, 10L);
+ HardInvoiceEntity created = voucher(101L, 10L);
+ created.setPrintNo(2);
+ when(managerService.getById(10L)).thenReturn(source(10L));
+ when(invoiceService.getById(100L)).thenReturn(history);
+ when(invoiceService.reprintTask(100L, "纸张破损")).thenReturn(created);
+
+ HardInvoiceEntity result = service.reprintVoucher(10L, 100L, " 纸张破损 ");
+
+ assertSame(created, result);
+ verify(invoiceService).reprintTask(100L, "纸张破损");
+ }
+
+ /**
+ * 构造一个有效的食堂终端。
+ *
+ * @param id 服务端设备 ID
+ * @return 食堂终端实体
+ */
+ private static HardManagerEntity source(Long id) {
+ return HardManagerEntity.builder().id(id).type(HardTypeEnum.CANTEEN.getType()).build();
+ }
+
+ /**
+ * 构造一个已经打印的兑换券任务。
+ *
+ * @param id 打印任务 ID
+ * @param sourceHardId 来源业务终端 ID
+ * @return 已打印兑换券任务
+ */
+ private static HardInvoiceEntity voucher(Long id, Long sourceHardId) {
+ return HardInvoiceEntity.builder()
+ .id(id)
+ .orderId(1L)
+ .hardId(sourceHardId)
+ .printType(HardPrintTypeEnum.REDEMPTION_VOUCHER.getType())
+ .printNo(1)
+ .retryVersion(0)
+ .status(HardPrintStatusEnum.PRINT.getType())
+ .build();
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java
index f8eda3faa..9bee161d5 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java
@@ -132,6 +132,19 @@ class HardOrderServiceImplVerificationTest {
verify(hardMessageOutboxMapper, never()).insert(any(HardMessageOutboxEntity.class));
}
+ /** Hardware 本地核销失败后必须通过现有 Wallet 接口恢复已经使用的预约码。 */
+ @Test
+ void localVerificationFailureRestoresRemoteReservationCode() {
+ HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY);
+ RuntimeException originalException = new RuntimeException("打印任务写入失败");
+ when(remoteCodeUseService.rollbackReservationCode(1L)).thenReturn(R.ok(true));
+
+ ReflectionTestUtils.invokeMethod(service, "rollbackReservationCodeAfterLocalFailure", order, originalException);
+
+ verify(remoteCodeUseService).rollbackReservationCode(1L);
+ assertEquals(0, originalException.getSuppressed().length);
+ }
+
/**
* 验证已核销且仍有可履约商品的食堂预约会恢复订单、二维码和取餐广播。
*/
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java
index a69ca2a5a..64f1da0cd 100644
--- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java
@@ -130,6 +130,16 @@ public interface RemoteUserService {
@GetMapping("/user/inner/get-by-user-id")
R getInnerByUserId(@RequestParam("userId") Long userId);
+ /**
+ * 按用户 ID 查询内部业务展示所需的主资料和完整身份扩展。
+ *
+ * @param userId 用户主键
+ * @return 用户姓名、部门、身份类型、学生资料和老师资料;用户不存在时数据为空
+ */
+ @NoToken
+ @GetMapping("/user/inner/vo-by-user-id")
+ R getInnerUserVoByUserId(@RequestParam("userId") Long userId);
+
@GetMapping("/user/get-by-user-id")
R getByUserId(@RequestParam("userId") Long userId);
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java
index e9cb3b330..5e6e00f3e 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java
@@ -219,6 +219,20 @@ public class SysUserController {
return R.ok(userService.findUserInfo(user));
}
+ /**
+ * 按用户 ID 查询内部业务展示所需的主资料和完整身份扩展。
+ *
+ * @param userId 用户主键
+ * @return 用户姓名、部门、身份类型、学生资料和老师资料;用户不存在时返回失败响应
+ */
+ @Inner
+ @GetMapping("/inner/vo-by-user-id")
+ public R getInnerUserVoByUserId(@RequestParam Long userId) {
+ UserVO user = userService.selectUserVoById(userId);
+ if (user == null) return R.failed(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERINFO_EMPTY, userId));
+ return R.ok(user);
+ }
+
/**
* 根据手机号查询用户
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionUpdateInfoDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionUpdateInfoDto.java
new file mode 100644
index 000000000..9532d6a0b
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionUpdateInfoDto.java
@@ -0,0 +1,37 @@
+package com.bsd.pjcloud.wallet.api.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 钱包流水信息修改参数,仅允许后台修正流水触发事件的归属部门和创建时间,
+ * 不承载金额、余额、业务关联等不可变流水信息。
+ */
+@Data
+@Schema(description = "钱包流水信息修改参数")
+public class WalletTransactionUpdateInfoDto {
+
+ /**
+ * 需要修改信息的钱包流水编号,由钱包流水列表返回。
+ */
+ @NotNull(message = "钱包流水编号不能为空")
+ @Schema(description = "钱包流水编号", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Long id;
+
+ /**
+ * 流水触发事件归属的新部门编号;必须是当前租户有效部门,用于更正部门归属统计口径。
+ */
+ @NotNull(message = "流水归属部门不能为空")
+ @Schema(description = "流水归属部门编号", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Long deptId;
+
+ /**
+ * 流水新的创建时间,用于更正流水归属的统计周期;时间格式为 yyyy-MM-dd HH:mm:ss。
+ */
+ @NotNull(message = "流水创建时间不能为空")
+ @Schema(description = "流水创建时间,格式 yyyy-MM-dd HH:mm:ss", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime createTime;
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java
index b59373630..d6fb7a5c6 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java
@@ -140,9 +140,11 @@ public class WalletManageController {
}
private R> listWalletByIds(List walletIds) {
+ // 内部接口无用户凭证,不能走依赖 UPMS 数据权限的 scopeList;
+ // 钱包主键已由调用方业务记录过滤,这里直接按主键联查钱包类型返回。
WalletManageDto dto = new WalletManageDto();
dto.setWalletIds(walletIds);
- List wallet = walletManageService.scopeList(dto, null);
+ List wallet = walletManageMapper.joinList(dto);
if (ObjectUtil.isEmpty(wallet)) return R.failed("钱包不存在");
return R.ok(wallet);
}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
index 85cf05389..3cd63b61e 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java
@@ -18,6 +18,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotBatchDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
+import com.bsd.pjcloud.wallet.api.dto.WalletTransactionUpdateInfoDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo;
@@ -214,6 +215,20 @@ public class WalletTransactionController {
return R.ok(walletTransactionService.updateRemark(dto));
}
+ /**
+ * 修改单笔钱包流水的归属部门和创建时间,用于更正流水归属统计口径,不开放金额、余额等核心字段变更。
+ *
+ * @param dto 流水编号、新部门编号和新创建时间
+ * @return 更新到当前租户有效流水时返回 {@code true}
+ */
+ @PutMapping("/update-info")
+ @Operation(summary = "修改钱包流水信息")
+ @SysLog("修改钱包流水信息")
+ @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_update_info')")
+ public R updateInfo(@Valid @RequestBody WalletTransactionUpdateInfoDto dto) {
+ return R.ok(walletTransactionService.updateInfo(dto));
+ }
+
@Operation(summary = "后台充值分页", description = "后台充值分页")
@PostMapping("/page-cz")
public R pageCz(@ParameterObject Page page, @RequestBody WalletTransactionDto dto) {
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java
index 4bd7c9ae3..b3f87d637 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java
@@ -134,6 +134,7 @@ public interface WalletManageMapper extends PjcloudBaseMapper deptResult = deptService.getDeptByDeptId(dto.getDeptId());
+ if (deptResult == null || !deptResult.isOk() || ObjectUtil.isEmpty(deptResult.getData())) {
+ throw new I18nBizException(ErrorCodes.COMMON_RECORD_NOT_FOUND);
+ }
+ // 传入空实体会跳过 MyBatis-Plus 的修改人和修改时间填充,因此保留更新实体承载审计字段。
+ WalletTransactionEntity updateEntity = new WalletTransactionEntity();
+ int updated = baseMapper.update(updateEntity, Wrappers.lambdaUpdate()
+ .eq(WalletTransactionEntity::getId, dto.getId())
+ .set(WalletTransactionEntity::getDeptId, dto.getDeptId())
+ .set(WalletTransactionEntity::getCreateTime, dto.getCreateTime()));
+ // 部分 MySQL affected-row 配置会把“值未变化”返回为 0,仅在记录也查不到时才判定失败。
+ if (updated == 0 && baseMapper.selectById(dto.getId()) == null) {
+ throw new I18nBizException(ErrorCodes.WALLET_TX_NOT_FOUND);
+ }
+ return true;
+ }
+
@Override
// @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#dto.getWalletId()")
public Boolean updateWalletBalance(WalletTransactionDto dto) {