From 6818c5fa5a596ba6d266505611e44606cc7a185b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 4 Sep 2026 17:33:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=A0=B8=E9=94=80=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E5=85=91=E6=8D=A2=E5=88=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hard/ReservationMenuBatchCancelDto.java | 38 ++++ .../hard/api/utils/ThermalReceiptImage.java | 150 ++++++++++++++ .../controller/hard/HardApiController.java | 46 +++++ .../hard/service/HardInvoiceService.java | 9 + .../hard/service/HardOrderService.java | 8 + .../service/HardTerminalPrintService.java | 31 +++ .../service/ReservationMenuQueryService.java | 10 + .../service/impl/HardInvoiceServiceImpl.java | 190 +++++++++++++++--- .../impl/HardTerminalPrintServiceImpl.java | 125 ++++++++++++ .../impl/ReservationMenuQueryServiceImpl.java | 70 +++++++ .../impl/order/HardOrderServiceImpl.java | 168 +++++++++++++++- .../api/utils/ThermalReceiptImageTest.java | 53 ++++- .../impl/HardInvoiceServiceImplTest.java | 72 +++++++ .../HardTerminalPrintServiceImplTest.java | 115 +++++++++++ .../HardOrderServiceImplVerificationTest.java | 13 ++ .../admin/api/feign/RemoteUserService.java | 10 + .../admin/controller/SysUserController.java | 14 ++ 17 files changed, 1088 insertions(+), 34 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/ReservationMenuBatchCancelDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalPrintService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImpl.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardTerminalPrintServiceImplTest.java diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/ReservationMenuBatchCancelDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/ReservationMenuBatchCancelDto.java new file mode 100644 index 000000000..a9ad6f6d9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/ReservationMenuBatchCancelDto.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.hard.api.dto.hard; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 预约菜单批量核销参数。 + * + *

批次只接受服务端已经返回给终端的预约订单主键;设备、用户、商户、日期和餐段均由服务端 + * 从订单快照校验,不能由客户端拼接覆盖。

