|
|
@@ -21,6 +21,7 @@ import org.springframework.stereotype.Service;
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
import java.util.*;
|
|
|
import java.util.stream.Collectors;
|
|
|
import com.etotem.cfc.util.SortUtil;
|
|
|
@@ -571,21 +572,18 @@ public class BeijingNutritionService {
|
|
|
* @return 推荐食材列表(Top 15,含推荐理由)
|
|
|
*/
|
|
|
public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date, int seed) {
|
|
|
- List<IngredientRecommendation> result = new ArrayList<>();
|
|
|
-
|
|
|
- // 1. 查 meal_configs → participant_member_ids
|
|
|
+ Set<Long> participantIds = new HashSet<>();
|
|
|
+
|
|
|
LambdaQueryWrapper<MealConfig> configWrapper = new LambdaQueryWrapper<>();
|
|
|
configWrapper.eq(MealConfig::getFamilyId, familyId);
|
|
|
configWrapper.eq(MealConfig::getConfigDateType, isWeekend(date) ? "weekend" : "weekday");
|
|
|
configWrapper.isNull(MealConfig::getConfigDate);
|
|
|
SortUtil.applySort(configWrapper);
|
|
|
List<MealConfig> mealConfigs = mealConfigMapper.selectList(configWrapper);
|
|
|
-
|
|
|
- // 2. 收集参与成员
|
|
|
- Set<Long> participantIds = new HashSet<>();
|
|
|
+
|
|
|
if (!mealConfigs.isEmpty()) {
|
|
|
for (MealConfig config : mealConfigs) {
|
|
|
- // 解析 participantMemberIds JSON
|
|
|
+ if (config.getParticipantMemberIds() == null) continue;
|
|
|
try {
|
|
|
com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper().readTree(config.getParticipantMemberIds());
|
|
|
if (nodes.isArray()) {
|
|
|
@@ -598,9 +596,7 @@ public class BeijingNutritionService {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // 未配置共餐时,降级为查询该家庭全部成员
|
|
|
- // 当天无默认配置匹配时,优先查当天临时配置(config_date = 当天)
|
|
|
+
|
|
|
if (participantIds.isEmpty()) {
|
|
|
LambdaQueryWrapper<MealConfig> todayWrapper = new LambdaQueryWrapper<>();
|
|
|
todayWrapper.eq(MealConfig::getFamilyId, familyId);
|
|
|
@@ -632,23 +628,21 @@ public class BeijingNutritionService {
|
|
|
}
|
|
|
if (participantIds.isEmpty()) {
|
|
|
log.warn("家庭 {} 无任何家庭成员", familyId);
|
|
|
- return result;
|
|
|
+ return new ArrayList<>();
|
|
|
}
|
|
|
log.info("家庭 {} 未配置共餐,降级为使用全部 {} 个家庭成员", familyId, allMembers.size());
|
|
|
}
|
|
|
-
|
|
|
- // 3. 查每个 member 的 diet_preferences + health_gut_flora
|
|
|
+
|
|
|
Map<Long, List<String>> allergiesMap = new HashMap<>();
|
|
|
Map<Long, List<String>> avoidMap = new HashMap<>();
|
|
|
Map<Long, Map<String, String>> bacteriaStatusMap = new HashMap<>();
|
|
|
-
|
|
|
+
|
|
|
for (Long memberId : participantIds) {
|
|
|
- // 查调研表
|
|
|
LambdaQueryWrapper<com.etotem.cfc.entity.DietPreferences> prefWrapper = new LambdaQueryWrapper<>();
|
|
|
prefWrapper.eq(com.etotem.cfc.entity.DietPreferences::getFamilyMemberId, memberId);
|
|
|
SortUtil.applySort(prefWrapper);
|
|
|
com.etotem.cfc.entity.DietPreferences prefs = dietPreferencesMapper.selectOne(prefWrapper);
|
|
|
-
|
|
|
+
|
|
|
if (prefs != null) {
|
|
|
if (prefs.getAllergies() != null) {
|
|
|
try {
|
|
|
@@ -667,8 +661,7 @@ public class BeijingNutritionService {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // 查最近菌群报告
|
|
|
+
|
|
|
LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
|
|
|
reportWrapper.eq(HealthReport::getUserId, memberId)
|
|
|
.eq(HealthReport::getReportType, "gut_flora")
|
|
|
@@ -676,13 +669,13 @@ public class BeijingNutritionService {
|
|
|
.last("LIMIT 1");
|
|
|
SortUtil.applySort(reportWrapper);
|
|
|
HealthReport report = healthReportMapper.selectOne(reportWrapper);
|
|
|
-
|
|
|
+
|
|
|
if (report != null) {
|
|
|
LambdaQueryWrapper<HealthGutFlora> floraWrapper = new LambdaQueryWrapper<>();
|
|
|
floraWrapper.eq(HealthGutFlora::getReportId, report.getId());
|
|
|
SortUtil.applySort(floraWrapper);
|
|
|
List<HealthGutFlora> floraList = healthGutFloraMapper.selectList(floraWrapper);
|
|
|
-
|
|
|
+
|
|
|
Map<String, String> bacteriaStatus = new HashMap<>();
|
|
|
for (HealthGutFlora flora : floraList) {
|
|
|
if (flora.getBacteriaName() != null && flora.getStatus() != null && !"正常".equals(flora.getStatus())) {
|
|
|
@@ -692,67 +685,125 @@ public class BeijingNutritionService {
|
|
|
bacteriaStatusMap.put(memberId, bacteriaStatus);
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // 4. 合并禁忌食材
|
|
|
+
|
|
|
Set<String> allAllergies = new HashSet<>();
|
|
|
allergiesMap.values().forEach(allAllergies::addAll);
|
|
|
Set<String> allAvoid = new HashSet<>();
|
|
|
avoidMap.values().forEach(allAvoid::addAll);
|
|
|
-
|
|
|
- // 5. 获取所有食材并计算推荐分
|
|
|
- List<Food> allFoods = foodMapper.selectList(null);
|
|
|
Map<String, String> mergedBacteriaStatus = new HashMap<>();
|
|
|
bacteriaStatusMap.values().forEach(mergedBacteriaStatus::putAll);
|
|
|
-
|
|
|
Map<String, BacteriaFoodMapping.FoodAdjustment> adjustments = bacteriaFoodMapping.computeAdjustments(mergedBacteriaStatus);
|
|
|
-
|
|
|
+
|
|
|
+ List<Food> allFoods = foodMapper.selectList(null);
|
|
|
+ List<ScoredIngredient> scored = new ArrayList<>();
|
|
|
+
|
|
|
for (Food food : allFoods) {
|
|
|
String foodName = food.getName();
|
|
|
-
|
|
|
- // 过滤禁忌食材
|
|
|
- if (allAllergies.contains(foodName) || allAvoid.contains(foodName)) {
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
+ if (allAllergies.contains(foodName) || allAvoid.contains(foodName)) continue;
|
|
|
+
|
|
|
BacteriaFoodMapping.FoodAdjustment adj = adjustments.get(foodName);
|
|
|
- int score = 50;
|
|
|
- String reason = "";
|
|
|
-
|
|
|
- if (adj != null) {
|
|
|
- if ("recommend".equals(adj.getDirection())) {
|
|
|
- score = 80 + (int)(Math.random() * 15);
|
|
|
- reason = adj.getRecommendReasons() != null && !adj.getRecommendReasons().isEmpty()
|
|
|
- ? adj.getRecommendReasons().get(0) : "推荐食材";
|
|
|
- } else if ("avoid".equals(adj.getDirection())) {
|
|
|
- score = 10 + (int)(Math.random() * 20);
|
|
|
- reason = "建议避免";
|
|
|
- } else {
|
|
|
- score = 40 + (int)(Math.random() * 20);
|
|
|
- }
|
|
|
+ double bacteriaScore = scoreBacteria(adj != null ? adj.getDirection() : null, food.getScore());
|
|
|
+ double nutritionScore = scoreNutrition(food.getCategory(), food);
|
|
|
+ double cookingScore = scoreCooking(food.getCategory());
|
|
|
+ double energyScore = scoreEnergyEfficiency(nutritionScore, food);
|
|
|
+
|
|
|
+ double composite = bacteriaScore * 0.40 + nutritionScore * 0.25
|
|
|
+ + cookingScore * 0.20 + energyScore * 0.15;
|
|
|
+
|
|
|
+ if (seed != 0) {
|
|
|
+ Random rand = new Random(seed * 31L + food.getId().hashCode());
|
|
|
+ composite += (rand.nextDouble() - 0.5) * 10.0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (adj != null && "recommend".equals(adj.getDirection())) {
|
|
|
+ composite += 10.0;
|
|
|
+ }
|
|
|
+
|
|
|
+ String cookingLabel = cookingScore >= 80 ? "快手" : cookingScore >= 50 ? "易做" : "需时";
|
|
|
+ String nutritionLabel = nutritionScore >= 70 ? "高营养" : nutritionScore >= 50 ? "均衡" : "低脂";
|
|
|
+ String bacteriaLabel = adj != null && "recommend".equals(adj.getDirection()) ? "菌属推荐"
|
|
|
+ : adj != null && "avoid".equals(adj.getDirection()) ? "建议避免" : "";
|
|
|
+ List<String> reasons = new ArrayList<>();
|
|
|
+ reasons.add(cookingLabel);
|
|
|
+ reasons.add(nutritionLabel);
|
|
|
+ if (!bacteriaLabel.isEmpty()) reasons.add(bacteriaLabel);
|
|
|
+ String balanceReason = String.join("·", reasons);
|
|
|
+
|
|
|
+ ScoredIngredient si = new ScoredIngredient();
|
|
|
+ si.foodId = food.getId();
|
|
|
+ si.name = foodName;
|
|
|
+ si.category = food.getCategory();
|
|
|
+ si.bacteriaScore = bacteriaScore;
|
|
|
+ si.nutritionScore = nutritionScore;
|
|
|
+ si.cookingScore = cookingScore;
|
|
|
+ si.energyScore = energyScore;
|
|
|
+ si.compositeScore = composite;
|
|
|
+ si.balanceReason = balanceReason;
|
|
|
+ scored.add(si);
|
|
|
+ }
|
|
|
+
|
|
|
+ scored.sort((a, b) -> Double.compare(b.compositeScore, a.compositeScore));
|
|
|
+
|
|
|
+ List<ScoredIngredient> result = new ArrayList<>();
|
|
|
+ Map<String, Integer> categoryCount = new HashMap<>();
|
|
|
+ int targetMin = 10, targetMax = 12;
|
|
|
+
|
|
|
+ for (Map.Entry<String, CategoryQuota> entry : CATEGORY_QUOTA.entrySet()) {
|
|
|
+ String cat = entry.getKey();
|
|
|
+ int max = entry.getValue().max;
|
|
|
+ if (max == 0) continue;
|
|
|
+
|
|
|
+ List<ScoredIngredient> catCandidates = scored.stream()
|
|
|
+ .filter(s -> cat.equals(s.category))
|
|
|
+ .filter(s -> !result.contains(s))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ int take = Math.min(max, catCandidates.size());
|
|
|
+ for (int i = 0; i < take; i++) {
|
|
|
+ result.add(catCandidates.get(i));
|
|
|
}
|
|
|
-
|
|
|
- // 检查是否已在结果中
|
|
|
- boolean exists = result.stream().anyMatch(r -> r.getFoodId() != null && r.getFoodId().equals(food.getId()));
|
|
|
- if (!exists) {
|
|
|
- IngredientRecommendation rec = new IngredientRecommendation();
|
|
|
- rec.setFoodId(food.getId());
|
|
|
- rec.setName(foodName);
|
|
|
- rec.setScore(score);
|
|
|
- rec.setReason(reason);
|
|
|
- rec.setCategory(food.getCategory());
|
|
|
- result.add(rec);
|
|
|
+ categoryCount.put(cat, take);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (result.size() < targetMin) {
|
|
|
+ Set<Long> selectedIds = result.stream().map(s -> s.foodId).collect(Collectors.toSet());
|
|
|
+ List<ScoredIngredient> remaining = scored.stream()
|
|
|
+ .filter(s -> !selectedIds.contains(s.foodId))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ int need = targetMin - result.size();
|
|
|
+ for (int i = 0; i < Math.min(need, remaining.size()); i++) {
|
|
|
+ result.add(remaining.get(i));
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // 6. 排序并返回 Top 15
|
|
|
- result.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
|
|
|
- if (seed != 0 && result.size() > 1) {
|
|
|
- int offset = Math.abs(seed) % result.size();
|
|
|
- if (offset > 0) {
|
|
|
- java.util.Collections.rotate(result, offset);
|
|
|
+
|
|
|
+ if (result.size() > targetMax) {
|
|
|
+ result.sort((a, b) -> Double.compare(b.compositeScore, a.compositeScore));
|
|
|
+ result = result.subList(0, targetMax);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<IngredientRecommendation> recommendations = new ArrayList<>();
|
|
|
+ for (ScoredIngredient si : result) {
|
|
|
+ IngredientRecommendation rec = new IngredientRecommendation();
|
|
|
+ rec.setFoodId(si.foodId);
|
|
|
+ rec.setName(si.name);
|
|
|
+ rec.setCategory(si.category);
|
|
|
+ rec.setScore((int) si.compositeScore);
|
|
|
+ rec.setNutritionScore((int) si.nutritionScore);
|
|
|
+ rec.setCookingLevel(si.cookingScore >= 80 ? 1 : si.cookingScore >= 50 ? 2 : 3);
|
|
|
+ rec.setBalanceReason(si.balanceReason);
|
|
|
+ if (si.name != null) {
|
|
|
+ Food food = allFoods.stream().filter(f -> f.getId().equals(si.foodId)).findFirst().orElse(null);
|
|
|
+ if (food != null) {
|
|
|
+ if (food.getCalories() != null) {
|
|
|
+ rec.setCaloriesPer100g(food.getCalories().intValue());
|
|
|
+ } else if (food.getEnergyKj() != null && food.getEnergyKj().compareTo(BigDecimal.ZERO) > 0) {
|
|
|
+ rec.setCaloriesPer100g((int) (food.getEnergyKj().doubleValue() / 4.184));
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
+ recommendations.add(rec);
|
|
|
}
|
|
|
- return result.size() > 15 ? new ArrayList<>(result.subList(0, 15)) : result;
|
|
|
+ return recommendations;
|
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
|
@@ -848,3 +899,9 @@ public class BeijingNutritionService {
|
|
|
double ratio = energyKj.doubleValue() / 500.0;
|
|
|
return Math.max(0, Math.min(100, nutritionScore / Math.max(ratio, 0.3)));
|
|
|
}
|
|
|
+
|
|
|
+ private boolean isWeekend(java.time.LocalDate date) {
|
|
|
+ return date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY ||
|
|
|
+ date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
|
|
|
+ }
|
|
|
+}
|