Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-09-08 16:23:54 +08:00
commit 0ac2115a60
34 files changed
+2287 -118

No files matched your search

+6
View File
@@ -61,3 +61,9 @@ gen_code
### node ###
node_modules
### AI 辅助工具 ###
.video_agent
.mimosa
.v2c
.zcode
@@ -105,6 +105,16 @@ public interface RemoteCanteenSpuService {
@PostMapping("/canteenSpu/ad/getComboItemsBySpuIds")
R<Map<Long, List<OnlineSpuComboItemVO>>> getComboItemsBySpuIds(@RequestBody List<Long> spuIds);
/**
* 供 Hardware 异步打印任务等无用户上下文的服务间调用批量查询套餐组成。
*
* @param spuIds 套餐 SPU 主键列表,空列表返回空映射
* @return 套餐 SPU ID 与已按配置顺序排列的组成菜品映射
*/
@PostMapping("/canteenSpu/inner/getComboItemsBySpuIds")
@NoToken
R<Map<Long, List<OnlineSpuComboItemVO>>> getInnerComboItemsBySpuIds(@RequestBody List<Long> spuIds);
/**
* 购物车效验库存
* @param spuId
@@ -444,6 +444,18 @@ public class CanteenSpuController {
return R.ok(canteenSpuService.loadComboItemVos(spuIds));
}
/**
* 为无用户令牌的 Hardware 异步打印任务提供套餐组成查询,复用管理端相同的数据装配逻辑。
*
* @param spuIds 套餐 SPU 主键列表,空列表返回空映射
* @return 套餐 SPU ID 与组成菜品列表的映射
*/
@Inner
@PostMapping("/inner/getComboItemsBySpuIds")
public R<Map<Long, List<OnlineSpuComboItemVO>>> getInnerComboItemsBySpuIds(@RequestBody List<Long> spuIds) {
return R.ok(canteenSpuService.loadComboItemVos(spuIds));
}
@ApiCryptoAes
@GetMapping("/app/checkSpuStock")
@@ -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);
}
}
@@ -0,0 +1,39 @@
package com.bsd.pjcloud.hard.api.dto.hard;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* 终端交易记录查询参数。
*
* 查询范围固定为线下购买订单({@code HardOrderTypeEnum.BELOW}),与终端本地交易记录口径一致。
* 设备 ID 是唯一的数据范围入口:scope 为空或 DEVICE 时仅查询本机订单,
* scope 为 MERCHANT 时按服务端解析的终端所属商户查询全部设备订单,
* 客户端不能通过本接口指定其他商户或用户范围。
*/
@Data
public class TerminalOrderRecordQueryDto {
@NotNull(message = "设备ID不能为空")
@Schema(description = "当前终端设备ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long hardId;
@Schema(description = "开始日期,格式 yyyy-MM-dd")
private LocalDate startDate;
@Schema(description = "结束日期,格式 yyyy-MM-dd")
private LocalDate endDate;
@Schema(description = "数据范围:DEVICE 仅本机(默认),MERCHANT 当前终端所属商户全部设备")
private String scope;
@Schema(description = "订单状态编码集合,为空时查询全部状态")
private List<String> orderStatusCodes;
@Schema(description = "用餐时段编码,为空或 -1 时不区分")
private String hourType;
}
@@ -26,4 +26,7 @@ public class VerificationRecordQueryDto {
@Schema(description = "核销类型,目前仅支持 RESERVATION 预约核销;为空时同样按预约核销查询")
private String verificationType;
@Schema(description = "数据范围:DEVICE 仅本机(默认),MERCHANT 当前终端所属商户全部设备")
private String scope;
}
@@ -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";
@@ -69,26 +75,31 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage title(String text) {
setFont(Font.BOLD, 34);
setFont(Font.BOLD, 42);
drawCentered(text, 16);
return this;
}
/**
* 绘制带黑色边框的大号打印次数提示。
* 绘制带黑色边框的大号打印次数提示,文字超过可用纸宽时自动换行。
*
* @param text 提示文本,例如“第 1 次打印”
* @return 当前绘制器
*/
public ThermalReceiptImage banner(String text) {
setFont(Font.BOLD, 38);
setFont(Font.BOLD, 44);
FontMetrics metrics = graphics.getFontMetrics();
int height = metrics.getHeight() + 30;
List<String> lines = wrap(text == null ? "" : text, contentWidth() - 32, metrics);
int lineHeight = metrics.getHeight() + 4;
int height = lines.size() * lineHeight + 26;
ensureCapacity(height + 20);
graphics.setStroke(new BasicStroke(3F));
graphics.drawRect(HORIZONTAL_PADDING, cursorY, contentWidth(), height);
int x = HORIZONTAL_PADDING + Math.max(0, (contentWidth() - metrics.stringWidth(text)) / 2);
graphics.drawString(text, x, cursorY + 15 + metrics.getAscent());
for (int index = 0; index < lines.size(); index++) {
String line = lines.get(index);
int x = HORIZONTAL_PADDING + Math.max(0, (contentWidth() - metrics.stringWidth(line)) / 2);
graphics.drawString(line, x, cursorY + 13 + index * lineHeight + metrics.getAscent());
}
cursorY += height + 20;
return this;
}
@@ -100,34 +111,38 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage verificationCode(String shortCode) {
setFont(Font.BOLD, 52);
setFont(Font.BOLD, 64);
drawCentered(shortCode == null || shortCode.isBlank() ? "--" : shortCode, 18);
return this;
}
/**
* 绘制一行左右键值信息,值过长时在下一行继续换行。
* 绘制一行左右键值信息,值无法完整同行显示时整段从下一行开始并继续换行。
*
* @param label 左侧字段名
* @param value 右侧字段值
* @return 当前绘制器
*/
public ThermalReceiptImage keyValue(String label, String value) {
setFont(Font.PLAIN, 25);
setFont(Font.PLAIN, 33);
String safeLabel = label == null ? "" : label;
String safeValue = value == null || value.isBlank() ? "--" : value;
FontMetrics metrics = graphics.getFontMetrics();
int labelWidth = metrics.stringWidth(safeLabel + ":");
int valueWidth = contentWidth() - labelWidth - 12;
List<String> lines = wrap(safeValue, Math.max(120, valueWidth), metrics);
int inlineValueWidth = contentWidth() - labelWidth - 12;
boolean stackValue = metrics.stringWidth(safeValue) > inlineValueWidth;
int valueWidth = stackValue ? contentWidth() : Math.max(120, inlineValueWidth);
List<String> lines = wrap(safeValue, valueWidth, metrics);
int valueLineOffset = stackValue ? 1 : 0;
int lineHeight = metrics.getHeight() + 6;
ensureCapacity(lines.size() * lineHeight + 10);
ensureCapacity((lines.size() + valueLineOffset) * lineHeight + 10);
graphics.drawString(safeLabel + ":", HORIZONTAL_PADDING, cursorY + metrics.getAscent());
for (int index = 0; index < lines.size(); index++) {
int x = index == 0 ? HORIZONTAL_PADDING + labelWidth + 12 : HORIZONTAL_PADDING + labelWidth + 12;
graphics.drawString(lines.get(index), x, cursorY + index * lineHeight + metrics.getAscent());
int x = stackValue ? HORIZONTAL_PADDING : HORIZONTAL_PADDING + labelWidth + 12;
graphics.drawString(lines.get(index), x,
cursorY + (index + valueLineOffset) * lineHeight + metrics.getAscent());
}
cursorY += lines.size() * lineHeight + 6;
cursorY += (lines.size() + valueLineOffset) * lineHeight + 6;
return this;
}
@@ -138,13 +153,86 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage sectionTitle(String text) {
setFont(Font.BOLD, 28);
ensureCapacity(48);
setFont(Font.BOLD, 36);
ensureCapacity(60);
graphics.drawString(text == null ? "" : text, HORIZONTAL_PADDING, cursorY + graphics.getFontMetrics().getAscent());
cursorY += graphics.getFontMetrics().getHeight() + 10;
return this;
}
/**
* 绘制多订单合并兑换券中的紧凑订单摘要。
*
* <p>订单序号使用黑底标签形成扫描锚点,完整订单号及日期餐段均按实际像素宽度换行;相邻订单仅以
* 细线分隔,避免重复商品版式中的大段标题和留白。</p>
*
* @param index 当前订单在合并批次中的一号起始序号
* @param total 合并批次订单总数
* @param orderNo 对外展示的完整订单号
* @param orderDate 预约订餐日期
* @param mealName 餐段名称
* @return 当前绘制器
*/
public ThermalReceiptImage voucherOrderSummary(int index, int total, String orderNo,
String orderDate, String mealName) {
int safeTotal = Math.max(1, total);
int safeIndex = Math.min(Math.max(1, index), safeTotal);
String tag = "订单 " + safeIndex + "/" + safeTotal;
String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo;
String summary = "订餐日期:" + (orderDate == null || orderDate.isBlank() ? "--" : orderDate)
+ " 餐段:" + (mealName == null || mealName.isBlank() ? "--" : mealName);
setFont(Font.BOLD, 25);
FontMetrics tagMetrics = graphics.getFontMetrics();
int tagWidth = tagMetrics.stringWidth(tag) + 20;
int tagHeight = tagMetrics.getHeight() + 6;
setFont(Font.BOLD, 31);
FontMetrics orderMetrics = graphics.getFontMetrics();
int orderX = HORIZONTAL_PADDING + tagWidth + 14;
int orderWidth = PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - orderX;
List<String> orderLines = wrap(safeOrderNo, Math.max(120, orderWidth), orderMetrics);
int orderLineHeight = orderMetrics.getHeight() + 4;
int headerHeight = Math.max(tagHeight, orderLines.size() * orderLineHeight);
setFont(Font.PLAIN, 29);
FontMetrics summaryMetrics = graphics.getFontMetrics();
List<String> summaryLines = wrap(summary, contentWidth(), summaryMetrics);
int summaryLineHeight = summaryMetrics.getHeight() + 4;
int separatorHeight = safeIndex < safeTotal ? 18 : 8;
int blockHeight = headerHeight + 10 + summaryLines.size() * summaryLineHeight + separatorHeight;
ensureCapacity(blockHeight);
int blockTop = cursorY;
graphics.setColor(Color.BLACK);
setFont(Font.BOLD, 25);
graphics.fillRect(HORIZONTAL_PADDING, blockTop, tagWidth, tagHeight);
graphics.setColor(Color.WHITE);
graphics.drawString(tag, HORIZONTAL_PADDING + 10, blockTop + 3 + tagMetrics.getAscent());
graphics.setColor(Color.BLACK);
setFont(Font.BOLD, 31);
for (int lineIndex = 0; lineIndex < orderLines.size(); lineIndex++) {
graphics.drawString(orderLines.get(lineIndex), orderX,
blockTop + lineIndex * orderLineHeight + orderMetrics.getAscent());
}
int summaryTop = blockTop + headerHeight + 10;
setFont(Font.PLAIN, 29);
for (int lineIndex = 0; lineIndex < summaryLines.size(); lineIndex++) {
graphics.drawString(summaryLines.get(lineIndex), HORIZONTAL_PADDING,
summaryTop + lineIndex * summaryLineHeight + summaryMetrics.getAscent());
}
if (safeIndex < safeTotal) {
int separatorY = summaryTop + summaryLines.size() * summaryLineHeight + 7;
graphics.setStroke(new BasicStroke(1F));
graphics.drawLine(HORIZONTAL_PADDING, separatorY,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, separatorY);
}
cursorY += blockHeight;
return this;
}
/**
* 绘制商品或套餐组成项,商品名换行且数量始终在首行右对齐。
*
@@ -170,6 +258,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, 28);
FontMetrics tagMetrics = graphics.getFontMetrics();
int tagHeight = tagMetrics.getHeight() + 6;
int tagWidth = tagMetrics.stringWidth(typeLabel) + 20;
setFont(Font.BOLD, 40);
FontMetrics nameMetrics = graphics.getFontMetrics();
List<String> nameLines = wrap(safeName, innerWidth, nameMetrics);
int nameLineHeight = nameMetrics.getHeight() + 4;
setFont(Font.PLAIN, 29);
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, 29);
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, 28);
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, 40);
for (String line : nameLines) {
graphics.drawString(line, contentX, contentY + nameMetrics.getAscent());
contentY += nameLineHeight;
}
contentY += 8;
setFont(Font.PLAIN, 29);
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, 29);
graphics.drawString("组合内容", contentX, contentY + componentMetrics.getAscent());
contentY += componentLineHeight;
setFont(Font.PLAIN, 29);
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, 42);
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;
}
/**
* 使用指定尾列宽度绘制一条可换行明细。
*
@@ -182,7 +414,7 @@ public class ThermalReceiptImage {
*/
private ThermalReceiptImage item(String name, String trailing, boolean child, boolean emphasized,
int trailingWidth) {
setFont(emphasized ? Font.BOLD : Font.PLAIN, child ? 23 : 26);
setFont(emphasized ? Font.BOLD : Font.PLAIN, child ? 30 : 34);
FontMetrics metrics = graphics.getFontMetrics();
int indent = child ? CHILD_INDENT : 0;
String prefix = child ? "- " : "";
@@ -0,0 +1,61 @@
package com.bsd.pjcloud.hard.api.vo.android;
import lombok.Data;
import java.time.LocalDateTime;
/** 终端交易记录展示对象,仅返回交易记录列表所需字段。 */
@Data
public class TerminalOrderRecordVo {
/** 订单主键。 */
private Long orderId;
/** 业务订单号。 */
private String orderNo;
/** 订单类型稳定编码。 */
private String orderType;
/** 订单类型展示名称。 */
private String orderTypeName;
/** 订单状态稳定编码。 */
private String orderStatus;
/** 订单状态展示名称。 */
private String orderStatusName;
/** 支付方式稳定编码。 */
private String payType;
/** 支付方式展示名称。 */
private String payTypeName;
/** 下单用户姓名。 */
private String username;
/** 下单用户所属部门。 */
private String userDeptName;
/** 订单商品摘要。 */
private String productDesc;
/** 用餐时段编码。 */
private String hourType;
/** 商品总金额(分)。 */
private Integer totalPrice;
/** 额外支付(分)。 */
private Integer extraPrice;
/** 实际支付(分)。 */
private Integer payPrice;
/** 完成交易的设备名称;本机数据范围下与服务端解析的终端一致。 */
private String hardName;
/** 下单时间。 */
private LocalDateTime createTime;
}
@@ -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;
@@ -27,6 +28,7 @@ import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo;
import com.bsd.pjcloud.hard.api.vo.android.ReservationMenuQueryVo;
import com.bsd.pjcloud.hard.api.vo.android.TerminalOrderRecordVo;
import com.bsd.pjcloud.hard.api.vo.android.VerificationRecordVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
@@ -66,7 +68,9 @@ public class HardApiController {
private final HardOrderTotalService hardOrderTotalService;
private final ReservationMenuQueryService reservationMenuQueryService;
private final VerificationRecordService verificationRecordService;
private final TerminalOrderRecordService terminalOrderRecordService;
private final HardTerminalFaceService hardTerminalFaceService;
private final HardTerminalPrintService hardTerminalPrintService;
/**
* 食堂、超市 Android 终端心跳状态服务。
@@ -397,6 +401,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,12 +466,33 @@ 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("DEVICE-ID") 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) {
return R.ok(verificationRecordService.page(page, hardId, dto));
}
@PostMapping("/order-record/page")
@Operation(summary = "分页查询当前终端交易记录")
public R<Page<TerminalOrderRecordVo>> orderRecordPage(@ParameterObject Page<?> page, @RequestHeader("DEVICE-ID") Long hardId, @Valid @RequestBody TerminalOrderRecordQueryDto dto) {
return R.ok(terminalOrderRecordService.page(page, hardId, dto));
}
@GetMapping("/chart-latitude")
@Operation(summary = "统计每天、每周、每月、每年的交易人数和次数")
public R<HardChartLatitudeVo> chartLatitude(@RequestParam Long merchantId) {
@@ -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);
}
@@ -0,0 +1,19 @@
package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.hard.api.dto.hard.TerminalOrderRecordQueryDto;
import com.bsd.pjcloud.hard.api.vo.android.TerminalOrderRecordVo;
/** 终端交易记录查询服务。 */
public interface TerminalOrderRecordService {
/**
* 分页查询终端交易记录。
*
* @param page 分页参数,页码最小为 1、每页最大为 50
* @param hardId 当前请求头中的设备主键
* @param dto 数据范围、日期、状态和时段查询条件
* @return 已按下单时间倒序排列的终端交易记录分页
*/
Page<TerminalOrderRecordVo> page(Page<?> page, Long hardId, TerminalOrderRecordQueryDto dto);
}
@@ -12,8 +12,10 @@ import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.admin.api.feign.RemoteParamService;
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 +55,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;
@@ -76,6 +76,7 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
@@ -95,64 +96,115 @@ import java.util.Optional;
public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardInvoiceEntity>
implements HardInvoiceService {
/** 核销回退后再次核销时自动记录的重打原因。 */
/**
* 核销回退后再次核销时自动记录的重打原因。
*/
private static final String REVERIFY_REASON = "核销回退后再次核销";
/** MQTT 待打印任务允许补偿重投的最小间隔,单位秒。 */
/**
* MQTT 待打印任务允许补偿重投的最小间隔,单位秒。
*/
private static final long REDISPATCH_INTERVAL_SECONDS = 30L;
/** 单轮跨租户扫描任务上限。 */
/**
* 单轮跨租户扫描任务上限。
*/
private static final int SCAN_LIMIT = 100;
/** 错误摘要数据库最大长度。 */
/**
* 错误摘要数据库最大长度。
*/
private static final int ERROR_MAX_LENGTH = 2000;
/** 设备管理服务。 */
/**
* 是否显示兑换券套餐组合内容的租户公共参数键;缺失或异常时必须默认关闭。
*/
private static final String VOUCHER_SHOW_COMBO_ITEMS_PARAM_KEY = "HARD_VOUCHER_SHOW_COMBO_ITEMS";
/**
* 多订单兑换券是否合并商品明细的租户公共参数键;缺失或异常时保持原有按订单展示版式。
*/
private static final String VOUCHER_MERGE_MULTI_ORDER_PRODUCTS_PARAM_KEY =
"HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS";
/**
* 设备管理服务。
*/
private final HardManagerService hardManagerService;
/** 订单服务。 */
/**
* 订单服务。
*/
private final HardOrderService orderService;
/** 订单 Mapper,用于串行化同一订单的人工创建与重打序号分配。 */
/**
* 订单 Mapper,用于串行化同一订单的人工创建与重打序号分配。
*/
private final HardOrderMapper hardOrderMapper;
/** 订单商品服务。 */
/**
* 订单商品服务。
*/
private final HardCarService carService;
/** 打印机绑定服务。 */
/**
* 打印机绑定服务。
*/
private final HardPrinterService printerService;
/** 用户远程服务。 */
/**
* 用户远程服务。
*/
private final RemoteUserService userService;
/** 部门远程服务。 */
/**
* 部门远程服务。
*/
private final RemoteDeptService deptService;
/** 商户远程服务。 */
/**
* 商户远程服务。
*/
private final RemoteMchService remoteMchService;
/** 钱包远程服务。 */
/**
* 钱包远程服务。
*/
private final RemoteWalletService walletService;
/** 文件上传远程服务。 */
/**
* 文件上传远程服务。
*/
private final RemoteFileService fileService;
/** MQTT 消息远程服务。 */
/**
* MQTT 消息远程服务。
*/
private final RemoteMqttEventService mqttEventService;
/** 预约核销码远程服务。 */
private final RemoteCodeUseService remoteCodeUseService;
/** 食堂菜品和套餐组成远程服务。 */
/**
* 食堂菜品和套餐组成远程服务。
*/
private final RemoteCanteenSpuService remoteCanteenSpuService;
/** 硬件业务异步线程池。 */
/**
* 租户公共参数远程服务,用于读取兑换券版式开关。
*/
private final RemoteParamService remoteParamService;
/**
* 硬件业务异步线程池。
*/
private final ThreadPoolTaskExecutor hardTaskExecutor;
/** 打印记录用户身份筛选与展示资料装配器。 */
/**
* 打印记录用户身份筛选与展示资料装配器。
*/
private final HardUserIdentitySupport userIdentitySupport;
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
public Page<HardInvoiceVo> scopeJoinPageList(Page page, HardInvoiceDto dto) {
prepareIdentityQuery(dto);
@@ -194,13 +246,17 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
dto.setHasFace(null);
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
public String generateInvoice(HardOrderEntity order) {
return generateInvoice(order, null);
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
public String generateInvoice(HardOrderEntity order, String code) {
List<HardCarEntity> carList = carService.list(new LambdaQueryWrapper<HardCarEntity>()
@@ -247,7 +303,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
}
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public HardInvoiceVo generateInvoice(Long orderId) {
@@ -260,7 +318,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return result;
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
public Boolean printInvoice(Long orderId, Long historyId) {
HardInvoiceEntity detail = baseMapper.selectById(historyId);
@@ -277,7 +337,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return baseMapper.updateById(update) > 0;
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean print(Long orderId) {
@@ -288,7 +350,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return true;
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean printByOrder(HardOrderEntity order) {
@@ -296,14 +360,31 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return true;
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@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} */
/**
* {@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}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public HardInvoiceEntity createPrintTask(HardPrintRequestDto request) {
@@ -327,7 +408,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
request.getReprintReason());
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public HardInvoiceEntity retryTask(Long historyId, Long sourceHardId) {
@@ -374,7 +457,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return result;
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public HardInvoiceEntity reprintTask(Long historyId, String reason) {
@@ -398,7 +483,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return createNewTask(order, printType, history.getHardId(), reason.trim());
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
public int cancelPendingVoucherTasks(Long orderId) {
return baseMapper.update(null, Wrappers.<HardInvoiceEntity>lambdaUpdate()
@@ -411,7 +498,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
.set(HardInvoiceEntity::getLastError, "预约核销已回退,任务取消"));
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
public List<HardInvoiceEntity> listPendingTasks(Long sourceHardId) {
HardPrinterBindingEntity binding = requireSourceBinding(sourceHardId);
@@ -424,7 +513,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
.last("limit 100"));
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean reportPrintResult(Long sourceHardId, HardPrintResultDto result) {
@@ -462,7 +553,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return baseMapper.update(null, update) > 0;
}
/** {@inheritDoc} */
/**
* {@inheritDoc}
*/
@Override
public int processDueTasks() {
LocalDateTime redispatchBefore = LocalDateTime.now().minusSeconds(REDISPATCH_INTERVAL_SECONDS);
@@ -581,8 +674,10 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
private void processTask(Long taskId) {
HardInvoiceEntity task = baseMapper.selectById(taskId);
if (task == null || task.getPrinterHardId() == null) return;
if (Objects.equals(task.getStatus(), HardPrintStatusEnum.PENDING_GENERATION.getType())) task = generateTaskImage(task);
if (task != null && Objects.equals(task.getStatus(), HardPrintStatusEnum.GENERATE.getType())) dispatchTask(task);
if (Objects.equals(task.getStatus(), HardPrintStatusEnum.PENDING_GENERATION.getType()))
task = generateTaskImage(task);
if (task != null && Objects.equals(task.getStatus(), HardPrintStatusEnum.GENERATE.getType()))
dispatchTask(task);
}
/**
@@ -647,38 +742,34 @@ 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("剩余可履约商品");
boolean hasProduct = false;
for (HardCarVo car : cars) {
int count = effectiveCount(car);
if (count <= 0) continue;
hasProduct = true;
ProductMetadata product = metadata.get(car.getProductId());
boolean combo = product != null && product.combo();
image.item(car.getProductName(), "x" + count, false, combo);
if (combo) {
product.items().stream().sorted(Comparator.comparing(item ->
ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE)))
.forEach(item -> image.item(item.getName(),
"x" + ObjectUtil.defaultIfNull(item.getQuantity(), 1) * count, true, false));
}
}
.banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size()))
.keyValue("商户", merchant.getName());
appendVoucherUser(image, user);
boolean showComboItems = shouldShowVoucherComboItems();
List<VoucherOrderProducts> orderProducts = loadVoucherOrderProducts(voucherOrders, showComboItems);
boolean mergeMultiOrderProducts = voucherOrders.size() > 1 && shouldMergeMultiOrderVoucherProducts();
if (mergeMultiOrderProducts) appendMergedVoucherLayout(image, orderProducts);
else appendOrderGroupedVoucherLayout(image, orderProducts);
boolean hasProduct = orderProducts.stream().anyMatch(item -> !item.products().isEmpty());
long totalAmount = orderProducts.stream().flatMap(item -> item.products().stream())
.mapToLong(VoucherProductLine::subtotal).sum();
if (!hasProduct) image.item("无可履约商品", "x0", false, false);
image.divider().keyValue("打印时间", formatDateTime(LocalDateTime.now()));
image.voucherTotal(money(totalAmount)).keyValue("打印时间", formatDateTime(LocalDateTime.now()));
try {
return uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes());
} catch (IOException exception) {
@@ -687,21 +778,294 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
}
/**
* 批量加载套餐类型和组成项;远程失败时按普通单品降级。
* 查询当前租户是否显示兑换券套餐的组合内容。
*
* <p>公共参数不存在、返回失败或远程服务异常时均按关闭处理,避免配置服务波动阻断已经完成核销的
* 订单出票。仅参数值 {@code true} 或 {@code 1}(忽略前后空格和 true 的大小写)代表开启。</p>
*
* @return {@code true} 显示组合内容;{@code false} 隐藏横线、标题和组成商品列表
*/
private Map<Long, ProductMetadata> loadProductMetadata(List<HardCarVo> cars) {
private boolean shouldShowVoucherComboItems() {
return isPublicParamEnabled(VOUCHER_SHOW_COMBO_ITEMS_PARAM_KEY, "兑换券套餐组合内容显示");
}
/**
* 查询当前租户是否对多订单兑换券合并商品明细。
*
* <p>该开关只由多订单生成流程调用;单订单始终保持现有版式。关闭或读取失败时,每笔订单继续展示
* 自己的商品列表,确保上线后不改变未配置租户的打印结果。</p>
*
* @return {@code true} 集中展示订单摘要并合并商品;{@code false} 按订单分别展示商品
*/
private boolean shouldMergeMultiOrderVoucherProducts() {
return isPublicParamEnabled(VOUCHER_MERGE_MULTI_ORDER_PRODUCTS_PARAM_KEY, "多订单兑换券商品合并");
}
/**
* 读取布尔型租户公共参数,统一兑换券版式开关的容错语义。
*
* <p>仅参数值 {@code true} 或 {@code 1}(忽略前后空格和 true 的大小写)代表开启;参数不存在、
* 返回失败或远程服务异常均按关闭处理,不能因配置服务波动阻断已完成核销订单出票。</p>
*
* @param paramKey 公共参数键
* @param settingName 日志中使用的开关名称
* @return 公共参数是否明确开启
*/
private boolean isPublicParamEnabled(String paramKey, String settingName) {
try {
R<String> result = remoteParamService.getByKey(paramKey);
if (result == null || !result.isOk()) {
log.warn("读取{}开关失败,按关闭处理,paramKey:{}", settingName, paramKey);
return false;
}
String value = StrUtil.trim(result.getData());
return "true".equalsIgnoreCase(value) || "1".equals(value);
} catch (Exception exception) {
log.warn("读取{}开关异常,按关闭处理,paramKey:{}", settingName, paramKey, exception);
return false;
}
}
/**
* 加载批量兑换券的订单商品快照及套餐元数据,供两种票据版式复用。
*
* @param voucherOrders 同一次核销的有序订单
* @param showComboItems 是否加载并显示套餐组成内容
* @return 与订单顺序一致的商品快照分组
*/
private List<VoucherOrderProducts> loadVoucherOrderProducts(List<HardOrderEntity> voucherOrders,
boolean showComboItems) {
List<VoucherOrderProducts> result = new ArrayList<>();
for (HardOrderEntity voucherOrder : voucherOrders) {
List<HardCarVo> cars = Optional.ofNullable(carService.getByOrderId(voucherOrder.getId()))
.orElseGet(List::of);
Map<Long, ProductMetadata> metadata = loadProductMetadata(cars, showComboItems);
List<VoucherProductLine> products = new ArrayList<>();
for (HardCarVo car : cars) {
int count = effectiveCount(car);
if (count <= 0) continue;
// 历史购物车允许商品 ID 为空,Map.of() 不接受空键查询,因此空 ID 直接按单品降级。
ProductMetadata product = car.getProductId() == null ? null : metadata.get(car.getProductId());
boolean combo = product != null && product.combo();
products.add(new VoucherProductLine(car.getProductId(), car.getProductName(), count,
voucherUnitPrice(car), voucherSubtotal(car), combo,
combo && showComboItems ? product.items() : List.of()));
}
result.add(new VoucherOrderProducts(voucherOrder, List.copyOf(products)));
}
return List.copyOf(result);
}
/**
* 绘制原有按订单分组的兑换券版式。
*
* @param image 当前兑换券图片绘制器
* @param orderProducts 按订单分组的商品快照
*/
private static void appendOrderGroupedVoucherLayout(ThermalReceiptImage image,
List<VoucherOrderProducts> orderProducts) {
for (VoucherOrderProducts group : orderProducts) {
HardOrderEntity order = group.order();
image.divider().sectionTitle("订单 " + displayOrderNo(order))
.keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
.keyValue("餐段", resolveMealName(order.getHourType()))
.sectionTitle("剩余可履约商品");
appendVoucherProducts(image, group.products());
}
}
/**
* 绘制多订单商品合并版式,先集中展示订单识别信息,再展示一份聚合商品明细。
*
* @param image 当前兑换券图片绘制器
* @param orderProducts 按订单分组的商品快照
*/
private static void appendMergedVoucherLayout(ThermalReceiptImage image,
List<VoucherOrderProducts> orderProducts) {
image.divider().sectionTitle("订单信息(" + orderProducts.size() + "笔)");
for (int index = 0; index < orderProducts.size(); index++) {
HardOrderEntity order = orderProducts.get(index).order();
image.voucherOrderSummary(index + 1, orderProducts.size(), displayOrderNo(order),
order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString(),
resolveMealName(order.getHourType()));
}
image.divider().sectionTitle("合并商品明细");
appendVoucherProducts(image, mergeVoucherProducts(orderProducts));
}
/**
* 绘制一组结构化兑换券商品,套餐组成数量随商品合计数量同步计算。
*
* @param image 当前兑换券图片绘制器
* @param products 待绘制商品;可以是单订单明细或多订单合并结果
*/
private static void appendVoucherProducts(ThermalReceiptImage image, List<VoucherProductLine> products) {
for (VoucherProductLine product : products) {
image.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()),
money(product.subtotal()), product.combo(), voucherComponentItems(product));
}
}
/**
* 将套餐组成快照转换为票据说明,并按套餐商品的剩余总份数放大组成数量。
*
* @param product 当前兑换券商品
* @return 已包含总数量且按组成排序稳定排列的说明文本
*/
private static List<String> voucherComponentItems(VoucherProductLine product) {
if (!product.combo() || product.comboItems().isEmpty()) return List.of();
return product.comboItems().stream().filter(Objects::nonNull)
.sorted(Comparator.comparing(item ->
ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE)))
.map(item -> StrUtil.blankToDefault(item.getName(), "未命名组成商品") + " x"
+ (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * product.quantity())
.toList();
}
/**
* 合并多订单中业务快照完全一致的商品,并保持商品第一次出现的顺序。
*
* <p>商品 ID、快照名称、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并;
* 历史商品 ID 为空时,自然按名称、单价和套餐类型合并。数量与小计均使用各原始行求和。</p>
*
* @param orderProducts 按订单分组的商品快照
* @return 可直接绘制的有序合并商品列表
*/
static List<VoucherProductLine> mergeVoucherProducts(List<VoucherOrderProducts> orderProducts) {
Map<VoucherProductKey, VoucherProductLine> merged = new LinkedHashMap<>();
for (VoucherOrderProducts group : orderProducts) {
for (VoucherProductLine product : group.products()) {
VoucherProductKey key = new VoucherProductKey(product.productId(), product.productName(),
product.unitPrice(), product.combo());
VoucherProductLine current = merged.get(key);
if (current == null) {
merged.put(key, product);
continue;
}
List<OnlineSpuComboItemVO> comboItems = current.comboItems().isEmpty()
? product.comboItems() : current.comboItems();
merged.put(key, new VoucherProductLine(current.productId(), current.productName(),
current.quantity() + product.quantity(), current.unitPrice(),
current.subtotal() + product.subtotal(), current.combo(), comboItems));
}
}
return List.copyOf(merged.values());
}
/**
* 按首订单核销快照恢复同一次批量核销的全部订单;历史没有独立核销时间的任务退化为单订单。
*
* @param anchor 首笔打印任务锚定订单
* @return 按订单主键稳定排序的批量订单
*/
private List<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;
}
/**
* 批量加载套餐类型,并按兑换券显示开关决定是否继续查询组成项。
*
* <p>异步任务没有用户令牌,必须通过内部接口查询。关闭组成内容时不发起明细请求,既减少远程调用,
* 也避免组成接口波动影响“组合商品”标签的识别;商品类型接口失败时才按普通单品降级。</p>
*
* @param cars 订单中的剩余可履约商品快照
* @param includeComboItems 是否加载套餐组成商品明细
* @return 按商品 ID 索引的套餐类型和组成项;类型查询失败时返回空 Map
*/
private Map<Long, ProductMetadata> loadProductMetadata(List<HardCarVo> cars, boolean includeComboItems) {
List<Long> productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList();
if (productIds.isEmpty()) return Map.of();
try {
R<List<CanteenSpuEntity>> result = remoteCanteenSpuService.getSpuListByIds(productIds);
R<List<CanteenSpuEntity>> result = remoteCanteenSpuService.getInnerSpuListByIds(productIds);
if (result == null || !result.isOk() || result.getData() == null) return Map.of();
List<Long> comboIds = result.getData().stream()
.filter(item -> item != null && Objects.equals(item.getSpuType(), 2))
.map(CanteenSpuEntity::getId).toList();
Map<Long, List<OnlineSpuComboItemVO>> comboItems = Map.of();
if (!comboIds.isEmpty()) {
R<Map<Long, List<OnlineSpuComboItemVO>>> comboResult = remoteCanteenSpuService.getComboItemsBySpuIds(comboIds);
if (comboResult != null && comboResult.isOk() && comboResult.getData() != null) comboItems = comboResult.getData();
if (includeComboItems && !comboIds.isEmpty()) {
R<Map<Long, List<OnlineSpuComboItemVO>>> comboResult =
remoteCanteenSpuService.getInnerComboItemsBySpuIds(comboIds);
if (comboResult != null && comboResult.isOk() && comboResult.getData() != null)
comboItems = comboResult.getData();
}
Map<Long, ProductMetadata> metadata = new LinkedHashMap<>();
for (CanteenSpuEntity spu : result.getData()) {
@@ -776,7 +1140,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return binding;
}
/** 将异常写入指定任务,条件版本防止旧处理覆盖人工新重试。 */
/**
* 将异常写入指定任务,条件版本防止旧处理覆盖人工新重试。
*/
private void markFailure(Long taskId, Integer retryVersion, HardPrintFailureStageEnum stage, Exception exception) {
HardInvoiceEntity failure = new HardInvoiceEntity();
failure.setStatus(HardPrintStatusEnum.FAILED.getType());
@@ -790,45 +1156,79 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
log.error("打印任务处理失败,taskId:{},stage:{}", taskId, stage.getStage(), exception);
}
/** 查询订单指定类型的最新任务。 */
/**
* 查询订单指定类型的最新任务。
*/
private HardInvoiceEntity findLatestTask(Long orderId, HardPrintTypeEnum printType) {
return baseMapper.selectOne(Wrappers.<HardInvoiceEntity>lambdaQuery()
.eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType())
.orderByDesc(HardInvoiceEntity::getPrintNo).orderByDesc(HardInvoiceEntity::getId).last("limit 1"));
}
/** 按打印序号查询并发创建结果。 */
/**
* 按打印序号查询并发创建结果。
*/
private HardInvoiceEntity findTaskByPrintNo(Long orderId, HardPrintTypeEnum printType, Integer printNo) {
return baseMapper.selectOne(Wrappers.<HardInvoiceEntity>lambdaQuery()
.eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType())
.eq(HardInvoiceEntity::getPrintNo, printNo).last("limit 1"));
}
/** 判断任务是否仍应复用而不能创建新打印序号。 */
/**
* 判断任务是否仍应复用而不能创建新打印序号。
*/
private static boolean isOpenStatus(String status) {
return List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), HardPrintStatusEnum.GENERATE.getType(),
HardPrintStatusEnum.FAILED.getType()).contains(status);
}
/** 兼容历史空打印类型。 */
/**
* 兼容历史空打印类型。
*/
private static HardPrintTypeEnum normalizePrintType(String printType) {
return Optional.ofNullable(HardPrintTypeEnum.valueOfByType(printType)).orElse(HardPrintTypeEnum.INVOICE);
}
/** 计算部分退款后的剩余可履约数量。 */
/**
* 计算部分退款后的剩余可履约数量。
*/
private static int effectiveCount(HardCarVo car) {
if (car.getEffectiveProductCount() != null) return Math.max(0, car.getEffectiveProductCount());
return Math.max(0, ObjectUtil.defaultIfNull(car.getProductCount(), 0)
- ObjectUtil.defaultIfNull(car.getRefundedProductCount(), 0));
}
/** 解析订单餐段显示名。 */
/**
* 读取兑换券商品的订单快照单价。
*
* @param car 订单商品快照
* @return 商品单价,单位分;历史空值按零处理
*/
static long voucherUnitPrice(HardCarVo car) {
return ObjectUtil.defaultIfNull(car.getProductPrice(), 0).longValue();
}
/**
* 按剩余可履约数量计算兑换券商品小计,避免把已完成退款商品计入金额。
*
* @param car 已补充退款统计的订单商品快照
* @return 剩余商品小计,单位分
*/
static long voucherSubtotal(HardCarVo car) {
return voucherUnitPrice(car) * effectiveCount(car);
}
/**
* 解析订单餐段显示名。
*/
private static String resolveMealName(String hourType) {
HardHourTypeEnum type = HardHourTypeEnum.valueOfByType(hourType);
return type == null ? StrUtil.blankToDefault(hourType, "--") : type.getDescription();
}
/** 上传 PNG 并校验文件服务返回地址。 */
/**
* 上传 PNG 并校验文件服务返回地址。
*/
private String uploadPng(String filePrefix, byte[] bytes) {
String fileName = filePrefix + "_" + System.currentTimeMillis() + ".png";
MultipartFile multipartFile = new MockMultipartFile("file", fileName, "image/png", bytes);
@@ -841,7 +1241,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return (String) url;
}
/** 解析钱包类型中文名称,远程数据缺失时返回空字符串。 */
/**
* 解析钱包类型中文名称,远程数据缺失时返回空字符串。
*/
private String resolveWalletTypeName(Long walletId) {
if (walletId == null) return "";
try {
@@ -856,17 +1258,23 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
}
}
/** 返回订单对外展示编号,业务编号为空时使用主键。 */
/**
* 返回订单对外展示编号,业务编号为空时使用主键。
*/
private static String displayOrderNo(HardOrderEntity order) {
return StrUtil.blankToDefault(order.getOrderNo(), String.valueOf(order.getId()));
}
/** 格式化本地日期时间。 */
/**
* 格式化本地日期时间。
*/
private static String formatDateTime(LocalDateTime value) {
return value == null ? "--" : LocalDateTimeUtil.format(value, DatePattern.NORM_DATETIME_PATTERN);
}
/** 计算发票显示数量或重量。 */
/**
* 计算发票显示数量或重量。
*/
private static String productQuantity(HardCarEntity car) {
if (car.getProductCount() != null && car.getProductCount() != 0) return car.getProductCount().toString();
if (car.getProductWeight() != null && car.getProductWeight().compareTo(BigDecimal.ZERO) != 0) {
@@ -875,24 +1283,67 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return "0";
}
/** 将分转换为两位小数金额文本。 */
/**
* 将分转换为两位小数金额文本。
*/
private static String money(Long cents) {
if (cents == null) return "0.00";
return BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toPlainString();
}
/** 校验远程响应并返回数据。 */
/**
* 校验远程响应并返回数据。
*/
private static <T> T requireData(R<T> result, String message) {
if (result == null || !result.isOk() || result.getData() == null) throw new RuntimeException(message);
return result.getData();
}
/** 截断错误摘要以适配数据库字段。 */
/**
* 截断错误摘要以适配数据库字段。
*/
private static String truncateError(String error) {
return StrUtil.sub(StrUtil.blankToDefault(error, "未知错误"), 0, ERROR_MAX_LENGTH);
}
/** 菜品是否为套餐及其组成项。 */
/**
* 菜品是否为套餐及其组成项。
*/
private record ProductMetadata(boolean combo, List<OnlineSpuComboItemVO> items) {
}
/**
* 一笔订单及其剩余可履约商品快照。
*
* @param order 订单识别信息和预约信息
* @param products 已扣除退款成功数量的商品行
*/
record VoucherOrderProducts(HardOrderEntity order, List<VoucherProductLine> products) {
}
/**
* 可直接绘制或参与多订单聚合的兑换券商品行。
*
* @param productId 商品主键;历史快照允许为空
* @param productName 下单时商品名称快照
* @param quantity 剩余可履约数量
* @param unitPrice 下单时单价快照,单位分
* @param subtotal 当前商品行小计,单位分
* @param combo 是否为组合商品
* @param comboItems 套餐组成快照;显示开关关闭时为空
*/
record VoucherProductLine(Long productId, String productName, int quantity, long unitPrice, long subtotal,
boolean combo, List<OnlineSpuComboItemVO> comboItems) {
}
/**
* 多订单商品合并键。
*
* @param productId 商品主键;为空时依靠其余快照字段区分
* @param productName 商品名称快照
* @param unitPrice 商品单价快照,单位分
* @param combo 是否为组合商品
*/
private record VoucherProductKey(Long productId, String productName, long unitPrice, boolean combo) {
}
}
@@ -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,79 @@ 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不能为空");
if (orderIds.size() > 100) throw new RuntimeException("一次最多核销100笔预约订单");
if (hardId == null) throw new RuntimeException("DEVICE-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) {
validateBatchDevice(order, hardId);
}
if (first == null) return;
if (!Objects.equals(first.getUserId(), order.getUserId())
|| !Objects.equals(first.getMerchantId(), order.getMerchantId())
|| !Objects.equals(first.getApptUseDate(), order.getApptUseDate())
|| !Objects.equals(first.getHourType(), order.getHourType())
|| !Objects.equals(first.getSysType(), order.getSysType())) {
throw new RuntimeException("只能合并核销同一用户、同一商户、同一日期餐段的预约订单");
}
}
/**
* 校验订单至少还有一份退款成功后可履约的商品,防止旧终端绕过查询结果直接核销空订单。
*
@@ -150,6 +225,22 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
return hard.getId();
}
/**
* 批量核销专用的来源设备白名单校验;单笔旧接口保留原有商户归属兼容语义。
*
* @param order 批次订单
* @param hardId 请求头来源设备 ID
*/
private void validateBatchDevice(HardOrderEntity order, Long hardId) {
if (hardId == null) return;
HardManagerEntity hard = hardManagerService.getById(hardId);
HardTypeEnum hardType = hard == null ? null : HardTypeEnum.valueOfByType(hard.getType());
if (hard == null || (hardType != HardTypeEnum.SUPERMARKET && hardType != HardTypeEnum.CANTEEN)
|| !Objects.equals(hard.getMerchantId(), order.getMerchantId())) {
throw new RuntimeException("当前设备不能核销该预约订单");
}
}
/**
* 判断预约订单退款聚合状态是否允许继续履约,历史空值按未售后兼容,未知状态按不可核销处理。
*
@@ -0,0 +1,128 @@
package com.bsd.pjcloud.hard.service.impl;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.hard.TerminalOrderRecordQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.vo.android.TerminalOrderRecordVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.TerminalOrderRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/** 终端交易记录查询服务,复用订单管理端分页查询保证范围、分页和用户信息口径一致。 */
@Service
@RequiredArgsConstructor
public class TerminalOrderRecordServiceImpl implements TerminalOrderRecordService {
/** 全部数据范围:按终端所属商户查询商户下全部设备的交易记录。 */
private static final String SCOPE_MERCHANT = "MERCHANT";
/** 终端不区分用餐时段的编码。 */
private static final String HOUR_TYPE_ALL = "-1";
/** 设备服务,用于校验请求设备存在并解析所属商户。 */
private final HardManagerService hardManagerService;
/** 订单服务,用于按设备/商户、订单状态和下单时间分页查询。 */
private final HardOrderService hardOrderService;
@Override
public Page<TerminalOrderRecordVo> page(Page<?> page, Long hardId, TerminalOrderRecordQueryDto dto) {
if (!Objects.equals(hardId, dto.getHardId())) {
throw new RuntimeException("请求设备与查询设备不一致");
}
HardManagerEntity hard = hardManagerService.getById(hardId);
if (hard == null) {
throw new RuntimeException("设备不存在或无权访问");
}
LocalDate startDate = ObjectUtil.defaultIfNull(dto.getStartDate(), LocalDate.now());
LocalDate endDate = ObjectUtil.defaultIfNull(dto.getEndDate(), startDate);
if (endDate.isBefore(startDate)) {
throw new RuntimeException("结束日期不能早于开始日期");
}
HardOrderDto query = new HardOrderDto();
if (SCOPE_MERCHANT.equalsIgnoreCase(StrUtil.trimToEmpty(dto.getScope()))) {
// 全部数据:范围来自服务端解析的终端所属商户,客户端不能自行指定商户。
query.setMerchantId(hard.getMerchantId());
} else {
query.setHardId(hard.getId());
}
// 终端交易记录只承载本机产生的线下购买订单,与本地数据库口径一致,排除预约、外卖等其他类型。
query.setOrderType(HardOrderTypeEnum.BELOW.getType());
query.setCreateTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.atTime(23, 59, 59)});
query.setOrderStatusCodes(dto.getOrderStatusCodes());
if (StrUtil.isNotBlank(dto.getHourType()) && !HOUR_TYPE_ALL.equals(dto.getHourType().trim())) {
query.setHourType(dto.getHourType().trim());
}
long current = Math.max(1, page.getCurrent());
long size = Math.min(50, Math.max(1, page.getSize()));
// 订单范围、分页、用户和部门信息统一由订单服务处理,交易记录只负责转换展示字段。
Page<HardOrderVo> source = hardOrderService.scopePageList(new Page<>(current, size), query, null);
List<TerminalOrderRecordVo> records = source.getRecords().stream()
.map(TerminalOrderRecordServiceImpl::convert)
.toList();
Page<TerminalOrderRecordVo> result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal());
result.setRecords(records);
return result;
}
/**
* 将订单查询结果转换为终端交易记录展示对象。
*
* @param order 订单分页查询结果
* @return 终端交易记录
*/
private static TerminalOrderRecordVo convert(HardOrderVo order) {
TerminalOrderRecordVo vo = new TerminalOrderRecordVo();
vo.setOrderId(order.getId());
vo.setOrderNo(StrUtil.blankToDefault(order.getOrderNo(), String.valueOf(order.getId())));
vo.setOrderType(order.getOrderType());
HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType());
vo.setOrderTypeName(orderType == null ? "其他类型" : orderType.getDescription());
vo.setOrderStatus(order.getOrderStatus());
// 状态枚举查找对未知编码抛异常,外卖/商城等终端外状态需安全降级。
vo.setOrderStatusName(resolveStatusName(order.getOrderStatus()));
vo.setPayType(order.getPayType());
HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType());
vo.setPayTypeName(payType == null ? "其他方式" : payType.getDescription());
vo.setUsername(StrUtil.blankToDefault(order.getUsername(), "未知用户"));
vo.setUserDeptName(order.getUserDeptName());
vo.setProductDesc(StrUtil.blankToDefault(order.getProductDesc(), "未知商品"));
vo.setHourType(order.getHourType());
vo.setTotalPrice(ObjectUtil.defaultIfNull(order.getTotalPrice(), 0));
vo.setExtraPrice(ObjectUtil.defaultIfNull(order.getExtraPrice(), 0));
vo.setPayPrice(ObjectUtil.defaultIfNull(order.getPayPrice(), 0));
vo.setHardName(order.getHardName());
vo.setCreateTime(order.getCreateTime());
return vo;
}
/** 状态枚举 {@code valueOfByType} 对未知编码会抛异常,这里安全降级为通用名称。 */
private static String resolveStatusName(String statusCode) {
if (StrUtil.isBlank(statusCode)) {
return "未知状态";
}
for (HardOrderStatusEnum status : HardOrderStatusEnum.values()) {
if (Objects.equals(statusCode, status.getType())) {
return status.getDescription();
}
}
return "其他状态";
}
}
@@ -30,6 +30,9 @@ public class VerificationRecordServiceImpl implements VerificationRecordService
/** 当前接口支持的预约核销类型协议值。 */
private static final String TYPE_RESERVATION = "RESERVATION";
/** 全部数据范围:按终端所属商户查询商户下全部设备的核销记录。 */
private static final String SCOPE_MERCHANT = "MERCHANT";
/** 设备服务,用于校验请求设备存在且与查询设备一致。 */
private final HardManagerService hardManagerService;
@@ -64,7 +67,12 @@ public class VerificationRecordServiceImpl implements VerificationRecordService
}
HardOrderDto query = new HardOrderDto();
query.setHardId(hard.getId());
if (SCOPE_MERCHANT.equalsIgnoreCase(StrUtil.trimToEmpty(dto.getScope()))) {
// 全部数据:范围来自服务端解析的终端所属商户,客户端不能自行指定商户。
query.setMerchantId(hard.getMerchantId());
} else {
query.setHardId(hard.getId());
}
query.setOrderType(HardOrderTypeEnum.RESERVE.getType());
query.setApptIdOrApptRuleIdNotNull(true);
query.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType()));
@@ -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,203 @@ 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("批量核销必须使用同一来源设备");
}
if (sourceHardId == null) throw new RuntimeException("批量核销来源设备不能为空");
Long firstOrderId = requestByOrderId.keySet().stream().min(Long::compareTo).orElseThrow();
HardOrderEntity firstSnapshot = baseMapper.selectById(firstOrderId);
if (firstSnapshot == null) throw new I18nBizException(ErrorCodes.COMMON_ORDER_NOT_FOUND);
validateReservationVerificationSource(firstSnapshot, sourceHardId);
LockInfo verificationLock = acquireReservationVerificationLock(firstSnapshot, sourceHardId);
boolean releaseByTransaction = false;
List<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 (!hasReservationFulfillableProduct(order.getId())) {
throw new I18nBizException(ErrorCodes.HARD_RESERVE_FULL_REFUND_NO_VERIFY);
}
if (sourceHardId != null && !Objects.equals(sourceHardId, order.getHardId())
&& order.getHardId() != null && order.getOrderStatus() != null) {
// 待核销预约通常尚未写入设备;若历史数据已有来源设备,仍禁止跨设备批量合并。
throw new RuntimeException("预约订单已有其他核销设备归属");
}
if (!Objects.equals(firstSnapshot.getUserId(), order.getUserId())
|| !Objects.equals(firstSnapshot.getMerchantId(), order.getMerchantId())
|| !Objects.equals(firstSnapshot.getApptUseDate(), order.getApptUseDate())
|| !Objects.equals(firstSnapshot.getHourType(), order.getHourType())
|| !Objects.equals(firstSnapshot.getSysType(), order.getSysType())) {
throw new RuntimeException("只能合并核销同一用户、同一商户、同一日期餐段的预约订单");
}
}
/**
* 对订单服务内部调用再次校验来源设备类型和商户,避免绕过预约菜单服务时使用其他硬件类型核销。
*
* @param order 批次首订单快照
* @param sourceHardId 来源业务终端 ID;为空表示历史无设备来源调用
*/
private void validateReservationVerificationSource(HardOrderEntity order, Long sourceHardId) {
if (sourceHardId == null) return;
HardManagerEntity source = hardManagerService.getById(sourceHardId);
HardTypeEnum sourceType = source == null ? null : HardTypeEnum.valueOfByType(source.getType());
if (source == null || (sourceType != HardTypeEnum.SUPERMARKET && sourceType != HardTypeEnum.CANTEEN)
|| !Objects.equals(source.getMerchantId(), order.getMerchantId())) {
throw new RuntimeException("当前设备不能核销该预约订单");
}
}
/**
* 判断预约订单是否仍有可履约商品,兼容历史商品查询返回空值的情况。
*
* @param orderId 待核销预约订单主键
* @return 至少有一份剩余商品时返回 {@code true}
*/
private boolean hasReservationFulfillableProduct(Long orderId) {
List<HardCarVo> cars = hardCarService.getByOrderId(orderId);
return cars != null && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0);
}
/**
* 按用户、商户、日期、餐段和来源设备串行化预约核销批次,避免共享核销时间误合并并发批次。
*
* @param order 批次首订单
* @param sourceHardId 请求头来源设备
* @return 已持有的分布式锁
*/
private LockInfo acquireReservationVerificationLock(HardOrderEntity order, Long sourceHardId) {
String lockKey = String.format("hard:reserve:verification:%s:%s:%s:%s:%s",
sourceHardId, order.getUserId(), order.getMerchantId(), order.getApptUseDate(), order.getHourType());
LockInfo lock = lockTemplate.lock(lockKey, RESERVATION_SLOT_LOCK_EXPIRE_MILLIS,
RESERVATION_SLOT_LOCK_WAIT_MILLIS);
if (lock == null) throw new I18nBizException(ErrorCodes.HARD_APPT_PROCESSING);
return lock;
}
/** 将批量核销锁绑定到事务完成时释放。 */
private void registerReservationVerificationLockRelease(LockInfo lock) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new I18nBizException(ErrorCodes.HARD_RESERVE_TX_BAD);
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
releaseReservationVerificationLock(lock);
}
});
}
/** 释放批量核销分布式锁并记录异常,不覆盖原始业务异常。 */
private void releaseReservationVerificationLock(LockInfo lock) {
try {
if (lock != null && !lockTemplate.releaseLock(lock)) {
log.warn("|{}|预约批量核销锁释放失败,lockKey:|{}|", TAG, lock.getLockKey());
}
} catch (Exception exception) {
log.error("|{}|预约批量核销锁释放异常", TAG, exception);
}
}
/**
* Hardware 本地核销事务失败后补偿已经提交的 Wallet 预约码状态,使订单仍可重新核销。补偿失败时
* 保留原始业务异常,并把恢复异常作为 suppressed exception 记录,便于运维识别需要人工修复的订单。
@@ -439,13 +439,13 @@
order by a.verification_time desc, a.id desc
</when>
<when test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
order by a.appt_use_date desc, a.hour_type asc, a.id desc
order by a.appt_use_date desc, a.pay_time desc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
<otherwise>
order by a.create_time desc, a.user_id desc
order by a.appt_use_date desc, a.pay_time desc, a.id desc
</otherwise>
</choose>
</select>
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -37,12 +38,136 @@ class ThermalReceiptImageTest {
BufferedImage invoice = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.title("发票").invoiceItem("独立单品", "2", "¥12.00").toPngBytes()));
BufferedImage voucher = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.title("兑换券").banner("第 1 次打印").verificationCode("123456").toPngBytes()));
.title("兑换券").banner("第 1 次打印")
.voucherProduct("独立单品", 2, "6.00", "12.00", false, List.of()).toPngBytes()));
assertEquals(576, invoice.getWidth());
assertEquals(576, voucher.getWidth());
}
/** 放大后的打印提示必须按纸宽自动换行,不能裁掉多订单数量等关键信息。 */
@Test
void largeBannerWrapsLongTextAndGrowsCanvas() throws Exception {
BufferedImage shortBanner = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.banner("第 1 次打印").toPngBytes()));
BufferedImage longBanner = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.banner("第 12 次打印 · 100 笔预约订单合并打印").toPngBytes()));
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, longBanner.getWidth());
assertTrue(longBanner.getHeight() > shortBanner.getHeight(),
"长提示换行后必须增加票据高度,不能越过右侧纸张边界");
}
/** 兑换券商品卡片应完整换行长名称和套餐组成,并随内容动态增高。 */
@Test
void voucherProductCardsWrapNamesAndComboComponents() throws Exception {
String longProductName = "超长组合商品名称".repeat(18);
String longComponentName = "组合内容中的超长菜品名称".repeat(10) + " x12";
ThermalReceiptImage receipt = new ThermalReceiptImage()
.title("预约核销兑换券")
.voucherProduct(longProductName, 2, "12.00", "24.00", true,
List.of(longComponentName, "红糖发糕 x4"))
.voucherProduct("独立单品", 3, "2.50", "7.50", false, List.of())
.voucherTotal("31.50");
BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
assertTrue(image.getHeight() > 512, "长商品名和套餐组成应触发画布动态扩容");
assertTrue(hasDarkPixel(image, 20, 36, 0, image.getHeight()), "商品卡片左边框应清晰可见");
assertTrue(hasDarkPixel(image, 430, image.getWidth(), 0, image.getHeight()),
"单价、小计和合计金额应保留右侧数字区域");
}
/** 组合商品的黑底标签和粗边框应比同内容单品形成更强的黑白视觉层级。 */
@Test
void comboProductCardHasStrongerVisualWeightThanSingleProductCard() throws Exception {
BufferedImage combo = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.voucherProduct("测试商品", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
BufferedImage single = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.voucherProduct("测试商品", 1, "10.00", "10.00", false, List.of()).toPngBytes()));
assertTrue(countDarkPixels(combo) > countDarkPixels(single),
"组合商品的黑底标签和粗边框应产生更多深色像素");
}
/** 套餐组成开关关闭时应删除分隔线、标题和明细占用的整段高度,但保留组合商品卡片。 */
@Test
void comboProductCardOmitsEntireComponentSectionWhenItemsAreHidden() throws Exception {
BufferedImage hidden = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.voucherProduct("测试套餐", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
BufferedImage visible = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.voucherProduct("测试套餐", 1, "10.00", "10.00", true,
List.of("糖醋排骨 x1", "红糖发糕 x1")).toPngBytes()));
assertTrue(hidden.getHeight() < visible.getHeight(),
"隐藏套餐组成时不应保留分隔线、组合内容标题或明细列表的空白高度");
}
/** 多订单合并版式应完整绘制每笔订单摘要和一份合并商品区。 */
@Test
void mergedVoucherLayoutDrawsCompactOrderSummariesAndProducts() throws Exception {
ThermalReceiptImage receipt = new ThermalReceiptImage()
.title("预约核销兑换券")
.sectionTitle("订单信息(3笔)")
.voucherOrderSummary(1, 3, "2096780000000000001", "2026-09-07", "午餐")
.voucherOrderSummary(2, 3, "2096780000000000002", "2026-09-07", "午餐")
.voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
.divider()
.sectionTitle("合并商品明细")
.voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
.voucherTotal("36.00");
BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
assertTrue(image.getHeight() > 500, "三笔订单摘要和合并商品区应全部保留在动态画布中");
assertTrue(hasDarkPixel(image, 24, 150, 80, image.getHeight()),
"订单序号黑底标签应形成清晰的左侧扫描锚点");
}
/** 超长订单号必须换行并推动后续内容,不能与日期餐段相互遮挡。 */
@Test
void voucherOrderSummaryWrapsLongOrderNumberAndGrowsCanvas() throws Exception {
BufferedImage shortOrder = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.voucherOrderSummary(1, 2, "2096780000000000001", "2026-09-07", "午餐")
.toPngBytes()));
BufferedImage longOrder = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.voucherOrderSummary(1, 2, "2096780000000000001".repeat(5), "2026-09-07", "午餐")
.toPngBytes()));
assertTrue(longOrder.getHeight() > shortOrder.getHeight(),
"超长订单号换行后必须增加票据高度,不能覆盖日期餐段");
}
/** 集中订单摘要并只绘制一次商品列表应比逐订单重复商品区更节省票据长度。 */
@Test
void mergedMultiOrderLayoutIsShorterThanRepeatedProductSections() throws Exception {
ThermalReceiptImage grouped = new ThermalReceiptImage().title("预约核销兑换券");
for (int index = 1; index <= 3; index++) {
grouped.divider().sectionTitle("订单 209678000000000000" + index)
.keyValue("预约日期", "2026-09-07")
.keyValue("餐段", "午餐")
.sectionTitle("剩余可履约商品")
.voucherProduct("A套餐-糖醋排骨", 1, "12.00", "12.00", true, List.of())
.voucherProduct("红糖发糕", 1, "3.00", "3.00", false, List.of());
}
ThermalReceiptImage merged = new ThermalReceiptImage().title("预约核销兑换券")
.divider().sectionTitle("订单信息(3笔)")
.voucherOrderSummary(1, 3, "2096780000000000001", "2026-09-07", "午餐")
.voucherOrderSummary(2, 3, "2096780000000000002", "2026-09-07", "午餐")
.voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
.divider().sectionTitle("合并商品明细")
.voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
.voucherProduct("红糖发糕", 3, "3.00", "9.00", false, List.of());
BufferedImage groupedImage = ImageIO.read(new ByteArrayInputStream(grouped.toPngBytes()));
BufferedImage mergedImage = ImageIO.read(new ByteArrayInputStream(merged.toPngBytes()));
assertTrue(mergedImage.getHeight() < groupedImage.getHeight(),
"合并版式不应重复绘制每笔订单的整套商品卡片");
}
private static boolean hasDarkPixel(BufferedImage image, int fromX, int toX, int fromY, int toY) {
for (int y = fromY; y < toY; y++) {
for (int x = fromX; x < toX; x++) {
@@ -52,4 +177,20 @@ class ThermalReceiptImageTest {
}
return false;
}
/**
* 统计图片中的深色像素,用于比较不同商品类型的黑白视觉权重。
*
* @param image 待统计的热敏票据图片
* @return RGB 值低于深灰阈值的像素数量
*/
private static long countDarkPixels(BufferedImage image) {
long count = 0L;
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
if ((image.getRGB(x, y) & 0xFFFFFF) < 0x606060) count++;
}
}
return count;
}
}
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.hard.service.HardOrderTotalService;
import com.bsd.pjcloud.hard.service.HardPrinterService;
import com.bsd.pjcloud.hard.service.HardTerminalStatusService;
import com.bsd.pjcloud.hard.service.HardTerminalFaceService;
import com.bsd.pjcloud.hard.service.HardTerminalPrintService;
import com.bsd.pjcloud.hard.service.ReservationMenuQueryService;
import com.bsd.pjcloud.hard.service.VerificationRecordService;
import org.junit.jupiter.api.Test;
@@ -69,11 +70,14 @@ class HardApiControllerTest {
/** 终端人脸删除服务,本组购物车测试不调用。 */
private final HardTerminalFaceService hardTerminalFaceService = mock(HardTerminalFaceService.class);
/** 终端打印凭证服务,本组购物车测试不调用。 */
private final HardTerminalPrintService hardTerminalPrintService = mock(HardTerminalPrintService.class);
/** 被测 Android 硬件接口控制器。 */
private final HardApiController controller = new HardApiController(hardManagerService, hardInvoiceService,
hardPrinterService, hardCarService, hardOrderService, hardOrderSyncService, hardApiService, hardOrderTotalService,
reservationMenuQueryService, verificationRecordService, hardTerminalFaceService,
hardTerminalStatusService);
hardTerminalPrintService, hardTerminalStatusService);
/**
* 验证食堂预约部分退款后数量和行金额按剩余份数投影,已全部退款商品不会返回消费机。
@@ -4,6 +4,14 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.feign.RemoteParamService;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.HardPrintStatusEnum;
import com.bsd.pjcloud.hard.api.constant.HardPrintTypeEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
@@ -13,6 +21,7 @@ import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.mapper.HardInvoiceMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardManagerService;
@@ -25,10 +34,12 @@ import org.mockito.ArgumentCaptor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
@@ -49,6 +60,13 @@ class HardInvoiceServiceImplTest {
private final HardOrderService orderService = mock(HardOrderService.class);
private final HardManagerService managerService = mock(HardManagerService.class);
private final HardPrinterService printerService = mock(HardPrinterService.class);
/** 无用户上下文的食堂商品内部查询客户端。 */
private final RemoteCanteenSpuService remoteCanteenSpuService = mock(RemoteCanteenSpuService.class);
/** 兑换券套餐组合内容显示开关的租户公共参数客户端。 */
private final RemoteParamService remoteParamService = mock(RemoteParamService.class);
private final ThreadPoolTaskExecutor taskExecutor = mock(ThreadPoolTaskExecutor.class);
private final HardInvoiceServiceImpl service = mock(HardInvoiceServiceImpl.class, CALLS_REAL_METHODS);
@@ -62,6 +80,8 @@ class HardInvoiceServiceImplTest {
ReflectionTestUtils.setField(service, "orderService", orderService);
ReflectionTestUtils.setField(service, "hardManagerService", managerService);
ReflectionTestUtils.setField(service, "printerService", printerService);
ReflectionTestUtils.setField(service, "remoteCanteenSpuService", remoteCanteenSpuService);
ReflectionTestUtils.setField(service, "remoteParamService", remoteParamService);
ReflectionTestUtils.setField(service, "hardTaskExecutor", taskExecutor);
}
@@ -83,6 +103,20 @@ class HardInvoiceServiceImplTest {
verify(invoiceMapper, never()).insert(any(HardInvoiceEntity.class));
}
/** App 预约订单没有下单设备部门时,打印任务必须使用商户部门快照。 */
@Test
void redemptionVoucherUsesMerchantDeptForOnlineReservation() {
HardOrderEntity order = order();
order.setDeptId(null);
order.setMerchantDeptId(60L);
when(invoiceMapper.insert(any(HardInvoiceEntity.class))).thenReturn(1);
HardInvoiceEntity result = service.createRedemptionVoucherTask(order, 10L);
assertEquals(60L, result.getDeptId());
verify(invoiceMapper).insert(result);
}
/** 两个重试请求读到同一失败版本时,未抢到更新的请求不得再次唤醒调度。 */
@Test
void retryTaskSchedulesOnlyWhenConditionalUpdateWins() {
@@ -170,6 +204,230 @@ class HardInvoiceServiceImplTest {
verify(invoiceMapper, never()).update(isNull(), any(Wrapper.class));
}
/** 部分退款后的兑换券小计必须只计算剩余可履约数量。 */
@Test
void voucherSubtotalUsesEffectiveCountAfterCompletedRefund() {
HardCarVo car = new HardCarVo();
car.setProductPrice(1250);
car.setProductCount(4);
car.setRefundedProductCount(1);
car.setEffectiveProductCount(3);
car.setTotalPrice(5000L);
assertEquals(3750L, HardInvoiceServiceImpl.voucherSubtotal(car));
}
/** 用户身份字段只返回非空值,学生学号优先且缺失时回退老师学号。 */
@Test
void voucherUserFieldsSkipBlankValuesAndFallbackToTeacherNumber() {
UserVO user = new UserVO();
user.setName("张三");
SysUserStudentDto student = new SysUserStudentDto();
student.setStudentNo(" ");
student.setClassName(null);
user.setStudentInfo(student);
SysUserTeacherDto teacher = new SysUserTeacherDto();
teacher.setTeacherNo("T-2026001");
user.setTeacherInfo(teacher);
Map<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("班级"));
}
/** 兑换券异步生成必须使用无用户令牌的内部接口识别套餐及其组成,避免统一降级为单品。 */
@Test
void voucherMetadataUsesInnerSpuAndComboQueries() {
HardCarVo car = new HardCarVo();
car.setProductId(501L);
CanteenSpuEntity combo = new CanteenSpuEntity();
combo.setId(501L);
combo.setSpuType(2);
OnlineSpuComboItemVO item = new OnlineSpuComboItemVO();
item.setName("糖醋排骨");
item.setQuantity(1);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(501L))).thenReturn(R.ok(List.of(combo)));
when(remoteCanteenSpuService.getInnerComboItemsBySpuIds(List.of(501L)))
.thenReturn(R.ok(Map.of(501L, List.of(item))));
@SuppressWarnings("unchecked")
Map<Long, Object> metadata = ReflectionTestUtils.invokeMethod(service, "loadProductMetadata", List.of(car), true);
Object product = metadata.get(501L);
assertNotNull(product);
assertTrue((Boolean) ReflectionTestUtils.invokeMethod(product, "combo"));
assertEquals(List.of(item), ReflectionTestUtils.invokeMethod(product, "items"));
verify(remoteCanteenSpuService).getInnerSpuListByIds(List.of(501L));
verify(remoteCanteenSpuService).getInnerComboItemsBySpuIds(List.of(501L));
verify(remoteCanteenSpuService, never()).getSpuListByIds(any());
verify(remoteCanteenSpuService, never()).getComboItemsBySpuIds(any());
}
/** 商品内部查询暂时不可用时应保持原有降级策略,避免已完成核销因打印资料失败而回滚。 */
@Test
void voucherMetadataFallsBackToSingleProductWhenInnerQueryFails() {
HardCarVo car = new HardCarVo();
car.setProductId(501L);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(501L)))
.thenThrow(new RuntimeException("canteen service unavailable"));
@SuppressWarnings("unchecked")
Map<Long, Object> metadata = ReflectionTestUtils.invokeMethod(service, "loadProductMetadata", List.of(car), true);
assertTrue(metadata.isEmpty());
verify(remoteCanteenSpuService, never()).getInnerComboItemsBySpuIds(any());
}
/** 关闭组成内容时仍须识别组合商品,但不应请求或携带组成明细。 */
@Test
void hiddenVoucherComboItemsKeepComboTypeWithoutLoadingDetails() {
HardCarVo car = new HardCarVo();
car.setProductId(501L);
CanteenSpuEntity combo = new CanteenSpuEntity();
combo.setId(501L);
combo.setSpuType(2);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(501L))).thenReturn(R.ok(List.of(combo)));
@SuppressWarnings("unchecked")
Map<Long, Object> metadata = ReflectionTestUtils.invokeMethod(service, "loadProductMetadata", List.of(car), false);
Object product = metadata.get(501L);
assertNotNull(product);
assertTrue((Boolean) ReflectionTestUtils.invokeMethod(product, "combo"));
assertEquals(List.of(), ReflectionTestUtils.invokeMethod(product, "items"));
verify(remoteCanteenSpuService, never()).getInnerComboItemsBySpuIds(any());
}
/** 公共参数尚未配置时,套餐组合内容开关必须默认关闭。 */
@Test
void voucherComboItemsDefaultToHiddenWhenParamIsMissing() {
when(remoteParamService.getByKey("HARD_VOUCHER_SHOW_COMBO_ITEMS")).thenReturn(R.ok((String) null));
Boolean visible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
assertFalse(Boolean.TRUE.equals(visible));
}
/** 公共参数显式设为 true 或 1 时,兑换券允许显示套餐组合内容。 */
@Test
void voucherComboItemsCanBeEnabledByPublicParam() {
when(remoteParamService.getByKey("HARD_VOUCHER_SHOW_COMBO_ITEMS"))
.thenReturn(R.ok(" true "), R.ok("1"));
Boolean textValueVisible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
Boolean numericValueVisible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
assertTrue(Boolean.TRUE.equals(textValueVisible));
assertTrue(Boolean.TRUE.equals(numericValueVisible));
}
/** 公共参数服务异常时必须回退为关闭,不能阻断已核销订单出票。 */
@Test
void voucherComboItemsStayHiddenWhenParamServiceFails() {
when(remoteParamService.getByKey("HARD_VOUCHER_SHOW_COMBO_ITEMS"))
.thenThrow(new RuntimeException("upms unavailable"));
Boolean visible = ReflectionTestUtils.invokeMethod(service, "shouldShowVoucherComboItems");
assertFalse(Boolean.TRUE.equals(visible));
}
/** 多订单商品合并参数尚未配置时必须保留原有按订单展示版式。 */
@Test
void multiOrderVoucherProductsDefaultToOrderGroupedLayout() {
when(remoteParamService.getByKey("HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS"))
.thenReturn(R.ok((String) null));
Boolean merged = ReflectionTestUtils.invokeMethod(service, "shouldMergeMultiOrderVoucherProducts");
assertFalse(Boolean.TRUE.equals(merged));
}
/** 多订单商品合并参数显式设为 true 或 1 时均应启用合并版式。 */
@Test
void multiOrderVoucherProductsCanBeMergedByPublicParam() {
when(remoteParamService.getByKey("HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS"))
.thenReturn(R.ok(" TRUE "), R.ok("1"));
Boolean textValueMerged = ReflectionTestUtils.invokeMethod(service,
"shouldMergeMultiOrderVoucherProducts");
Boolean numericValueMerged = ReflectionTestUtils.invokeMethod(service,
"shouldMergeMultiOrderVoucherProducts");
assertTrue(Boolean.TRUE.equals(textValueMerged));
assertTrue(Boolean.TRUE.equals(numericValueMerged));
}
/** 多订单版式参数服务异常时必须回退原版,不能阻断已经完成核销的订单出票。 */
@Test
void multiOrderVoucherProductsStayGroupedWhenParamServiceFails() {
when(remoteParamService.getByKey("HARD_VOUCHER_MERGE_MULTI_ORDER_PRODUCTS"))
.thenThrow(new RuntimeException("upms unavailable"));
Boolean merged = ReflectionTestUtils.invokeMethod(service, "shouldMergeMultiOrderVoucherProducts");
assertFalse(Boolean.TRUE.equals(merged));
}
/** 相同快照商品应合并数量和小计,不同价格商品保持独立且顺序按首次出现稳定排列。 */
@Test
void mergeVoucherProductsUsesSnapshotIdentityAndKeepsFirstAppearanceOrder() {
OnlineSpuComboItemVO component = new OnlineSpuComboItemVO();
component.setName("糖醋排骨");
component.setQuantity(1);
HardInvoiceServiceImpl.VoucherProductLine firstCombo =
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1200L, 1200L,
true, List.of(component));
HardInvoiceServiceImpl.VoucherProductLine single =
new HardInvoiceServiceImpl.VoucherProductLine(601L, "红糖发糕", 1, 300L, 300L,
false, List.of());
HardInvoiceServiceImpl.VoucherProductLine repeatedCombo =
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 2, 1200L, 2400L,
true, List.of(component));
HardInvoiceServiceImpl.VoucherProductLine changedPrice =
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1300L, 1300L,
true, List.of(component));
HardOrderEntity firstOrder = order();
HardOrderEntity secondOrder = order();
secondOrder.setId(2L);
List<HardInvoiceServiceImpl.VoucherOrderProducts> orders = List.of(
new HardInvoiceServiceImpl.VoucherOrderProducts(firstOrder, List.of(firstCombo, single)),
new HardInvoiceServiceImpl.VoucherOrderProducts(secondOrder, List.of(repeatedCombo, changedPrice)));
List<HardInvoiceServiceImpl.VoucherProductLine> merged =
HardInvoiceServiceImpl.mergeVoucherProducts(orders);
assertEquals(List.of("A套餐", "红糖发糕", "A套餐"),
merged.stream().map(HardInvoiceServiceImpl.VoucherProductLine::productName).toList());
assertEquals(3, merged.get(0).quantity());
assertEquals(3600L, merged.get(0).subtotal());
assertSame(component, merged.get(0).comboItems().get(0));
assertEquals(1200L, merged.get(0).unitPrice());
assertEquals(1300L, merged.get(2).unitPrice());
}
private static HardOrderEntity order() {
return HardOrderEntity.builder().id(1L).hardId(10L).merchantId(30L).deptId(40L).tenantId(50L).build();
}
@@ -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);
}
/**
* 根据手机号查询用户
*/
@@ -0,0 +1,37 @@
package com.bsd.pjcloud.wallet.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 钱包流水信息修改参数,仅允许后台修正流水触发事件的归属部门和创建时间,
* 不承载金额、余额、业务关联等不可变流水信息。
*/
@Data
@Schema(description = "钱包流水信息修改参数")
public class WalletTransactionUpdateInfoDto {
/**
* 需要修改信息的钱包流水编号,由钱包流水列表返回。
*/
@NotNull(message = "钱包流水编号不能为空")
@Schema(description = "钱包流水编号", requiredMode = Schema.RequiredMode.REQUIRED)
private Long id;
/**
* 流水触发事件归属的新部门编号;必须是当前租户有效部门,用于更正部门归属统计口径。
*/
@NotNull(message = "流水归属部门不能为空")
@Schema(description = "流水归属部门编号", requiredMode = Schema.RequiredMode.REQUIRED)
private Long deptId;
/**
* 流水新的创建时间,用于更正流水归属的统计周期;时间格式为 yyyy-MM-dd HH:mm:ss。
*/
@NotNull(message = "流水创建时间不能为空")
@Schema(description = "流水创建时间,格式 yyyy-MM-dd HH:mm:ss", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
}
@@ -140,9 +140,11 @@ public class WalletManageController {
}
private R<List<WalletManageVo>> listWalletByIds(List<Long> walletIds) {
// 内部接口无用户凭证,不能走依赖 UPMS 数据权限的 scopeList;
// 钱包主键已由调用方业务记录过滤,这里直接按主键联查钱包类型返回。
WalletManageDto dto = new WalletManageDto();
dto.setWalletIds(walletIds);
List<WalletManageVo> wallet = walletManageService.scopeList(dto, null);
List<WalletManageVo> wallet = walletManageMapper.joinList(dto);
if (ObjectUtil.isEmpty(wallet)) return R.failed("钱包不存在");
return R.ok(wallet);
}
@@ -18,6 +18,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotBatchDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionUpdateInfoDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo;
@@ -214,6 +215,20 @@ public class WalletTransactionController {
return R.ok(walletTransactionService.updateRemark(dto));
}
/**
* 修改单笔钱包流水的归属部门和创建时间,用于更正流水归属统计口径,不开放金额、余额等核心字段变更。
*
* @param dto 流水编号、新部门编号和新创建时间
* @return 更新到当前租户有效流水时返回 {@code true}
*/
@PutMapping("/update-info")
@Operation(summary = "修改钱包流水信息")
@SysLog("修改钱包流水信息")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_update_info')")
public R<Boolean> updateInfo(@Valid @RequestBody WalletTransactionUpdateInfoDto dto) {
return R.ok(walletTransactionService.updateInfo(dto));
}
@Operation(summary = "后台充值分页", description = "后台充值分页")
@PostMapping("/page-cz")
public R pageCz(@ParameterObject Page page, @RequestBody WalletTransactionDto dto) {
@@ -134,6 +134,7 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
.leftJoin(WalletTypeEntity.class, WalletTypeEntity::getId, WalletManageEntity::getWalletTypeId)
.eqIfExists(WalletManageEntity::getUserId, dto.getUserId())
.eqIfExists(WalletManageEntity::getWalletTypeId, dto.getWalletTypeId())
.in(ObjectUtil.isNotEmpty(dto.getWalletIds()), WalletManageEntity::getId, dto.getWalletIds())
.eqIfExists(WalletManageEntity::getIsFreeze, dto.getIsFreeze())
.likeIfExists(WalletManageEntity::getCardNumber, dto.getCardNumber())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletManageEntity::getUserId, dto.getUserIds())
@@ -8,6 +8,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotBatchDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionUpdateInfoDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo;
import com.bsd.pjcloud.wallet.api.vo.WalletTransactionDepartmentSnapshotVo;
@@ -186,6 +187,14 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
*/
Boolean updateRemark(WalletTransactionRemarkDto dto);
/**
* 修改单笔钱包流水的归属部门和创建时间,用于更正流水的部门归属统计和统计周期。
*
* @param dto 流水编号、新部门编号和新创建时间
* @return 更新到当前租户有效流水时返回 {@code true}
*/
Boolean updateInfo(WalletTransactionUpdateInfoDto dto);
/**
* 更新会员用户余额
*
@@ -42,6 +42,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotBatchDt
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionUpdateInfoDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
import com.bsd.pjcloud.wallet.api.dto.mch.WalletMchDeptViewDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
@@ -829,6 +830,32 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
return true;
}
/**
* 校验目标部门有效后,使用独立更新条件仅重写归属部门和创建时间,不触碰流水其他字段。
*
* @param dto 流水编号、新部门编号和新创建时间
* @return 更新到当前租户有效流水时返回 {@code true}
*/
@Override
public Boolean updateInfo(WalletTransactionUpdateInfoDto dto) {
// 部门归属直接决定部门统计口径,先经 UPMS 校验部门真实存在,避免把无效部门编号写入流水。
R<SysDept> deptResult = deptService.getDeptByDeptId(dto.getDeptId());
if (deptResult == null || !deptResult.isOk() || ObjectUtil.isEmpty(deptResult.getData())) {
throw new I18nBizException(ErrorCodes.COMMON_RECORD_NOT_FOUND);
}
// 传入空实体会跳过 MyBatis-Plus 的修改人和修改时间填充,因此保留更新实体承载审计字段。
WalletTransactionEntity updateEntity = new WalletTransactionEntity();
int updated = baseMapper.update(updateEntity, Wrappers.<WalletTransactionEntity>lambdaUpdate()
.eq(WalletTransactionEntity::getId, dto.getId())
.set(WalletTransactionEntity::getDeptId, dto.getDeptId())
.set(WalletTransactionEntity::getCreateTime, dto.getCreateTime()));
// 部分 MySQL affected-row 配置会把“值未变化”返回为 0,仅在记录也查不到时才判定失败。
if (updated == 0 && baseMapper.selectById(dto.getId()) == null) {
throw new I18nBizException(ErrorCodes.WALLET_TX_NOT_FOUND);
}
return true;
}
@Override
// @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#dto.getWalletId()")
public Boolean updateWalletBalance(WalletTransactionDto dto) {