feat:核销打印兑换券

This commit is contained in:
dzc committed 2026-09-04 17:33:22 +08:00
1 parent 991685ca48
commit 6818c5fa5a
17 files changed
+1088 -34

No files matched your search

@@ -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;
/**
* 预约菜单批量核销参数。
*
* <p>批次只接受服务端已经返回给终端的预约订单主键;设备、用户、商户、日期和餐段均由服务端
* 从订单快照校验,不能由客户端拼接覆盖。</p>
*/
@Data
public class ReservationMenuBatchCancelDto {
/**
* 本次一次性核销的预约订单主键,服务端会去重并按主键升序加锁。
*/
@NotEmpty(message = "预约订单不能为空")
@Size(max = 100, message = "一次最多核销100笔预约订单")
@Schema(description = "待批量核销预约订单ID", requiredMode = Schema.RequiredMode.REQUIRED)
private List<Long> orderIds;
/**
* 显式拒绝未声明字段,避免终端误传日期、餐段或设备参数形成越权选择条件。
*
* @param fieldName 未声明字段名称
* @param value 未声明字段值
*/
@JsonAnySetter
public void rejectUnknownField(String fieldName, Object value) {
throw new IllegalArgumentException("预约批量核销接口不支持参数:" + fieldName);
}
}
@@ -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);
}
/**
* 绘制兑换券商品卡片,使用文字标签和边框粗细同时区分组合商品与单品。
*
* <p>商品名称和组合内容均按真实像素宽度完整换行;价格区固定展示单价、数量和小计。
* 组合内容只解释套餐构成,不参与当前卡片金额计算。</p>
*
* @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<String> 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<String> 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<String> 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<List<String>> 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<String> 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;
}
/**
* 使用指定尾列宽度绘制一条可换行明细。
*
@@ -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<List<HardInvoiceVo>> 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<Boolean> 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<Page<VerificationRecordVo>> verificationRecordPage(@ParameterObject Page<?> page, @RequestHeader("DEVICE-ID") Long hardId, @Valid @RequestBody VerificationRecordQueryDto dto) {
@@ -61,6 +61,15 @@ public interface HardInvoiceService extends IService<HardInvoiceEntity> {
*/
HardInvoiceEntity createRedemptionVoucherTask(HardOrderEntity order, Long sourceHardId);
/**
* 为同一批已核销预约订单创建一个兑换券打印任务,订单内容在异步生成图片时按核销批次恢复。
*
* @param orders 已在同一事务中完成核销的订单,第一笔作为打印任务锚点
* @param sourceHardId 实际核销终端 ID
* @return 合并批次的唯一兑换券任务
*/
HardInvoiceEntity createRedemptionVoucherBatchTask(List<HardOrderEntity> orders, Long sourceHardId);
/**
* 管理端创建首张发票或补建兑换券任务。
*
@@ -312,6 +312,14 @@ public interface HardOrderService extends IService<HardOrderEntity> {
*/
Boolean handCancelCode(HandCancelCodeDto dto);
/**
* 在同一个本地事务中核销一组预约订单,并在全部成功后创建一个合并兑换券任务。
*
* @param dtos 订单、来源设备及可选终端订单号组成的批量核销参数
* @return 整批订单均完成核销时返回 {@code true}
*/
Boolean handCancelReservationCodes(List<HandCancelCodeDto> dtos);
/**
* 将误核销的线上预订订单恢复到待核销状态,并同步恢复对应预约二维码。
*
@@ -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<HardInvoiceVo> listRecords(Long sourceHardId, Long orderId);
/**
* 校验任务类型和来源终端后,为已经打印的兑换券创建下一打印序号。
*
* @param sourceHardId DEVICE-ID 请求头中的食堂或超市终端 ID
* @param historyId 已打印兑换券任务 ID
* @param reason 操作员填写的人工重打原因
* @return 新建或并发场景下复用的兑换券打印任务
*/
HardInvoiceEntity reprintVoucher(Long sourceHardId, Long historyId, String reason);
}
@@ -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);
}
@@ -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<HardInvoiceMapper, HardI
/** MQTT 消息远程服务。 */
private final RemoteMqttEventService mqttEventService;
/** 预约核销码远程服务。 */
private final RemoteCodeUseService remoteCodeUseService;
/** 食堂菜品和套餐组成远程服务。 */
private final RemoteCanteenSpuService remoteCanteenSpuService;
@@ -300,7 +296,18 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
@Override
@Transactional(rollbackFor = Exception.class)
public HardInvoiceEntity createRedemptionVoucherTask(HardOrderEntity order, Long sourceHardId) {
return createInitialTask(order, HardPrintTypeEnum.REDEMPTION_VOUCHER, sourceHardId, null);
return createRedemptionVoucherBatchTask(List.of(order), sourceHardId);
}
/** {@inheritDoc} */
@Override
@Transactional(rollbackFor = Exception.class)
public HardInvoiceEntity createRedemptionVoucherBatchTask(List<HardOrderEntity> 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<HardInvoiceMapper, HardI
}
/**
* 生成兑换券 PNG 并上传文件服务。
* 生成不含核销短码的兑换券 PNG 并上传文件服务。
*
* <p>身份资料查询失败时降级为不打印用户区,不能阻断已经完成核销的订单出票;商品金额按
* 部分退款后的剩余可履约数量重新计算,组合组成项只做说明而不重复计价。</p>
*
* @param order 已完成核销的预约订单
* @param printNo 当前订单打印序号
* @return 文件服务返回的兑换券 PNG 地址
*/
private String generateVoucher(HardOrderEntity order, Integer printNo) {
List<HardOrderEntity> voucherOrders = loadVoucherBatchOrders(order);
MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
R<WalletCodeUseEntity> codeResult = remoteCodeUseService.getByBizId(order.getId());
WalletCodeUseEntity code = codeResult == null ? null : codeResult.getData();
List<HardCarVo> cars = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of);
Map<Long, ProductMetadata> 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<HardCarVo> cars = Optional.ofNullable(carService.getByOrderId(voucherOrder.getId())).orElseGet(List::of);
Map<Long, ProductMetadata> 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<String> 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<HardInvoiceMapper, HardI
}
}
/**
* 按首订单核销快照恢复同一次批量核销的全部订单;历史没有独立核销时间的任务退化为单订单。
*
* @param anchor 首笔打印任务锚定订单
* @return 按订单主键稳定排序的批量订单
*/
private List<HardOrderEntity> loadVoucherBatchOrders(HardOrderEntity anchor) {
if (anchor.getVerificationTime() == null) return List.of(anchor);
var query = Wrappers.<HardOrderEntity>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<HardOrderEntity> 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<UserVO> 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<UserInfo> 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<String, String> fields = voucherUserFields(user);
if (fields.isEmpty()) return;
image.divider().sectionTitle("用户信息");
fields.forEach(image::keyValue);
}
/**
* 提取兑换券允许打印的非空身份字段,保持姓名、学号、班级的固定顺序。
*
* @param user UPMS 返回的用户及身份扩展;为空表示没有可打印身份信息
* @return 有序的非空身份字段;学生学号优先,学生学号为空时回退老师学号
*/
static Map<String, String> 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<String, String> 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<HardInvoiceMapper, HardI
- 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);
@@ -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<HardInvoiceVo> listRecords(Long sourceHardId, Long orderId) {
requireSourceTerminal(sourceHardId);
if (orderId == null) throw new RuntimeException("订单ID不能为空");
List<HardInvoiceEntity> records = hardInvoiceService.list(
Wrappers.<HardInvoiceEntity>lambdaQuery()
.eq(HardInvoiceEntity::getHardId, sourceHardId)
.eq(HardInvoiceEntity::getOrderId, orderId)
.orderByDesc(HardInvoiceEntity::getCreateTime)
.orderByDesc(HardInvoiceEntity::getId)
.last("limit " + RECORD_LIMIT));
Map<Long, String> 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<Long, String> loadHardNames(List<HardInvoiceEntity> records) {
Set<Long> 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<Long, String> 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<Long, String> 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;
}
}
@@ -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);
}
/**
* 校验同一识别用户在同一终端一次选中的预约订单,并交给订单服务执行原子批量核销。
*
* <p>客户端只提交订单 ID;这里再次从订单快照确认用户、商户、设备类型、预约日期和餐段完全一致,
* 防止人为拼接跨用户或跨商户订单后共用一张兑换券。</p>
*
* @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<Long> orderIds = new LinkedHashSet<>(dto.getOrderIds()).stream().toList();
if (orderIds.stream().anyMatch(Objects::isNull)) throw new RuntimeException("预约订单ID不能为空");
List<HardOrderEntity> 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<HandCancelCodeDto> 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("只能合并核销同一用户、同一商户、同一日期餐段的预约订单");
}
}
/**
* 校验订单至少还有一份退款成功后可履约的商品,防止旧终端绕过查询结果直接核销空订单。
*
@@ -3942,11 +3942,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
boolean isCancel = validateCodeIsCancel(orderType, order.getId());
if (isCancel) throw new I18nBizException(ErrorCodes.HARD_QR_ALREADY_VERIFIED);
boolean isReservationVerification = isReservationVerification(order);
boolean remoteCodeCancelled = false;
try {
// Wallet 是独立远程事务,成功返回后若 Hardware 本地事务失败,必须显式恢复预约二维码。
// Wallet 是独立远程事务;调用开始后若 Hardware 核销链路异常,必须幂等恢复预约二维码。
getSelf().cancelCode(order, userInfo);
remoteCodeCancelled = true;
LocalDateTime verificationTime = LocalDateTime.now();
HardOrderEntity updateObj = HardOrderEntity.builder()
.id(order.getId())
@@ -3966,13 +3964,175 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (isReservationVerification) createReservationVerificationArtifacts(order, verificationTime, dto.getHardwareId());
return true;
} catch (RuntimeException exception) {
if (isReservationVerification && remoteCodeCancelled) {
if (isReservationVerification) {
rollbackReservationCodeAfterLocalFailure(order, exception);
}
throw exception;
}
}
/**
* 原子批量核销超市、食堂预约订单。
*
* <p>所有订单先按主键顺序加行锁并完成完整校验,再逐笔消费 Wallet 预约码和更新订单状态;
* 任意远程或本地步骤失败都会补偿已经消费的预约码。事务成功后只创建一个以首笔订单为锚点的
* 合并兑换券任务,图片生成服务再按共享核销时间恢复整个批次。</p>
*
* @param dtos 预约订单、来源设备及可选终端订单号参数
* @return 全部订单核销并创建合并打印任务时返回 {@code true}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean handCancelReservationCodes(List<HandCancelCodeDto> dtos) {
if (dtos == null || dtos.isEmpty()) throw new RuntimeException("预约订单不能为空");
Map<Long, HandCancelCodeDto> 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<HardOrderEntity> canceledOrders = new ArrayList<>();
LocalDateTime verificationTime = LocalDateTime.now();
try {
List<HardOrderEntity> orders = new ArrayList<>();
for (Long orderId : requestByOrderId.keySet().stream().sorted().toList()) {
HardOrderEntity order = baseMapper.selectByIdForUpdate(orderId);
validateReservationBatchOrder(order, firstSnapshot, sourceHardId);
orders.add(order);
}
Map<Long, UserInfo> users = new HashMap<>();
for (HardOrderEntity order : orders) {
R<UserInfo> 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<HardOrderEntity>()
.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 记录,便于运维识别需要人工修复的订单。
@@ -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;
}
}
@@ -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<String, String> 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<String, String> 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();
}
@@ -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<HardInvoiceVo> 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();
}
}
@@ -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);
}
/**
* 验证已核销且仍有可履约商品的食堂预约会恢复订单、二维码和取餐广播。
*/
@@ -130,6 +130,16 @@ public interface RemoteUserService {
@GetMapping("/user/inner/get-by-user-id")
R<UserInfo> getInnerByUserId(@RequestParam("userId") Long userId);
/**
* 按用户 ID 查询内部业务展示所需的主资料和完整身份扩展。
*
* @param userId 用户主键
* @return 用户姓名、部门、身份类型、学生资料和老师资料;用户不存在时数据为空
*/
@NoToken
@GetMapping("/user/inner/vo-by-user-id")
R<UserVO> getInnerUserVoByUserId(@RequestParam("userId") Long userId);
@GetMapping("/user/get-by-user-id")
R<UserInfo> getByUserId(@RequestParam("userId") Long userId);
@@ -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<UserVO> 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);
}
/**
* 根据手机号查询用户
*/