From 54f784c16de096b1622108bdca58c2beb43c556b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 22 Sep 2026 10:31:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=89=93=E5=8D=B0=E6=9C=BA=E4=BD=BF?= =?UTF-8?q?=E7=94=A8"=E5=A5=97=E9=A4=90=E5=90=8D"=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260922_add_canteen_spu_combo_name.sql | 20 +++ .../api/feign/RemoteCanteenSpuService.java | 14 +++ .../pjcloud/canteen/vo/SpuPrintNameVo.java | 40 ++++++ .../controller/CanteenSpuController.java | 16 +++ .../canteen/service/CanteenSpuService.java | 12 ++ .../service/impl/CanteenSpuServiceImpl.java | 64 ++++++++++ .../CanteenSpuServiceImplPrintNameTest.java | 118 ++++++++++++++++++ .../service/impl/HardInvoiceServiceImpl.java | 73 +++++++++-- .../impl/HardInvoiceServiceImplTest.java | 75 +++++++++++ 9 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 db/20260922_add_canteen_spu_combo_name.sql create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplPrintNameTest.java 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(); }