+ */ +@Data +public class ReservationMenuBatchCancelDto { + + /** + * 本次一次性核销的预约订单主键,服务端会去重并按主键升序加锁。 + */ + @NotEmpty(message = "预约订单不能为空") + @Size(max = 100, message = "一次最多核销100笔预约订单") + @Schema(description = "待批量核销预约订单ID", requiredMode = Schema.RequiredMode.REQUIRED) + private List orderIds; + + /** + * 显式拒绝未声明字段,避免终端误传日期、餐段或设备参数形成越权选择条件。 + * + * @param fieldName 未声明字段名称 + * @param value 未声明字段值 + */ + @JsonAnySetter + public void rejectUnknownField(String fieldName, Object value) { + throw new IllegalArgumentException("预约批量核销接口不支持参数:" + fieldName); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java index 1cd639909..a67751adb 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java @@ -38,6 +38,12 @@ public class ThermalReceiptImage { /** 套餐组成项左侧缩进。 */ private static final int CHILD_INDENT = 32; + /** 兑换券商品卡片的内容内边距。 */ + private static final int PRODUCT_CARD_PADDING = 14; + + /** 相邻兑换券商品卡片的垂直间距。 */ + private static final int PRODUCT_CARD_GAP = 14; + /** 中文优先逻辑字体名称。 */ private static final String FONT_FAMILY = "SansSerif"; @@ -170,6 +176,150 @@ public class ThermalReceiptImage { return item(name, String.format("%s %s", quantity, amount), false, false, 190); } + /** + * 绘制兑换券商品卡片,使用文字标签和边框粗细同时区分组合商品与单品。 + * + *

商品名称和组合内容均按真实像素宽度完整换行;价格区固定展示单价、数量和小计。 + * 组合内容只解释套餐构成,不参与当前卡片金额计算。

+ * + * @param name 商品快照名称;空值显示“未命名商品” + * @param quantity 部分退款后的剩余可履约数量 + * @param unitPrice 商品快照单价,单位元且不含货币符号 + * @param subtotal 剩余可履约数量对应的小计,单位元且不含货币符号 + * @param combo 是否为组合商品 + * @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空 + * @return 当前绘制器 + */ + public ThermalReceiptImage voucherProduct(String name, int quantity, String unitPrice, String subtotal, + boolean combo, List componentItems) { + String typeLabel = combo ? "组合商品" : "单品"; + String safeName = name == null || name.isBlank() ? "未命名商品" : name; + String safeUnitPrice = unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice; + String safeSubtotal = subtotal == null || subtotal.isBlank() ? "--" : subtotal; + List safeComponents = componentItems == null ? List.of() : componentItems.stream() + .filter(item -> item != null && !item.isBlank()).toList(); + int innerWidth = contentWidth() - PRODUCT_CARD_PADDING * 2; + + setFont(Font.BOLD, 21); + FontMetrics tagMetrics = graphics.getFontMetrics(); + int tagHeight = tagMetrics.getHeight() + 6; + int tagWidth = tagMetrics.stringWidth(typeLabel) + 20; + + setFont(Font.BOLD, 26); + FontMetrics nameMetrics = graphics.getFontMetrics(); + List nameLines = wrap(safeName, innerWidth, nameMetrics); + int nameLineHeight = nameMetrics.getHeight() + 4; + + setFont(Font.PLAIN, 21); + FontMetrics priceMetrics = graphics.getFontMetrics(); + String priceAndQuantity = "单价 ¥" + safeUnitPrice + " x" + Math.max(0, quantity); + String subtotalText = "小计 ¥" + safeSubtotal; + boolean splitPriceLine = priceMetrics.stringWidth(priceAndQuantity) + + priceMetrics.stringWidth(subtotalText) + 24 > innerWidth; + int priceLineHeight = priceMetrics.getHeight() + 4; + + setFont(Font.PLAIN, 21); + FontMetrics componentMetrics = graphics.getFontMetrics(); + int componentLineHeight = componentMetrics.getHeight() + 3; + List> componentLines = new ArrayList<>(); + for (String component : safeComponents) { + componentLines.add(wrap("- " + component, innerWidth - CHILD_INDENT, componentMetrics)); + } + int componentHeight = componentLines.stream().mapToInt(List::size).sum() * componentLineHeight; + int comboSectionHeight = combo && !componentLines.isEmpty() + ? 18 + componentLineHeight + componentHeight : 0; + int cardHeight = PRODUCT_CARD_PADDING + tagHeight + 10 + nameLines.size() * nameLineHeight + 8 + + (splitPriceLine ? priceLineHeight * 2 : priceLineHeight) + comboSectionHeight + + PRODUCT_CARD_PADDING; + ensureCapacity(cardHeight + PRODUCT_CARD_GAP); + + int cardTop = cursorY; + int contentX = HORIZONTAL_PADDING + PRODUCT_CARD_PADDING; + int contentY = cardTop + PRODUCT_CARD_PADDING; + graphics.setColor(Color.BLACK); + graphics.setStroke(new BasicStroke(combo ? 4F : 2F)); + graphics.drawRect(HORIZONTAL_PADDING, cardTop, contentWidth(), cardHeight); + + setFont(Font.BOLD, 21); + if (combo) { + graphics.fillRect(contentX, contentY, tagWidth, tagHeight); + graphics.setColor(Color.WHITE); + graphics.drawString(typeLabel, contentX + 10, contentY + 3 + tagMetrics.getAscent()); + graphics.setColor(Color.BLACK); + } else { + graphics.setStroke(new BasicStroke(2F)); + graphics.drawRect(contentX, contentY, tagWidth, tagHeight); + graphics.drawString(typeLabel, contentX + 10, contentY + 3 + tagMetrics.getAscent()); + } + contentY += tagHeight + 10; + + setFont(Font.BOLD, 26); + for (String line : nameLines) { + graphics.drawString(line, contentX, contentY + nameMetrics.getAscent()); + contentY += nameLineHeight; + } + contentY += 8; + + setFont(Font.PLAIN, 21); + graphics.drawString(priceAndQuantity, contentX, contentY + priceMetrics.getAscent()); + if (splitPriceLine) { + contentY += priceLineHeight; + } + graphics.drawString(subtotalText, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - PRODUCT_CARD_PADDING + - priceMetrics.stringWidth(subtotalText), + contentY + priceMetrics.getAscent()); + contentY += priceLineHeight; + + if (combo && !componentLines.isEmpty()) { + contentY += 8; + graphics.setStroke(new BasicStroke(1F)); + graphics.drawLine(contentX, contentY, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - PRODUCT_CARD_PADDING, contentY); + contentY += 10; + setFont(Font.BOLD, 21); + graphics.drawString("组合内容", contentX, contentY + componentMetrics.getAscent()); + contentY += componentLineHeight; + setFont(Font.PLAIN, 21); + for (List lines : componentLines) { + for (String line : lines) { + graphics.drawString(line, contentX + CHILD_INDENT, + contentY + componentMetrics.getAscent()); + contentY += componentLineHeight; + } + } + } + cursorY += cardHeight + PRODUCT_CARD_GAP; + return this; + } + + /** + * 绘制兑换券商品合计,使用粗分隔线和大号金额形成票据金额层级。 + * + * @param totalAmount 剩余可履约商品合计,单位元且不含货币符号 + * @return 当前绘制器 + */ + public ThermalReceiptImage voucherTotal(String totalAmount) { + String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount; + String label = "商品合计"; + String amount = "¥" + safeAmount; + setFont(Font.BOLD, 30); + FontMetrics metrics = graphics.getFontMetrics(); + int blockHeight = metrics.getHeight() + 30; + ensureCapacity(blockHeight + 14); + graphics.setColor(Color.BLACK); + graphics.setStroke(new BasicStroke(3F)); + graphics.drawLine(HORIZONTAL_PADDING, cursorY, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY); + int baseline = cursorY + 15 + metrics.getAscent(); + graphics.drawString(label, HORIZONTAL_PADDING, baseline); + graphics.drawString(amount, PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - metrics.stringWidth(amount), baseline); + graphics.drawLine(HORIZONTAL_PADDING, cursorY + blockHeight, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + blockHeight); + cursorY += blockHeight + 14; + return this; + } + /** * 使用指定尾列宽度绘制一条可换行明细。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index 86db3e1b9..796614607 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -17,6 +17,7 @@ import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.async.SyncOfflineReqVo; import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRetryDto; import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto; import com.bsd.pjcloud.hard.api.dto.hard.*; import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo; @@ -67,6 +68,7 @@ public class HardApiController { private final ReservationMenuQueryService reservationMenuQueryService; private final VerificationRecordService verificationRecordService; private final HardTerminalFaceService hardTerminalFaceService; + private final HardTerminalPrintService hardTerminalPrintService; /** * 食堂、超市 Android 终端心跳状态服务。 @@ -397,6 +399,35 @@ public class HardApiController { return R.ok(hardInvoiceService.createPrintTask(request)); } + /** + * 查询当前业务终端为指定订单生成的发票、兑换券及最终打印结果。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param orderId 核销记录中的服务端订单 ID + * @return 当前设备范围内的服务端打印记录,结果不会读取终端本地数据库 + */ + @GetMapping("/printer/records") + @Operation(summary = "查询业务终端订单打印记录") + public R> printerRecords(@RequestHeader("DEVICE-ID") Long hardId, + @RequestParam Long orderId) { + return R.ok(hardTerminalPrintService.listRecords(hardId, orderId)); + } + + /** + * 由当前业务终端对本机已打印的核销兑换券创建下一打印序号。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param request 已打印任务 ID 和操作员填写的重打原因 + * @return 新建或并发场景下复用的兑换券打印任务 + */ + @PostMapping("/printer/reprint") + @Operation(summary = "业务终端重新打印核销兑换券") + public R reprintVoucher(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrintRetryDto request) { + return R.ok(hardTerminalPrintService.reprintVoucher( + hardId, request.getHistoryId(), request.getReprintReason())); + } + @PostMapping("/sync-offline-order") @Operation(summary = "同步离线订单") @Idempotent(expireTime = 1, info = "正在处理中,请勿重复操作") @@ -433,6 +464,21 @@ public class HardApiController { return R.ok(reservationMenuQueryService.cancel(dto, hardId)); } + /** + * 在同一事务中批量核销预约订单,整批只生成一张兑换券。 + * + * @param dto 待核销订单主键列表 + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @return 整批核销成功时返回 {@code true} + */ + @PostMapping("/reservation-menu/cancel-batch") + @Operation(summary = "批量核销预约订单并合并打印兑换券") + @Idempotent(expireTime = 1, key = "'/hard-api/ad/reservation-menu/cancel-batch:' + #dto.getOrderIds()", info = "正在核销中,请勿重复操作") + public R cancelReservationMenuBatch(@Valid @RequestBody ReservationMenuBatchCancelDto dto, + @RequestHeader(value = "DEVICE-ID", required = false) Long hardId) { + return R.ok(reservationMenuQueryService.cancelBatch(dto, hardId)); + } + @PostMapping("/verification-record/page") @Operation(summary = "分页查询当前终端核销记录") public R> verificationRecordPage(@ParameterObject Page page, @RequestHeader("DEVICE-ID") Long hardId, @Valid @RequestBody VerificationRecordQueryDto dto) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java index c3028a2a0..faca006f8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java @@ -61,6 +61,15 @@ public interface HardInvoiceService extends IService { */ HardInvoiceEntity createRedemptionVoucherTask(HardOrderEntity order, Long sourceHardId); + /** + * 为同一批已核销预约订单创建一个兑换券打印任务,订单内容在异步生成图片时按核销批次恢复。 + * + * @param orders 已在同一事务中完成核销的订单,第一笔作为打印任务锚点 + * @param sourceHardId 实际核销终端 ID + * @return 合并批次的唯一兑换券任务 + */ + HardInvoiceEntity createRedemptionVoucherBatchTask(List orders, Long sourceHardId); + /** * 管理端创建首张发票或补建兑换券任务。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index e2bae5ad4..08c4cefff 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -312,6 +312,14 @@ public interface HardOrderService extends IService { */ Boolean handCancelCode(HandCancelCodeDto dto); + /** + * 在同一个本地事务中核销一组预约订单,并在全部成功后创建一个合并兑换券任务。 + * + * @param dtos 订单、来源设备及可选终端订单号组成的批量核销参数 + * @return 整批订单均完成核销时返回 {@code true} + */ + Boolean handCancelReservationCodes(List dtos); + /** * 将误核销的线上预订订单恢复到待核销状态,并同步恢复对应预约二维码。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalPrintService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalPrintService.java new file mode 100644 index 000000000..c29da1b1b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalPrintService.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; +import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; + +import java.util.List; + +/** + * 食堂、超市 Android 终端查询本机打印凭证并发起兑换券重打的设备边界服务。 + */ +public interface HardTerminalPrintService { + + /** + * 查询当前来源终端为指定订单生成的打印凭证,最多返回最近一百条。 + * + * @param sourceHardId DEVICE-ID 请求头中的食堂或超市终端 ID + * @param orderId 核销记录中的服务端订单 ID + * @return 按创建时间和任务 ID 倒序排列的服务端打印任务,不读取终端本地数据库 + */ + List listRecords(Long sourceHardId, Long orderId); + + /** + * 校验任务类型和来源终端后,为已经打印的兑换券创建下一打印序号。 + * + * @param sourceHardId DEVICE-ID 请求头中的食堂或超市终端 ID + * @param historyId 已打印兑换券任务 ID + * @param reason 操作员填写的人工重打原因 + * @return 新建或并发场景下复用的兑换券打印任务 + */ + HardInvoiceEntity reprintVoucher(Long sourceHardId, Long historyId, String reason); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/ReservationMenuQueryService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/ReservationMenuQueryService.java index b0a15cc33..543eb0a88 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/ReservationMenuQueryService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/ReservationMenuQueryService.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.hard.service; import com.bsd.pjcloud.hard.api.dto.hard.ReservationMenuCancelDto; +import com.bsd.pjcloud.hard.api.dto.hard.ReservationMenuBatchCancelDto; import com.bsd.pjcloud.hard.api.dto.hard.ReservationMenuQueryDto; import com.bsd.pjcloud.hard.api.vo.android.ReservationMenuQueryVo; @@ -18,4 +19,13 @@ public interface ReservationMenuQueryService { /** 设备 ID 只用于记录核销归属,订单选择仍严格由请求体中的 orderId 决定。 */ Boolean cancel(ReservationMenuCancelDto dto, Long hardId); + + /** + * 在同一事务中批量核销同一识别用户的一组预约订单,并只创建一张兑换券打印任务。 + * + * @param dto 批量订单主键参数 + * @param hardId 当前食堂或超市终端 ID,可为空以兼容历史调用 + * @return 整批订单均核销成功时返回 {@code true} + */ + Boolean cancelBatch(ReservationMenuBatchCancelDto dto, Long hardId); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java index a861825f9..007ea285d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java @@ -14,6 +14,7 @@ import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.utils.DataUtil; +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; @@ -53,8 +54,6 @@ import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; -import com.bsd.pjcloud.wallet.api.entity.WalletCodeUseEntity; -import com.bsd.pjcloud.wallet.api.feign.RemoteCodeUseService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil; @@ -140,9 +139,6 @@ public class HardInvoiceServiceImpl extends ServiceImpl orders, Long sourceHardId) { + if (orders == null || orders.isEmpty() || orders.get(0) == null) { + throw new RuntimeException("预约订单不能为空,无法创建兑换券任务"); + } + // 打印表以首笔订单作为任务唯一锚点;异步图片生成通过首订单核销快照恢复整批订单。 + return createInitialTask(orders.get(0), HardPrintTypeEnum.REDEMPTION_VOUCHER, sourceHardId, null); } /** {@inheritDoc} */ @@ -647,38 +654,54 @@ public class HardInvoiceServiceImpl extends ServiceImpl身份资料查询失败时降级为不打印用户区,不能阻断已经完成核销的订单出票;商品金额按 + * 部分退款后的剩余可履约数量重新计算,组合组成项只做说明而不重复计价。

+ * + * @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("剩余可履约商品"); + .banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size())) + .keyValue("商户", merchant.getName()); + appendVoucherUser(image, user); 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 -> + long totalAmount = 0L; + for (HardOrderEntity voucherOrder : voucherOrders) { + image.divider().sectionTitle("订单 " + displayOrderNo(voucherOrder)) + .keyValue("预约日期", voucherOrder.getApptUseDate() == null ? "--" : voucherOrder.getApptUseDate().toString()) + .keyValue("餐段", resolveMealName(voucherOrder.getHourType())) + .sectionTitle("剩余可履约商品"); + List cars = Optional.ofNullable(carService.getByOrderId(voucherOrder.getId())).orElseGet(List::of); + Map metadata = loadProductMetadata(cars); + for (HardCarVo car : cars) { + int count = effectiveCount(car); + if (count <= 0) continue; + hasProduct = true; + // 历史购物车允许商品 ID 为空,Map.of() 不接受空键查询,因此空 ID 直接按单品降级。 + ProductMetadata product = car.getProductId() == null ? null : metadata.get(car.getProductId()); + boolean combo = product != null && product.combo(); + long unitPrice = voucherUnitPrice(car); + long subtotal = voucherSubtotal(car); + totalAmount += subtotal; + List componentItems = combo ? product.items().stream().filter(Objects::nonNull) + .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)); + .map(item -> StrUtil.blankToDefault(item.getName(), "未命名组成商品") + " x" + + (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * count) + .toList() : List.of(); + image.voucherProduct(car.getProductName(), count, money(unitPrice), money(subtotal), combo, + componentItems); } } 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) { @@ -686,6 +709,95 @@ public class HardInvoiceServiceImpl extends ServiceImpl 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; + } + /** * 批量加载套餐类型和组成项;远程失败时按普通单品降级。 */ @@ -822,6 +934,26 @@ public class HardInvoiceServiceImpl extends ServiceImpl 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..8b746dbca 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,74 @@ 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不能为空"); + + 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) 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("只能合并核销同一用户、同一商户、同一日期餐段的预约订单"); + } + } + /** * 校验订单至少还有一份退款成功后可履约的商品,防止旧终端绕过查询结果直接核销空订单。 * 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..ae11213aa 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("批量核销必须使用同一来源设备"); + } + + 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); + 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 (hardCarService.getByOrderId(order.getId()).stream() + .noneMatch(car -> resolveEffectiveProductCount(car) > 0)) { + 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 请求头来源设备 + * @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/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..e93fb88ed 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,46 @@ 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 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), + "组合商品的黑底标签和粗边框应产生更多深色像素"); + } + 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 +87,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/service/impl/HardInvoiceServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java index 0b4619d4a..475b703a0 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,9 @@ 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.vo.UserVO; 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 +16,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,6 +29,7 @@ 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; @@ -83,6 +88,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 +189,59 @@ 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("班级")); + } + 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); + } + /** * 根据手机号查询用户 */