diff --git a/db/20260922_add_canteen_spu_combo_name.sql b/db/20260922_add_canteen_spu_combo_name.sql
new file mode 100644
index 000000000..ac96ecc8d
--- /dev/null
+++ b/db/20260922_add_canteen_spu_combo_name.sql
@@ -0,0 +1,20 @@
+-- 每日菜谱菜品表补充套餐名列:发票/兑换券打印商品名优先展示套餐名(comboName),为空降级商品名(name)。
+-- 该列由 2026-09-21 代码提交(实体 CanteenSpuEntity.comboName)引入但当时未附迁移;若 DBA 已手工加列,
+-- 本脚本通过 information_schema 判断自动跳过,可反复执行。
+-- 注意:canteen_spu.name 列长度请以目标库 SHOW CREATE TABLE 为准,如不一致请先对齐再执行。
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_canteen`.`canteen_spu` ADD COLUMN `combo_name` varchar(128) DEFAULT NULL COMMENT ''套餐名(仅spuType=2套餐使用),发票/兑换券打印商品名优先展示,为空降级商品名'' AFTER `name_en`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_canteen' AND TABLE_NAME = 'canteen_spu' AND COLUMN_NAME = 'combo_name');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 【可选诊断】确认列已添加。
+SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = 'pjcloudx_canteen'
+ AND TABLE_NAME = 'canteen_spu'
+ AND COLUMN_NAME = 'combo_name';
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
index 983f094bc..cdded4d08 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
@@ -23,6 +23,7 @@ package com.bsd.pjcloud.canteen.api.feign;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
+import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
@@ -102,6 +103,19 @@ public interface RemoteCanteenSpuService {
@NoToken
R> getInnerSpuListByIds(@RequestBody List ids);
+ /**
+ * 供 Hardware 打印发票/兑换券查询商品名称:套餐名优先、商品名兜底。
+ *
+ * 入参 ID 兼容 canteen_spu.id 和 canteen_online_spu.id(经 linkSpuId 关联),
+ * 未命中的 ID 不出现在返回结果中,调用方自行降级购物车快照名。
+ *
+ * @param productIds 购物车商品 ID 列表
+ * @return 命中商品的打印名称列表
+ */
+ @PostMapping("/canteenSpu/inner/getSpuPrintNames")
+ @NoToken
+ R> getInnerSpuPrintNames(@RequestBody List productIds);
+
/**
* 按套餐 SPU ID 批量查询套餐组成,用于跨服务补充只读展示数据。
*
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java
new file mode 100644
index 000000000..e44f2d44b
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java
@@ -0,0 +1,40 @@
+package com.bsd.pjcloud.canteen.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 打印凭证商品名称视图对象。
+ *
+ * Hardware 打印发票/兑换券时按购物车商品 ID 实时查询,返回套餐名优先、商品名兜底的展示名称;
+ * {@code productId} 回传调用方原始入参 ID,兼容直接命中 canteen_spu 和经 canteen_online_spu.linkSpuId
+ * 关联命中的两种来源。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "打印凭证商品名称")
+public class SpuPrintNameVo {
+
+ /**
+ * 调用方传入的商品 ID,可能是 canteen_spu.id 或 canteen_online_spu.id。
+ */
+ @Schema(description = "商品ID,回传原始入参")
+ private Long productId;
+
+ /**
+ * 套餐名(仅 spuType=2 套餐有值),打印凭证优先展示。
+ */
+ @Schema(description = "套餐名")
+ private String comboName;
+
+ /**
+ * 商品名称,套餐名为空时打印凭证降级使用。
+ */
+ @Schema(description = "商品名称")
+ private String name;
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
index a34a5510c..bfc15f963 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
@@ -21,6 +21,7 @@ import com.bsd.pjcloud.canteen.excel.CanteenSpuTemplateMergeHandler;
import com.bsd.pjcloud.canteen.service.impl.CanteenSpuImportHelper;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import com.bsd.pjcloud.canteen.vo.ADCanteenSpuListVO;
+import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuSimpleVO;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuStockVO;
@@ -430,6 +431,21 @@ public class CanteenSpuController {
return querySpuByIds(ids);
}
+ /**
+ * 为 Hardware 打印发票/兑换券提供商品名称查询:套餐名(comboName)优先、商品名(name)兜底。
+ *
+ * 入参 ID 兼容 canteen_spu.id(线下/每日菜谱单)和 canteen_online_spu.id(App 线上预约单,
+ * 经 linkSpuId 关联回基础菜品);未命中的 ID 不出现在返回结果中,调用方自行降级购物车快照名。
+ *
+ * @param productIds 购物车商品 ID 列表
+ * @return 命中商品的打印名称列表
+ */
+ @Inner
+ @PostMapping("/inner/getSpuPrintNames")
+ public R> getInnerSpuPrintNames(@RequestBody List productIds) {
+ return R.ok(canteenSpuService.loadSpuPrintNames(productIds));
+ }
+
/**
* 统一执行菜品 SPU 批量查询,避免管理端和内部端点产生不同的数据筛选口径。
*
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
index fec256dbe..8b2e24e38 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuSimpleVO;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuStockVO;
import com.bsd.pjcloud.canteen.vo.ADCanteenSpuListVO;
+import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.R;
@@ -127,4 +128,15 @@ public interface CanteenSpuService extends IService {
* 批量加载套餐组成(App/每日菜谱展示)
*/
Map> loadComboItemVos(List comboSpuIds);
+
+ /**
+ * 按购物车商品 ID 批量加载打印凭证名称(套餐名优先、商品名兜底)。
+ *
+ * ID 兼容两种来源:直接命中 canteen_spu(线下/每日菜谱单),或经 canteen_online_spu.linkSpuId
+ * 关联命中 canteen_spu(App 线上预约单);返回的 productId 回传原始入参 ID。
+ *
+ * @param productIds 商品 ID 列表;空列表返回空列表
+ * @return 命中的打印名称列表;未命中的 ID 不出现在结果中
+ */
+ List loadSpuPrintNames(List productIds);
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
index c3a502169..1db09efd0 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
@@ -18,6 +18,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenStockEntity;
import com.bsd.pjcloud.canteen.mapper.*;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
+import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuSimpleVO;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuStockVO;
import com.bsd.pjcloud.canteen.vo.ADCanteenSpuListVO;
@@ -769,6 +770,69 @@ public class CanteenSpuServiceImpl extends ServiceImpl loadSpuPrintNames(List productIds) {
+ if (CollUtil.isEmpty(productIds)) {
+ return Collections.emptyList();
+ }
+ List distinctIds = productIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
+ if (distinctIds.isEmpty()) {
+ return Collections.emptyList();
+ }
+ // 第一步:直接命中 canteen_spu(线下单、每日菜谱单的商品 ID)。
+ List directHits = list(new LambdaQueryWrapper()
+ .in(CanteenSpuEntity::getId, distinctIds));
+ Map spuById = new LinkedHashMap<>();
+ for (CanteenSpuEntity spu : directHits) {
+ if (spu != null && spu.getId() != null) {
+ spuById.putIfAbsent(spu.getId(), spu);
+ }
+ }
+ List result = new ArrayList<>(spuById.size());
+ for (Map.Entry entry : spuById.entrySet()) {
+ result.add(buildPrintNameVo(entry.getKey(), entry.getValue()));
+ }
+ // 第二步:App 线上预约单的商品 ID 是 canteen_online_spu.id,经 linkSpuId 关联回基础菜品取名。
+ List missIds = distinctIds.stream().filter(id -> !spuById.containsKey(id)).collect(Collectors.toList());
+ if (missIds.isEmpty()) {
+ return result;
+ }
+ List onlineHits = canteenOnlineSpuMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(CanteenOnlineSpuEntity::getId, missIds)
+ .isNotNull(CanteenOnlineSpuEntity::getLinkSpuId));
+ if (CollUtil.isEmpty(onlineHits)) {
+ return result;
+ }
+ List linkSpuIds = onlineHits.stream()
+ .map(CanteenOnlineSpuEntity::getLinkSpuId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .collect(Collectors.toList());
+ Map linkedById = new LinkedHashMap<>();
+ for (CanteenSpuEntity spu : list(new LambdaQueryWrapper()
+ .in(CanteenSpuEntity::getId, linkSpuIds))) {
+ if (spu != null && spu.getId() != null) {
+ linkedById.putIfAbsent(spu.getId(), spu);
+ }
+ }
+ for (CanteenOnlineSpuEntity online : onlineHits) {
+ CanteenSpuEntity linked = online.getLinkSpuId() == null ? null : linkedById.get(online.getLinkSpuId());
+ if (linked != null) {
+ result.add(buildPrintNameVo(online.getId(), linked));
+ }
+ }
+ return result;
+ }
+
+ private SpuPrintNameVo buildPrintNameVo(Long productId, CanteenSpuEntity spu) {
+ return SpuPrintNameVo.builder()
+ .productId(productId)
+ .comboName(spu.getComboName())
+ .name(spu.getName())
+ .build();
+ }
+
private void normalizeSpuType(CanteenSpuEntity entity) {
if (entity.getSpuType() == null || (entity.getSpuType() != 1 && entity.getSpuType() != 2)) {
entity.setSpuType(1);
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplPrintNameTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplPrintNameTest.java
new file mode 100644
index 000000000..2a32bbe0a
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplPrintNameTest.java
@@ -0,0 +1,118 @@
+package com.bsd.pjcloud.canteen.service.impl;
+
+import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
+import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
+import com.bsd.pjcloud.canteen.mapper.CanteenOnlineSpuMapper;
+import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
+import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 打印凭证商品名称查询测试:线下/每日菜谱商品直接命中,App 线上预约商品经 linkSpuId 关联取名。
+ */
+class CanteenSpuServiceImplPrintNameTest {
+
+ /** 被测服务。 */
+ private CanteenSpuServiceImpl service;
+
+ /** 基础菜品 Mapper,模拟 canteen_spu 查询。 */
+ private com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper spuMapper;
+
+ /** 在线点餐 Mapper,模拟 canteen_online_spu 查询。 */
+ private CanteenOnlineSpuMapper onlineSpuMapper;
+
+ @BeforeEach
+ void setUp() {
+ spuMapper = mock(com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper.class);
+ onlineSpuMapper = mock(CanteenOnlineSpuMapper.class);
+ CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(mock(StringRedisTemplate.class),
+ mock(com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper.class), null, null,
+ onlineSpuMapper, null, mock(CanteenMerchantScopeHelper.class),
+ mock(CanteenDailyMenuStockSupport.class));
+ ReflectionTestUtils.setField(target, "baseMapper", spuMapper);
+ service = target;
+ }
+
+ /** 线下/每日菜谱单的商品 ID 直接命中 canteen_spu,返回套餐名与商品名原始口径。 */
+ @Test
+ void directSpuHitReturnsPrintName() {
+ when(spuMapper.selectList(any())).thenReturn(List.of(spu(501L, "A套餐", "A套餐-红烧肉")));
+
+ List names = service.loadSpuPrintNames(List.of(501L));
+
+ assertEquals(1, names.size());
+ assertEquals(501L, names.get(0).getProductId());
+ assertEquals("A套餐", names.get(0).getComboName());
+ assertEquals("A套餐-红烧肉", names.get(0).getName());
+ verify(onlineSpuMapper, never()).selectList(any());
+ }
+
+ /** App 线上预约单的商品 ID 是 canteen_online_spu.id,必须经 linkSpuId 关联回基础菜品取名并回传原始 ID。 */
+ @Test
+ void onlineSpuIdResolvesThroughLinkSpuId() {
+ when(spuMapper.selectList(any())).thenReturn(List.of()).thenReturn(List.of(spu(501L, "B套餐", "B套餐-主菜")));
+ when(onlineSpuMapper.selectList(any())).thenReturn(List.of(onlineSpu(601L, 501L)));
+
+ List names = service.loadSpuPrintNames(List.of(601L));
+
+ assertEquals(1, names.size());
+ assertEquals(601L, names.get(0).getProductId());
+ assertEquals("B套餐", names.get(0).getComboName());
+ assertEquals("B套餐-主菜", names.get(0).getName());
+ }
+
+ /** 混合入参:直接命中、经 linkSpuId 命中与完全未命中(已删除/非食堂商品)互不影响。 */
+ @Test
+ void mixedIdsKeepHitsAndSkipUnknown() {
+ when(spuMapper.selectList(any())).thenReturn(List.of(spu(501L, "A套餐", "A套餐-红烧肉")))
+ .thenReturn(List.of(spu(502L, null, "糖醋排骨")));
+ when(onlineSpuMapper.selectList(any())).thenReturn(List.of(onlineSpu(601L, 502L)));
+
+ List names = service.loadSpuPrintNames(List.of(501L, 601L, 999L));
+
+ assertEquals(2, names.size());
+ SpuPrintNameVo direct = names.stream().filter(vo -> vo.getProductId() == 501L).findFirst().orElseThrow();
+ assertEquals("A套餐", direct.getComboName());
+ SpuPrintNameVo linked = names.stream().filter(vo -> vo.getProductId() == 601L).findFirst().orElseThrow();
+ assertEquals("糖醋排骨", linked.getName());
+ assertNull(linked.getComboName());
+ }
+
+ /** 空入参直接返回空列表且不访问数据库。 */
+ @Test
+ void emptyInputSkipsQueries() {
+ assertTrue(service.loadSpuPrintNames(List.of()).isEmpty());
+ verify(spuMapper, never()).selectList(any());
+ }
+
+ /** 构造基础菜品实体。 */
+ private CanteenSpuEntity spu(Long id, String comboName, String name) {
+ CanteenSpuEntity entity = new CanteenSpuEntity();
+ entity.setId(id);
+ entity.setName(name);
+ entity.setComboName(comboName);
+ return entity;
+ }
+
+ /** 构造在线点餐实体,linkSpuId 指向基础菜品。 */
+ private CanteenOnlineSpuEntity onlineSpu(Long id, Long linkSpuId) {
+ CanteenOnlineSpuEntity entity = new CanteenOnlineSpuEntity();
+ entity.setId(id);
+ entity.setLinkSpuId(linkSpuId);
+ return entity;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java
index 8830a9146..81bcf7793 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java
@@ -17,6 +17,7 @@ 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.SpuPrintNameVo;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
@@ -290,7 +291,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl printNames = loadPrintProductNames(carList);
+ appendInvoiceProducts(renderer, carList, metadata, showComboItems, printNames);
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
renderer.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
}
@@ -326,15 +328,18 @@ public class HardInvoiceServiceImpl extends ServiceImpl发票是支付凭证,小计使用下单时的商品快照总额;套餐标签与组合内容沿用兑换券元数据和显示开关。
+ * 发票是支付凭证,小计使用下单时的商品快照总额;商品名称打印时实时按套餐名优先解析,
+ * 套餐标签与组合内容沿用兑换券元数据和显示开关。
*
* @param renderer 当前渲染器
* @param cars 订单商品快照
* @param metadata 按商品 ID 索引的套餐类型和组成项
* @param showComboItems 是否展示套餐组成内容
+ * @param printNames 按商品 ID 索引的实时打印名称;未命中的商品降级购物车快照名
*/
private static void appendInvoiceProducts(ReceiptRenderer renderer, List cars,
- Map metadata, boolean showComboItems) {
+ Map metadata, boolean showComboItems,
+ Map printNames) {
renderer.divider().sectionTitle("商品明细");
boolean hasProduct = false;
for (HardCarVo car : cars) {
@@ -344,7 +349,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl comboItems = combo && showComboItems
? componentItemTexts(product.items(), invoiceComboQuantity(car)) : List.of();
- renderer.voucherProduct(car.getProductName(), invoiceQuantityText(car), money(voucherUnitPrice(car)),
+ renderer.voucherProduct(resolvePrintName(car, printNames), invoiceQuantityText(car), money(voucherUnitPrice(car)),
money(car.getTotalPrice()), combo, comboItems);
}
if (!hasProduct) renderer.item("无商品明细", "x0", false, false);
@@ -906,16 +911,28 @@ public class HardInvoiceServiceImpl extends ServiceImpl商品名称打印时实时按套餐名优先解析:先集中收集全部订单的商品快照一次性查询打印名称,
+ * 避免多订单兑换券逐单发起远程调用;未命中实时名称的商品降级购物车快照名。
+ *
* @param voucherOrders 同一次核销的有序订单
* @param showComboItems 是否加载并显示套餐组成内容
* @return 与订单顺序一致的商品快照分组
*/
private List loadVoucherOrderProducts(List voucherOrders,
boolean showComboItems) {
- List result = new ArrayList<>();
+ List> carsPerOrder = new ArrayList<>(voucherOrders.size());
+ List allCars = new ArrayList<>();
for (HardOrderEntity voucherOrder : voucherOrders) {
List cars = Optional.ofNullable(carService.getByOrderId(voucherOrder.getId()))
.orElseGet(List::of);
+ carsPerOrder.add(cars);
+ allCars.addAll(cars);
+ }
+ Map printNames = loadPrintProductNames(allCars);
+ List result = new ArrayList<>(voucherOrders.size());
+ for (int index = 0; index < voucherOrders.size(); index++) {
+ HardOrderEntity voucherOrder = voucherOrders.get(index);
+ List cars = carsPerOrder.get(index);
Map metadata = loadProductMetadata(cars, showComboItems);
List products = new ArrayList<>();
for (HardCarVo car : cars) {
@@ -924,7 +941,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl商品 ID 兼容 canteen_spu.id(线下/每日菜谱单)和 canteen_online_spu.id(App 线上预约单,
+ * 服务端经 linkSpuId 关联取名);非食堂商品、已删除商品或名称全为空的记录不进入结果,
+ * 调用方降级购物车快照名。打印是核销后的关键闭环,查询失败时记录警告并整体降级,不阻断出票。
+ *
+ * @param cars 票据涉及的商品快照
+ * @return 按商品 ID 索引的打印名称;查询失败时返回空 Map
+ */
+ private Map loadPrintProductNames(List cars) {
+ List productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList();
+ if (productIds.isEmpty()) return Map.of();
+ try {
+ R> result = remoteCanteenSpuService.getInnerSpuPrintNames(productIds);
+ if (result == null || !result.isOk() || result.getData() == null) return Map.of();
+ Map names = new LinkedHashMap<>();
+ for (SpuPrintNameVo vo : result.getData()) {
+ if (vo == null || vo.getProductId() == null) continue;
+ String comboName = StrUtil.trimToNull(vo.getComboName());
+ String printName = comboName != null ? comboName : StrUtil.trimToNull(vo.getName());
+ if (printName != null) names.put(vo.getProductId(), printName);
+ }
+ return names;
+ } catch (Exception exception) {
+ log.warn("打印商品名称加载失败,按购物车快照名降级,productIds:{}", productIds, exception);
+ return Map.of();
+ }
+ }
+
+ /**
+ * 解析单个购物车行的打印名称:命中实时名称时使用套餐名优先的结果,否则降级下单快照名。
+ *
+ * @param car 订单商品快照;历史数据允许商品 ID 为空
+ * @param printNames 按商品 ID 索引的实时打印名称
+ * @return 该商品打印在票据上的名称
+ */
+ private static String resolvePrintName(HardCarVo car, Map printNames) {
+ if (car.getProductId() == null) return car.getProductName();
+ return printNames.getOrDefault(car.getProductId(), car.getProductName());
+ }
+
/**
* 校验订单当前允许补建或重打兑换券。
*/
@@ -1402,7 +1461,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+
+ assertEquals(Map.of(501L, "15元套餐"), names);
+ }
+
+ /** 套餐名为空串(非套餐商品保存时被清空)时必须降级商品名。 */
+ @Test
+ void printProductNamesFallBackToNameWhenComboBlank() {
+ HardCarVo car = new HardCarVo();
+ car.setProductId(502L);
+ when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(502L))).thenReturn(R.ok(List.of(
+ SpuPrintNameVo.builder().productId(502L).comboName("").name("糖醋排骨").build())));
+
+ @SuppressWarnings("unchecked")
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+
+ assertEquals(Map.of(502L, "糖醋排骨"), names);
+ }
+
+ /** 商品已删除或非食堂商品时不在返回结果中,渲染层由快照名兜底。 */
+ @Test
+ void printProductNamesSkipMissingProductAndBlankNames() {
+ HardCarVo car = new HardCarVo();
+ car.setProductId(503L);
+ when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(503L))).thenReturn(R.ok(List.of(
+ SpuPrintNameVo.builder().productId(504L).comboName("B套餐").name("B套餐-主菜").build())));
+
+ @SuppressWarnings("unchecked")
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+
+ assertFalse(names.containsKey(503L));
+ }
+
+ /** 打印是核销后的关键闭环:名称查询失败必须整体降级为快照名而不是阻断出票。 */
+ @Test
+ void printProductNamesFallBackToEmptyWhenQueryFails() {
+ HardCarVo car = new HardCarVo();
+ car.setProductId(501L);
+ when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(501L)))
+ .thenThrow(new RuntimeException("canteen service unavailable"));
+
+ @SuppressWarnings("unchecked")
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+
+ assertTrue(names.isEmpty());
+ }
+
+ /** 未命中实时名称或历史购物车商品 ID 为空时,打印名必须回落到下单快照名。 */
+ @Test
+ void resolvePrintNameFallsBackToSnapshot() {
+ HardCarVo named = new HardCarVo();
+ named.setProductId(501L);
+ named.setProductName("下单快照名");
+ HardCarVo legacy = new HardCarVo();
+ legacy.setProductId(null);
+ legacy.setProductName("历史无ID商品");
+
+ assertEquals("实时套餐名", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
+ named, Map.of(501L, "实时套餐名")));
+ assertEquals("下单快照名", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
+ named, Map.of()));
+ assertEquals("历史无ID商品", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
+ legacy, Map.of()));
+ }
+
private static HardOrderEntity order() {
return HardOrderEntity.builder().id(1L).hardId(10L).merchantId(30L).deptId(40L).tenantId(50L).build();
}