feat:别名打印问题

This commit is contained in:
dzc committed 2026-10-14 14:57:07 +08:00
1 parent 0ff9fb9342
commit 66be2c3ad2
9 files changed
+359 -34

No files matched your search

@@ -21,6 +21,7 @@ package com.bsd.pjcloud.canteen.api.feign;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.SpuPrintNameQueryDTO;
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
@@ -116,6 +117,20 @@ public interface RemoteCanteenSpuService {
@NoToken
R<List<SpuPrintNameVo>> getInnerSpuPrintNames(@RequestBody List<Long> productIds);
/**
* 供 Hardware 打印发票/兑换券查询商品名称,带排菜上下文时当日菜谱展示名优先作为套餐名。
*
* <p>在 {@link #getInnerSpuPrintNames(List)} 的解析口径之上,按商户+用餐日期+餐段匹配
* canteen_daily_menu 行,非空 display_name 覆盖套餐名,保证打印主行与排菜展示一致;
* 上下文不齐全时返回结果与原接口完全一致。</p>
*
* @param query 商品 ID 列表与可选排菜上下文
* @return 命中商品的打印名称列表
*/
@PostMapping("/canteenSpu/inner/getSpuPrintNamesWithMenu")
@NoToken
R<List<SpuPrintNameVo>> getInnerSpuPrintNamesWithMenu(@RequestBody SpuPrintNameQueryDTO query);
/**
* 按套餐 SPU ID 批量查询套餐组成,用于跨服务补充只读展示数据。
*
@@ -0,0 +1,49 @@
package com.bsd.pjcloud.canteen.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* 打印凭证商品名称查询请求:商品 ID 加可选的排菜上下文。
*
* <p>排菜上下文(商户 + 用餐日期 + 餐段)齐全时,先按既有口径解析 canteen_spu 套餐名/商品名,
* 再用当日菜谱行的展示名(canteen_daily_menu.display_name)覆盖套餐名,使打印主行与排菜展示一致;
* 上下文缺失时行为与仅传商品 ID 完全相同。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "打印凭证商品名称查询请求")
public class SpuPrintNameQueryDTO {
/**
* 购物车商品 ID 列表,兼容 canteen_spu.id 和 canteen_online_spu.id。
*/
@Schema(description = "商品ID列表")
private List<Long> productIds;
/**
* 商户 ID;与用餐日期、餐段共同定位当日菜谱行。
*/
@Schema(description = "商户ID")
private Long merchantId;
/**
* 用餐日期;对应 canteen_daily_menu.start_time。
*/
@Schema(description = "用餐日期")
private LocalDate mealDate;
/**
* 餐段:1 早餐 2 午餐 3 晚餐;对应 canteen_daily_menu.hour_type。
*/
@Schema(description = "餐段")
private Integer hourType;
}
@@ -27,7 +27,7 @@ public class SpuPrintNameVo {
private Long productId;
/**
* 套餐名(canteen_spu.combo_name);每日排菜别名见 display_name,打印若需排菜名需另查菜谱。
* 套餐名(canteen_spu.combo_name);携带排菜上下文查询时已被当日菜谱展示名(display_name)覆盖。
*/
@Schema(description = "套餐名")
private String comboName;
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.canteen.dto.CanteenSpuBatchPicDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuExportQuery;
import com.bsd.pjcloud.canteen.dto.CanteenSpuImportExcel;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.SpuPrintNameQueryDTO;
import com.bsd.pjcloud.canteen.entity.CanteenErpMaterialEntity;
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenStockEntity;
@@ -446,6 +447,22 @@ public class CanteenSpuController {
return R.ok(canteenSpuService.loadSpuPrintNames(productIds));
}
/**
* 为 Hardware 打印发票/兑换券提供带排菜上下文的商品名称查询。
*
* <p>在 {@link #getInnerSpuPrintNames(List)} 的解析口径之上,按商户+用餐日期+餐段匹配
* canteen_daily_menu 行,非空展示名(display_name)覆盖套餐名,使打印主行与当日排菜展示一致;
* 上下文不齐全时返回结果与原接口完全一致。</p>
*
* @param query 商品 ID 列表与可选排菜上下文
* @return 命中商品的打印名称列表
*/
@Inner
@PostMapping("/inner/getSpuPrintNamesWithMenu")
public R<List<SpuPrintNameVo>> getInnerSpuPrintNamesWithMenu(@RequestBody SpuPrintNameQueryDTO query) {
return R.ok(canteenSpuService.loadSpuPrintNames(query));
}
/**
* 统一执行菜品 SPU 批量查询,避免管理端和内部端点产生不同的数据筛选口径。
*
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.canteen.dto.CanteenDailySpuDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.SpuPrintNameQueryDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuSimpleVO;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuStockVO;
@@ -147,4 +148,16 @@ public interface CanteenSpuService extends IService<CanteenSpuEntity> {
* @return 命中的打印名称列表;未命中的 ID 不出现在结果中
*/
List<SpuPrintNameVo> loadSpuPrintNames(List<Long> productIds);
/**
* 按购物车商品 ID 批量加载打印凭证名称,排菜上下文齐全时叠加当日菜谱展示名。
*
* <p>解析口径与 {@link #loadSpuPrintNames(List)} 一致,另按商户+用餐日期+餐段匹配
* canteen_daily_menu 行,非空 display_name 覆盖套餐名,使打印主行与排菜展示一致;
* 上下文不齐全时退化为无上下文行为,不额外查询菜谱。</p>
*
* @param query 商品 ID 列表与可选排菜上下文
* @return 命中的打印名称列表;未命中的 ID 不出现在结果中
*/
List<SpuPrintNameVo> loadSpuPrintNames(SpuPrintNameQueryDTO query);
}
@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.canteen.dto.CanteenDailySpuDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.SpuPrintNameQueryDTO;
import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
@@ -920,10 +921,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
@Override
public List<SpuPrintNameVo> loadSpuPrintNames(List<Long> productIds) {
if (CollUtil.isEmpty(productIds)) {
return loadSpuPrintNames(SpuPrintNameQueryDTO.builder().productIds(productIds).build());
}
@Override
public List<SpuPrintNameVo> loadSpuPrintNames(SpuPrintNameQueryDTO query) {
if (query == null || CollUtil.isEmpty(query.getProductIds())) {
return Collections.emptyList();
}
List<Long> distinctIds = productIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
List<Long> distinctIds = query.getProductIds().stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
if (distinctIds.isEmpty()) {
return Collections.emptyList();
}
@@ -937,42 +943,99 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
}
}
List<SpuPrintNameVo> result = new ArrayList<>(spuById.size());
// 打印名回传调用方原始商品 ID;菜谱展示名按底层 SPU ID 匹配,这里同步记录两套 ID 的映射。
Map<Long, Long> spuIdByProductId = new LinkedHashMap<>();
for (Map.Entry<Long, CanteenSpuEntity> entry : spuById.entrySet()) {
result.add(buildPrintNameVo(entry.getKey(), entry.getValue()));
spuIdByProductId.put(entry.getKey(), entry.getKey());
}
// 第二步:App 线上预约单的商品 ID 是 canteen_online_spu.id,经 linkSpuId 关联回基础菜品取名。
List<Long> missIds = distinctIds.stream().filter(id -> !spuById.containsKey(id)).collect(Collectors.toList());
if (missIds.isEmpty()) {
return result;
}
List<CanteenOnlineSpuEntity> onlineHits = canteenOnlineSpuMapper.selectList(
new LambdaQueryWrapper<CanteenOnlineSpuEntity>()
.in(CanteenOnlineSpuEntity::getId, missIds)
.isNotNull(CanteenOnlineSpuEntity::getLinkSpuId));
if (CollUtil.isEmpty(onlineHits)) {
return result;
}
List<Long> linkSpuIds = onlineHits.stream()
.map(CanteenOnlineSpuEntity::getLinkSpuId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, CanteenSpuEntity> linkedById = new LinkedHashMap<>();
for (CanteenSpuEntity spu : list(new LambdaQueryWrapper<CanteenSpuEntity>()
.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));
if (!missIds.isEmpty()) {
List<CanteenOnlineSpuEntity> onlineHits = canteenOnlineSpuMapper.selectList(
new LambdaQueryWrapper<CanteenOnlineSpuEntity>()
.in(CanteenOnlineSpuEntity::getId, missIds)
.isNotNull(CanteenOnlineSpuEntity::getLinkSpuId));
if (CollUtil.isNotEmpty(onlineHits)) {
List<Long> linkSpuIds = onlineHits.stream()
.map(CanteenOnlineSpuEntity::getLinkSpuId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, CanteenSpuEntity> linkedById = new LinkedHashMap<>();
for (CanteenSpuEntity spu : list(new LambdaQueryWrapper<CanteenSpuEntity>()
.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));
spuIdByProductId.put(online.getId(), online.getLinkSpuId());
}
}
}
}
// 第三步:排菜上下文齐全时,用当日菜谱展示名覆盖套餐名,保证打印主行与排菜展示一致。
overlayDailyMenuDisplayNames(result, spuIdByProductId, query);
return result;
}
/**
* 用当日菜谱行的展示名(canteen_daily_menu.display_name)覆盖打印套餐名。
*
* <p>单品菜品的套餐名在保存时会被清空,商户在排菜行上配置的展示名只挂菜谱、不写回菜品,
* 因此打印查名若只看 canteen_spu 会漏掉展示名。这里按商户+用餐日期+餐段精确定位菜谱行,
* 非空展示名覆盖套餐名(含真套餐的原套餐名,以当日排菜展示为准);上下文不齐全、
* 菜谱行缺失或展示名为空时保持原解析结果不变。</p>
*
* @param result 已按商品 ID 解析出的打印名称,允许原地覆盖
* @param spuIdByProductId 打印名商品 ID 到底层 canteen_spu ID 的映射
* @param query 含排菜上下文的原始查询
*/
private void overlayDailyMenuDisplayNames(List<SpuPrintNameVo> result, Map<Long, Long> spuIdByProductId,
SpuPrintNameQueryDTO query) {
if (result.isEmpty() || query.getMerchantId() == null || query.getMealDate() == null
|| query.getHourType() == null) {
return;
}
List<Long> spuIds = spuIdByProductId.values().stream().filter(Objects::nonNull).distinct()
.collect(Collectors.toList());
if (spuIds.isEmpty()) {
return;
}
// 同一 SPU 当日同餐段正常只有一条菜谱行;万一存在重复,先出现的非空展示名优先,结果保持稳定。
Map<Long, String> displayNameBySpuId = new LinkedHashMap<>();
for (CanteenDailyMenuEntity menu : canteenDailyMenuMapper.selectList(
Wrappers.<CanteenDailyMenuEntity>lambdaQuery()
.select(CanteenDailyMenuEntity::getSpuId, CanteenDailyMenuEntity::getDisplayName)
.eq(CanteenDailyMenuEntity::getMerchantId, query.getMerchantId())
.eq(CanteenDailyMenuEntity::getStartTime, query.getMealDate())
.eq(CanteenDailyMenuEntity::getHourType, query.getHourType())
.in(CanteenDailyMenuEntity::getSpuId, spuIds))) {
if (menu == null || menu.getSpuId() == null) {
continue;
}
String displayName = StrUtil.trimToNull(menu.getDisplayName());
if (displayName != null) {
displayNameBySpuId.putIfAbsent(menu.getSpuId(), displayName);
}
}
if (displayNameBySpuId.isEmpty()) {
return;
}
for (int index = 0; index < result.size(); index++) {
SpuPrintNameVo vo = result.get(index);
String displayName = displayNameBySpuId.get(spuIdByProductId.get(vo.getProductId()));
if (displayName != null) {
result.set(index, SpuPrintNameVo.builder()
.productId(vo.getProductId()).comboName(displayName).name(vo.getName()).build());
}
}
}
private SpuPrintNameVo buildPrintNameVo(Long productId, CanteenSpuEntity spu) {
return SpuPrintNameVo.builder()
.productId(productId)
@@ -1,5 +1,9 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.canteen.dto.SpuPrintNameQueryDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineSpuMapper;
@@ -7,9 +11,11 @@ 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.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -22,7 +28,8 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 打印凭证商品名称查询测试:线下/每日菜谱商品直接命中,App 线上预约商品经 linkSpuId 关联取名。
* 打印凭证商品名称查询测试:线下/每日菜谱商品直接命中,App 线上预约商品经 linkSpuId 关联取名;
* 带排菜上下文时当日菜谱展示名覆盖套餐名。
*/
class CanteenSpuServiceImplPrintNameTest {
@@ -35,12 +42,19 @@ class CanteenSpuServiceImplPrintNameTest {
/** 在线点餐 Mapper,模拟 canteen_online_spu 查询。 */
private CanteenOnlineSpuMapper onlineSpuMapper;
/** 每日菜谱 Mapper,模拟 canteen_daily_menu 查询。 */
private com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper dailyMenuMapper;
@BeforeEach
void setUp() {
// 菜谱展示名覆盖使用 select 指定列,lambda 列名在单测环境需要预注册实体元数据。
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
CanteenDailyMenuEntity.class);
spuMapper = mock(com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper.class);
onlineSpuMapper = mock(CanteenOnlineSpuMapper.class);
dailyMenuMapper = mock(com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper.class);
CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(mock(StringRedisTemplate.class),
mock(com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper.class), null, null,
dailyMenuMapper, null, null,
onlineSpuMapper, null, mock(CanteenMerchantScopeHelper.class),
mock(CanteenDailyMenuStockSupport.class));
ReflectionTestUtils.setField(target, "baseMapper", spuMapper);
@@ -99,6 +113,59 @@ class CanteenSpuServiceImplPrintNameTest {
verify(spuMapper, never()).selectList(any());
}
/** 排菜上下文齐全时当日菜谱展示名覆盖套餐名:单品套餐名为空也能借展示名获得打印主行。 */
@Test
void menuDisplayNameOverridesComboNameForDirectHit() {
when(spuMapper.selectList(any())).thenReturn(List.of(spu(501L, null, "榨菜肉丝面")));
when(dailyMenuMapper.selectList(any()))
.thenReturn(List.of(menu(501L, "榨菜肉丝面123123")));
List<SpuPrintNameVo> names = service.loadSpuPrintNames(SpuPrintNameQueryDTO.builder()
.productIds(List.of(501L)).merchantId(30L).mealDate(LocalDate.of(2026, 10, 14))
.hourType(2).build());
assertEquals(1, names.size());
assertEquals(501L, names.get(0).getProductId());
assertEquals("榨菜肉丝面123123", names.get(0).getComboName());
assertEquals("榨菜肉丝面", names.get(0).getName());
}
/** 线上预约商品按 linkSpuId 反查菜谱行:展示名覆盖的是关联基础菜品的菜谱,不是原始入参 ID。 */
@Test
void menuDisplayNameAppliesToOnlineLinkedProduct() {
when(spuMapper.selectList(any())).thenReturn(List.of()).thenReturn(List.of(spu(501L, "B套餐", "B套餐-主菜")));
when(onlineSpuMapper.selectList(any())).thenReturn(List.of(onlineSpu(601L, 501L)));
when(dailyMenuMapper.selectList(any())).thenReturn(List.of(menu(501L, "今日B套餐")));
List<SpuPrintNameVo> names = service.loadSpuPrintNames(SpuPrintNameQueryDTO.builder()
.productIds(List.of(601L)).merchantId(30L).mealDate(LocalDate.of(2026, 10, 14))
.hourType(2).build());
assertEquals(1, names.size());
assertEquals(601L, names.get(0).getProductId());
assertEquals("今日B套餐", names.get(0).getComboName());
assertEquals("B套餐-主菜", names.get(0).getName());
}
/** 菜谱展示名为空白时保持 canteen_spu 原套餐名;上下文不齐全时不查询菜谱。 */
@Test
void blankMenuDisplayNameOrMissingContextKeepsSpuComboName() {
when(spuMapper.selectList(any())).thenReturn(List.of(spu(501L, "A套餐", "A套餐-红烧肉")));
when(dailyMenuMapper.selectList(any())).thenReturn(List.of(menu(501L, " ")));
// 缺少餐段时无法定位菜谱行,必须完全退化为无上下文行为。
List<SpuPrintNameVo> noHour = service.loadSpuPrintNames(SpuPrintNameQueryDTO.builder()
.productIds(List.of(501L)).merchantId(30L).mealDate(LocalDate.of(2026, 10, 14)).build());
assertEquals("A套餐", noHour.get(0).getComboName());
verify(dailyMenuMapper, never()).selectList(any());
// 菜谱展示名为空白时不得覆盖原套餐名。
List<SpuPrintNameVo> names = service.loadSpuPrintNames(SpuPrintNameQueryDTO.builder()
.productIds(List.of(501L)).merchantId(30L).mealDate(LocalDate.of(2026, 10, 14))
.hourType(2).build());
assertEquals("A套餐", names.get(0).getComboName());
}
/** 构造基础菜品实体。 */
private CanteenSpuEntity spu(Long id, String comboName, String name) {
CanteenSpuEntity entity = new CanteenSpuEntity();
@@ -115,4 +182,12 @@ class CanteenSpuServiceImplPrintNameTest {
entity.setLinkSpuId(linkSpuId);
return entity;
}
/** 构造每日菜谱实体,仅填充展示名覆盖逻辑依赖的字段。 */
private CanteenDailyMenuEntity menu(Long spuId, String displayName) {
CanteenDailyMenuEntity entity = new CanteenDailyMenuEntity();
entity.setSpuId(spuId);
entity.setDisplayName(displayName);
return entity;
}
}
@@ -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.dto.SpuPrintNameQueryDTO;
import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.BeanUtils;
@@ -76,6 +77,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
@@ -293,7 +295,10 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
if (StrUtil.isNotBlank(walletTypeName)) renderer.keyValue("钱包类型", walletTypeName);
appendVoucherUser(renderer, user);
appendInvoiceOrderBlock(renderer, order);
Map<Long, PrintProductName> printNames = loadPrintProductNames(carList);
// 商品名称打印时实时解析为套餐名主行加商品名小字副行;携带商户+用餐日期+餐段时
// 套餐名优先取当日菜谱展示名,与商户排菜配置保持一致。
Map<Long, PrintProductName> printNames = loadPrintProductNames(carList, order.getMerchantId(),
order.getApptUseDate(), parseHourType(order.getHourType()));
appendInvoiceProducts(renderer, carList, metadata, showComboItems, printNames);
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
renderer.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
@@ -952,12 +957,18 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
carsPerOrder.add(cars);
allCars.addAll(cars);
}
Map<Long, PrintProductName> printNames = loadPrintProductNames(allCars);
// 按商户+用餐日期+餐段缓存查名结果:同一批核销的订单通常同餐段,避免逐单发起远程调用。
Map<String, Map<Long, PrintProductName>> printNamesByContext = new LinkedHashMap<>();
List<VoucherOrderProducts> result = new ArrayList<>(voucherOrders.size());
for (int index = 0; index < voucherOrders.size(); index++) {
HardOrderEntity voucherOrder = voucherOrders.get(index);
List<HardCarVo> cars = carsPerOrder.get(index);
Map<Long, ProductMetadata> metadata = loadProductMetadata(cars, showComboItems);
Integer hourType = parseHourType(voucherOrder.getHourType());
String contextKey = voucherOrder.getMerchantId() + "|" + voucherOrder.getApptUseDate() + "|" + hourType;
Map<Long, PrintProductName> printNames = printNamesByContext.computeIfAbsent(contextKey,
ignored -> loadPrintProductNames(allCars, voucherOrder.getMerchantId(),
voucherOrder.getApptUseDate(), hourType));
List<VoucherProductLine> products = new ArrayList<>();
for (HardCarVo car : cars) {
int count = effectiveCount(car);
@@ -1244,10 +1255,32 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
* @return 按商品 ID 索引的双名结构;查询失败时返回空 Map
*/
private Map<Long, PrintProductName> loadPrintProductNames(List<HardCarVo> cars) {
return loadPrintProductNames(cars, null, null, null);
}
/**
* 批量加载打印凭证商品名称,排菜上下文齐全时叠加当日菜谱展示名。
*
* <p>在仅按商品 ID 解析的口径之上,携带商户、用餐日期和餐段调用带菜谱上下文的内部接口,
* canteen 侧会用当日菜谱行的展示名覆盖套餐名,使打印主行与商户排菜展示一致;上下文缺失时
* 自动退回无上下文接口,行为与历史版本完全一致。</p>
*
* @param cars 票据涉及的商品快照
* @param merchantId 下单商户 ID;用于定位当日菜谱行
* @param mealDate 用餐日期;用于定位当日菜谱行
* @param hourType 餐段;用于定位当日菜谱行
* @return 按商品 ID 索引的双名结构;查询失败时返回空 Map
*/
private Map<Long, PrintProductName> loadPrintProductNames(List<HardCarVo> cars, Long merchantId,
LocalDate mealDate, Integer hourType) {
List<Long> productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList();
if (productIds.isEmpty()) return Map.of();
try {
R<List<SpuPrintNameVo>> result = remoteCanteenSpuService.getInnerSpuPrintNames(productIds);
boolean withMenuContext = merchantId != null && mealDate != null && hourType != null;
R<List<SpuPrintNameVo>> result = withMenuContext
? remoteCanteenSpuService.getInnerSpuPrintNamesWithMenu(SpuPrintNameQueryDTO.builder()
.productIds(productIds).merchantId(merchantId).mealDate(mealDate).hourType(hourType).build())
: remoteCanteenSpuService.getInnerSpuPrintNames(productIds);
if (result == null || !result.isOk() || result.getData() == null) return Map.of();
Map<Long, PrintProductName> names = new LinkedHashMap<>();
for (SpuPrintNameVo vo : result.getData()) {
@@ -1263,6 +1296,21 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
}
}
/**
* 解析订单上的餐段文本为餐段编号;历史订单可能存非数字文本,无法解析时返回 null 放弃菜谱匹配。
*
* @param hourType 订单餐段文本
* @return 餐段编号;空或非数字时返回 null
*/
private static Integer parseHourType(String hourType) {
if (StrUtil.isBlank(hourType)) return null;
try {
return Integer.valueOf(hourType.trim());
} catch (NumberFormatException exception) {
return null;
}
}
/**
* 解析单个购物车行的打印名称:命中实时名称时保留套餐名/商品名双字段,否则降级下单快照名。
*
@@ -9,6 +9,7 @@ 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.dto.SpuPrintNameQueryDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.SpuPrintNameVo;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
@@ -37,6 +38,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@@ -538,6 +540,49 @@ class HardInvoiceServiceImplTest {
assertTrue(names.isEmpty());
}
/** 排菜上下文齐全时必须携带商户+用餐日期+餐段调用带菜谱的接口,菜谱展示名进入套餐名主行。 */
@Test
void printProductNamesUseMenuContextWhenAvailable() {
HardCarVo car = new HardCarVo();
car.setProductId(501L);
when(remoteCanteenSpuService.getInnerSpuPrintNamesWithMenu(any(SpuPrintNameQueryDTO.class)))
.thenReturn(R.ok(List.of(SpuPrintNameVo.builder().productId(501L)
.comboName("榨菜肉丝面123123").name("榨菜肉丝面").build())));
@SuppressWarnings("unchecked")
Map<Long, Object> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames",
List.of(car), 30L, LocalDate.of(2026, 10, 14), 2);
ArgumentCaptor<SpuPrintNameQueryDTO> captor = ArgumentCaptor.forClass(SpuPrintNameQueryDTO.class);
verify(remoteCanteenSpuService).getInnerSpuPrintNamesWithMenu(captor.capture());
verify(remoteCanteenSpuService, never()).getInnerSpuPrintNames(any());
assertEquals(List.of(501L), captor.getValue().getProductIds());
assertEquals(30L, captor.getValue().getMerchantId());
assertEquals(LocalDate.of(2026, 10, 14), captor.getValue().getMealDate());
assertEquals(2, captor.getValue().getHourType());
Object printName = names.get(501L);
assertEquals("榨菜肉丝面123123", ReflectionTestUtils.invokeMethod(printName, "mainName"));
assertEquals("榨菜肉丝面", ReflectionTestUtils.invokeMethod(printName, "subName"));
}
/** 排菜上下文缺失(如历史订单餐段不可解析)时必须退回无上下文接口,行为与历史版本一致。 */
@Test
void printProductNamesFallBackToPlainQueryWithoutFullContext() {
HardCarVo car = new HardCarVo();
car.setProductId(501L);
when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(501L))).thenReturn(R.ok(List.of(
SpuPrintNameVo.builder().productId(501L).comboName("A套餐").name("A套餐-红烧肉").build())));
@SuppressWarnings("unchecked")
Map<Long, Object> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames",
List.of(car), 30L, LocalDate.of(2026, 10, 14), null);
verify(remoteCanteenSpuService).getInnerSpuPrintNames(List.of(501L));
verify(remoteCanteenSpuService, never()).getInnerSpuPrintNamesWithMenu(any(SpuPrintNameQueryDTO.class));
Object printName = names.get(501L);
assertEquals("A套餐", ReflectionTestUtils.invokeMethod(printName, "mainName"));
}
/** 未命中实时名称或历史购物车商品 ID 为空时,打印名必须回落到下单快照名并保持大字主行。 */
@Test
void resolvePrintNameFallsBackToSnapshot() {