shinnosuke
2026-09-21 7844f5af457ff3b7d3f8eeac8799e8d281e635a1
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java
@@ -27,9 +27,11 @@
    private static final int ATTEMPT_ORDER_CREATED = 3;
    private final JdbcTemplate jdbc;
    private final MerchantPaymentReadinessService paymentReadinessService;
    public ManualConsumptionService(JdbcTemplate jdbc) {
    public ManualConsumptionService(JdbcTemplate jdbc, MerchantPaymentReadinessService paymentReadinessService) {
        this.jdbc = jdbc;
        this.paymentReadinessService = paymentReadinessService;
    }
    @Transactional(readOnly = true)
@@ -38,11 +40,14 @@
        ensurePending(source);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("verificationId", verificationId);
        result.put("customerName", maskName(string(source.get("talentName"))));
        result.put("customerName", displayName(string(source.get("talentName")), number(source.get("gender"))));
        result.put("customerMobile", maskMobile(string(source.get("talentMobile"))));
        result.put("cardLevel", string(source.get("cardLevel")));
        result.put("benefitText", benefitText(principal.merchantId(), string(source.get("cardLevel"))));
        result.put("rules", rules(principal.merchantId(), string(source.get("cardLevel"))));
        List<Map<String, Object>> matchedRules = rules(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")));
        if (matchedRules.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前商户未配置适用于该人才卡的到店消费权益");
        result.put("benefitText", benefitTextForLevel(principal.merchantId(), string(source.get("cardLevel")),
                string(source.get("sourceAreaType")), matchedRules));
        result.put("rules", matchedRules);
        result.put("coupons", coupons(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel"))));
        result.put("expiresAt", source.get("expiresAt"));
        return result;
@@ -52,7 +57,7 @@
    public Map<String, Object> quote(MerchantPrincipal principal, ManualConsumptionCreateRequest request) {
        Map<String, Object> source = source(principal, request.verificationId(), false);
        ensurePending(source);
        Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), request.ruleId());
        Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")), request.ruleId());
        Map<String, Object> coupon = usableCoupon(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel")), request.couponId(), discountedAmount(rule, request.amount()));
        return quote(rule, coupon, request.amount());
    }
@@ -68,14 +73,15 @@
            return createdOrder(existingOrderId);
        }
        ensurePending(source);
      Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), request.ruleId());
      Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")), request.ruleId());
        Map<String, Object> coupon = usableCoupon(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel")), request.couponId(), discountedAmount(rule, request.amount()));
        Map<String, Object> amounts = quote(rule, coupon, request.amount());
        // 需要支付的订单在创建时固定收款子商户;能力未就绪时不消耗人才码,避免产生无法结算的订单。
        MerchantPaymentReadinessService.CollectingMerchant collectingMerchant = number(amounts.get("payAmount")) > 0
                ? paymentReadinessService.requireCollectingMerchant(principal.merchantId(), principal.storeId())
                : null;
        Long dynamicCodeId = number(source.get("dynamicCodeId"));
        int codeUpdated = jdbc.update("""
                update talent_dynamic_code set status = 1, merchant_id = ?, used_at = now()
            where id = ? and status = 0
                """, principal.merchantId(), dynamicCodeId);
        int codeUpdated = consumeScannedTalentCode(principal, dynamicCodeId);
        if (codeUpdated == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效");
        }
@@ -83,16 +89,20 @@
        Map<String, Object> merchant = merchant(principal);
        String orderNo = BusinessIdGenerator.next("MC");
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, card_id, merchant_id, store_id, order_type,
            insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, store_id, order_type,
                                          order_amount, subsidy_amount, base_pay_amount, pay_amount, coupon_id, coupon_amount, expires_at, price_version,
                                          merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot,
                                          merchant_longitude_snapshot, merchant_latitude_snapshot, product_name_snapshot,
                                          collect_onboarding_id, collect_store_id, collect_huifu_id,
                                          order_status, verify_status, merchant_verification_attempt_id, client_idempotency_key)
                values (?, ?, ?, ?, ?, 'STORE_CONSUME', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
                """, orderNo, number(source.get("talentId")), number(source.get("cardId")), principal.merchantId(), principal.storeId(),
            values (?, ?, ?, ?, ?, ?, 'STORE_CONSUME', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
            """, orderNo, number(source.get("talentId")), number(source.get("cardId")), string(source.get("cardLevel")), principal.merchantId(), principal.storeId(),
                amounts.get("originalAmount"), amounts.get("subsidyAmount"), amounts.get("basePayAmount"), amounts.get("payAmount"), request.couponId(), amounts.get("couponAmount"),
                LocalDateTime.now().plusMinutes(15), merchant.get("merchantName"), merchant.get("address"), merchant.get("contactMobile"),
                merchant.get("longitude"), merchant.get("latitude"), "到店消费 - " + rule.get("ruleName"),
                MerchantPaymentReadinessService.CollectingMerchant.applicationIdOf(collectingMerchant),
                MerchantPaymentReadinessService.CollectingMerchant.storeIdOf(collectingMerchant),
                MerchantPaymentReadinessService.CollectingMerchant.huifuIdOf(collectingMerchant),
                number(source.get("attemptId")), "manual-consumption-" + number(source.get("attemptId")));
        Long orderId = jdbc.queryForObject("select id from benefit_order where merchant_verification_attempt_id = ?", Long.class, number(source.get("attemptId")));
        jdbc.update("""
@@ -107,7 +117,7 @@
        List<Map<String, Object>> sources = rows("""
                select a.id attempt_id, a.dynamic_code_id, a.status attempt_status, a.expires_at, a.manual_order_id,
                       d.talent_id, d.card_id, c.card_level,
                       t.name talent_name, t.mobile talent_mobile
                       t.name talent_name, t.mobile talent_mobile, t.gender, t.source_area_type
                from merchant_verification_attempt a
                join talent_dynamic_code d on d.id = a.dynamic_code_id
                join talent_card c on c.id = d.card_id and c.deleted = 0
@@ -121,15 +131,22 @@
        return sources.get(0);
    }
    int consumeScannedTalentCode(MerchantPrincipal principal, Long dynamicCodeId) {
        return jdbc.update("""
                update talent_dynamic_code set status = 1, merchant_id = ?, used_at = now()
                where id = ? and status in (0, 2)
                """, principal.merchantId(), dynamicCodeId);
    }
    private void ensurePending(Map<String, Object> source) {
        if (number(source.get("attemptStatus")) != ATTEMPT_PENDING) {
            throw new BusinessException(ErrorCode.CONFLICT, "该人才码已处理,请重新扫码");
        }
   }
    private List<Map<String, Object>> rules(Long merchantId, String cardLevel) {
        List<Map<String, Object>> configured = configuredRules(merchantId, cardLevel);
        if (!configured.isEmpty()) return configured;
    private List<Map<String, Object>> rules(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> configured = configuredRules(merchantId, cardLevel, sourceAreaType);
        if (!configured.isEmpty() || hasStructuredRightsConfig(merchantId)) return configured;
        return rows("""
                select id, card_level, rule_name, discount_rate, subsidy_enabled
                from merchant_store_consume_rule
@@ -146,11 +163,65 @@
        }).toList();
    }
    private List<Map<String, Object>> configuredRules(Long merchantId, String cardLevel) {
        List<Map<String, Object>> merchants = rows("select merchant_rights_config from merchant where id = ? and deleted = 0 limit 1", merchantId);
    /**
     * 免费入住券要显示「超过免费天数按门市价X折」:X 就是这家店给该人才卡配的到店消费折扣
     * (走和到店消费页同一套解析,结构化配置和旧的门店规则表都能取到)。
     *
     * <p>没配折扣、或配的是立减时返回空串,调用方据此不显示这段说明。
     */
    String freeStayExceedDiscountText(Long merchantId, String cardLevel, String sourceAreaType) {
        for (Map<String, Object> rule : rules(merchantId, cardLevel, sourceAreaType)) {
            BigDecimal rate = decimal(rule.get("discountRate"));
            if (rate.signum() > 0) return "门市价" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折";
        }
        return "";
    }
    boolean hasApplicableRule(Long merchantId, String cardLevel, String sourceAreaType) {
        return !rules(merchantId, cardLevel, sourceAreaType).isEmpty();
    }
    private boolean hasStructuredRightsConfig(Long merchantId) {
        return !rightsConfigs(merchantId).isEmpty();
    }
    private List<Map<String, Object>> rightsConfigs(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code, merchant_rights_config from merchant where id = ? and deleted = 0 limit 1", merchantId);
        if (merchants.isEmpty()) return List.of();
        Object raw = merchants.get(0).get("merchantRightsConfig");
        if (raw == null || string(raw).isBlank()) return List.of();
        List<Map<String, Object>> configs = parseRightsConfigs(merchants.get(0).get("merchantRightsConfig"));
        if (!"parking".equalsIgnoreCase(string(merchants.get(0).get("merchantTypeCode")))) return configs;
        // Parking's dedicated card-level configuration takes precedence over legacy JSON.
        List<Map<String, Object>> parkingRights = rows("""
                select card_level as level, enabled, label as tag, benefit_detail as detail,
                       charge_type, store_consume_enabled, discount_text, government_subsidy,
                       consume_usage_text as consume_desc, status
                from transport_parking_card_level_right
                where merchant_id = ? and deleted = 0
                """, merchantId);
        for (Map<String, Object> parkingRight : parkingRights) {
            String level = string(parkingRight.get("level"));
            Map<String, Object> merged = configs.stream()
                    .filter(right -> matchesCardLevel(string(right.get("level")), level))
                    .findFirst().map(LinkedHashMap::new).orElseGet(LinkedHashMap::new);
            for (Map.Entry<String, Object> entry : parkingRight.entrySet()) {
                Object value = entry.getValue();
                if (value != null && (!(value instanceof String text) || !text.isBlank())) merged.put(entry.getKey(), value);
            }
            merged.put("enabled", booleanValue(parkingRight.get("enabled")) && booleanValue(parkingRight.get("status")));
            merged.put("parkingType", "PAID".equalsIgnoreCase(string(parkingRight.get("chargeType"))) ? "paid" : "free");
            merged.put("storeConsume", booleanValue(parkingRight.get("storeConsumeEnabled")));
            String discount = string(parkingRight.get("discountText")).replace("折", "").trim();
            if (!discount.isBlank()) merged.put("discount", discount);
            configs.removeIf(right -> matchesCardLevel(string(right.get("level")), level));
            configs.add(merged);
        }
        return configs;
    }
    private List<Map<String, Object>> parseRightsConfigs(Object raw) {
        if (raw == null || string(raw).isBlank()) return new ArrayList<>();
        String json = raw instanceof byte[] bytes ? new String(bytes, java.nio.charset.StandardCharsets.UTF_8) : string(raw);
        List<Map<String, Object>> configs = new ArrayList<>();
        try {
            if (raw instanceof List<?> list) {
@@ -160,34 +231,54 @@
                    configs.add(value);
                }
            } else {
                for (Object item : JSON.parseArray(string(raw), Map.class)) if (item instanceof Map<?, ?> map) {
                for (Object item : JSON.parseArray(json, Map.class)) if (item instanceof Map<?, ?> map) {
                    Map<String, Object> value = new LinkedHashMap<>();
                    for (Map.Entry<?, ?> entry : map.entrySet()) value.put(String.valueOf(entry.getKey()), entry.getValue());
                    configs.add(value);
                }
            }
        } catch (Exception ignored) {
            return List.of();
            return new ArrayList<>();
        }
        return configs.stream().filter(right -> booleanValue(right.get("enabled")) && booleanValue(right.get("consumeEnabled")) && booleanValue(right.get("storeConsume")) && matchesCardLevel(string(right.get("level")), cardLevel)).map(this::configuredRule).filter(rule -> decimal(rule.get("discountRate")).signum() > 0).toList();
        return configs;
    }
    private Map<String, Object> configuredRule(Map<String, Object> right) {
    private List<Map<String, Object>> configuredRules(Long merchantId, String cardLevel, String sourceAreaType) {
        return rightsConfigs(merchantId).stream()
                .filter(right -> booleanValue(right.get("enabled"))
                        && configuredStoreConsumptionEnabled(right)
                        && matchesConfiguredLevel(string(right.get("level")), cardLevel, sourceAreaType))
                .map(this::configuredRule)
                .toList();
    }
    boolean configuredStoreConsumptionEnabled(Map<String, Object> right) {
        if (!booleanValue(right.get("storeConsume"))) return false;
        if (!string(right.get("parkingType")).isBlank()) return "paid".equalsIgnoreCase(string(right.get("parkingType")));
        return booleanValue(right.get("consumeEnabled"));
    }
    Map<String, Object> configuredRule(Map<String, Object> right) {
        String level = normalizedCardLevel(string(right.get("level")));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", configRuleId(level));
        result.put("cardLevel", level);
        result.put("ruleName", string(right.get("tag")).isBlank() ? "到店消费优惠" : right.get("tag"));
        result.put("name", result.get("ruleName"));
        result.put("discountRate", configuredDiscountRate(right.get("discount")));
        result.put("subsidyEnabled", booleanValue(right.get("governmentSubsidy")) ? 1 : 0);
        result.put("discountRate", configuredDiscountRate(right));
        result.put("fixedReduction", configuredFixedReduction(right));
        result.put("subsidyEnabled", configuredSubsidyEnabled(right) ? 1 : 0);
        result.put("merchantDetail", right.get("detail"));
        result.put("consumeDesc", right.get("consumeDesc"));
        result.put("benefitText", configuredBenefitText(right));
        // 到店消费开着但没配折扣(子女托管、文化服务常见)时也要能发单:按原价收,标签上写明。
        // 以前这类规则被过滤掉,页面上价格全是 0、发送订单永远点不动。
        if (string(result.get("benefitText")).isBlank()) result.put("benefitText", "按原价支付");
        return result;
    }
    private Map<String, Object> rule(Long merchantId, String cardLevel, Long ruleId) {
        List<Map<String, Object>> configured = configuredRules(merchantId, cardLevel);
    private Map<String, Object> rule(Long merchantId, String cardLevel, String sourceAreaType, Long ruleId) {
        List<Map<String, Object>> configured = configuredRules(merchantId, cardLevel, sourceAreaType);
        for (Map<String, Object> candidate : configured) if (String.valueOf(candidate.get("id")).equals(String.valueOf(ruleId))) return candidate;
        List<Map<String, Object>> found = rows("""
                select id, card_level, rule_name, discount_rate, subsidy_enabled
@@ -201,9 +292,10 @@
    }
    private List<Map<String, Object>> coupons(Long merchantId, Long talentId, String cardLevel) {
        if (!storeConsumptionCouponsEnabled(merchantId)) return List.of();
        return rows("""
                select c.id, c.coupon_name as name, c.coupon_type as couponType, c.coupon_amount as couponAmount, c.talent_levels as talentLevels,
                       c.condition_type as conditionType, c.condition_amount as conditionAmount, c.valid_end as validEnd
                select c.id, c.coupon_name as name, c.coupon_type, c.coupon_amount, c.talent_levels,
                       c.condition_type, c.condition_amount, c.valid_end
                from merchant_coupon c
                where c.merchant_id = ? and c.deleted = 0 and c.status = 1 and c.store_consume = 1
                  and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date)
@@ -220,9 +312,12 @@
    private Map<String, Object> usableCoupon(Long merchantId, Long talentId, String cardLevel, Long couponId, BigDecimal amount) {
        if (couponId == null) return null;
        if (!storeConsumptionCouponsEnabled(merchantId)) {
            throw new BusinessException(ErrorCode.CONFLICT, "文化服务到店消费仅支持人才卡折扣,不支持优惠券");
        }
        List<Map<String,Object>> found = rows("""
                select c.id, c.coupon_amount as couponAmount, c.condition_type as conditionType, c.condition_amount as conditionAmount,
                       c.talent_levels as talentLevels
                select c.id, c.coupon_amount, c.condition_type, c.condition_amount,
                       c.talent_levels
                from merchant_coupon c
                where c.id = ? and c.merchant_id = ? and c.deleted = 0 and c.status = 1 and c.store_consume = 1
                  and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date)
@@ -239,6 +334,11 @@
        return coupon;
    }
    private boolean storeConsumptionCouponsEnabled(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code from merchant where id = ? and deleted = 0 limit 1", merchantId);
        return !merchants.isEmpty() && !"culture".equalsIgnoreCase(string(merchants.get(0).get("merchantTypeCode")));
    }
    private boolean supportsCard(String levels, String cardLevel) {
        if (levels == null || levels.isBlank()) return true;
        return java.util.Arrays.stream(levels.split("[,、,]" )).anyMatch(v -> matchesCardLevel(v, cardLevel));
@@ -248,13 +348,34 @@
        return switch (level) { case "EXCELLENT" -> -1L; case "OUTSTANDING" -> -2L; case "ELITE" -> -3L; default -> -9L; };
    }
    private BigDecimal configuredDiscountRate(Object value) {
    private BigDecimal configuredDiscountRate(Map<String, Object> right) {
        try {
            Object value = !string(right.get("parkingType")).isBlank() ? right.get("discount")
                    : "折扣".equals(string(right.get("consumeType"))) ? right.get("consumeDiscount") : right.get("discount");
            BigDecimal discount = decimal(string(value));
            return discount.signum() > 0 && discount.compareTo(BigDecimal.TEN) <= 0 ? discount.divide(BigDecimal.TEN, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
        } catch (Exception ignored) {
            return BigDecimal.ZERO;
        }
    }
    private BigDecimal configuredFixedReduction(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) return BigDecimal.ZERO;
        if (!"优惠".equals(string(right.get("consumeType")))) return BigDecimal.ZERO;
        try {
            BigDecimal reduction = decimal(string(right.get("consumeCoupon")));
            return reduction.signum() > 0 ? reduction : BigDecimal.ZERO;
        } catch (Exception ignored) {
            return BigDecimal.ZERO;
        }
    }
    private boolean configuredSubsidyEnabled(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) return booleanValue(right.get("governmentSubsidy"));
        String consumeType = string(right.get("consumeType"));
        if ("折扣".equals(consumeType)) return booleanValue(right.get("consumeDiscountSubsidy"));
        if ("优惠".equals(consumeType)) return booleanValue(right.get("consumeCouponSubsidy"));
        return booleanValue(right.get("governmentSubsidy"));
    }
    private boolean booleanValue(Object value) {
@@ -264,14 +385,14 @@
        return "true".equals(text) || "1".equals(text) || "yes".equals(text);
    }
    private Map<String, Object> quote(Map<String, Object> rule, Map<String,Object> coupon, BigDecimal inputAmount) {
    Map<String, Object> quote(Map<String, Object> rule, Map<String,Object> coupon, BigDecimal inputAmount) {
        long originalAmount = inputAmount.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact();
        long payAmount = discountedAmountCents(rule, originalAmount);
        long basePayAmount = payAmount;
        long couponAmount = coupon == null ? 0 : Math.min(decimal(coupon.get("couponAmount")).movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(), basePayAmount);
        payAmount = basePayAmount - couponAmount;
        long discountAmount = originalAmount - payAmount;
        long subsidyAmount = originalAmount - payAmount;
        long subsidyAmount = booleanValue(rule.get("subsidyEnabled")) ? discountAmount : 0;
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("originalAmount", originalAmount);
        result.put("discountAmount", discountAmount);
@@ -289,6 +410,11 @@
    }
    private long discountedAmountCents(Map<String, Object> rule, long originalAmount) {
        BigDecimal fixedReduction = rule.get("fixedReduction") == null ? BigDecimal.ZERO : decimal(rule.get("fixedReduction"));
        if (fixedReduction.signum() > 0) {
            long reductionCents = fixedReduction.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact();
            return Math.max(0, originalAmount - reductionCents);
        }
        BigDecimal rate = decimal(rule.get("discountRate"));
        if (rate.signum() <= 0 || rate.compareTo(BigDecimal.ONE) > 0) throw new BusinessException(ErrorCode.CONFLICT, "商户优惠规则配置无效");
        return BigDecimal.valueOf(originalAmount).multiply(rate).setScale(0, RoundingMode.HALF_UP).longValueExact();
@@ -320,30 +446,105 @@
        return result;
    }
    private String benefitText(Long merchantId, String cardLevel) {
    private String benefitText(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code from merchant where id = ? and deleted = 0 limit 1", merchantId);
        String merchantType = merchants.isEmpty() ? "" : string(merchants.get(0).get("merchantTypeCode"));
        if ("children".equalsIgnoreCase(merchantType) || "culture".equalsIgnoreCase(merchantType)) {
            List<Map<String, Object>> configs = rightsConfigs(merchantId).stream()
                    .filter(right -> booleanValue(right.get("enabled")))
                    .toList();
            String configuredText = firstRightsText(configs, cardLevel, sourceAreaType,
                    "culture".equalsIgnoreCase(merchantType) ? "consumeDesc" : "detail");
            // 没填权益描述时也要返回兜底文案:调用方会据此换成规则自己的权益(如「按原价支付」),
            // 返回空串会让卡片上的「当前权益」整行空着。
            return configuredText.isBlank() ? DEFAULT_BENEFIT_TEXT : configuredText;
        }
        String configuredDescription = consumeDescFromRights(merchantId, cardLevel, sourceAreaType);
        if (!configuredDescription.isBlank()) return configuredDescription;
        List<Map<String, Object>> benefits = rows("""
                select card_level, benefit_text from merchant_card_benefit
                where merchant_id = ? and status = 1 order by sort_no desc, id desc
                """, merchantId);
        String configuredDescription = configuredRules(merchantId, cardLevel).stream()
                .map(rule -> string(rule.get("benefitText")))
                .filter(value -> !value.isBlank())
                .findFirst()
                .orElse("");
        if (configuredDescription.isBlank()) {
            configuredDescription = configuredRules(merchantId, cardLevel).stream()
                    .map(rule -> string(rule.get("consumeDesc")))
                    .filter(value -> !value.isBlank())
                    .findFirst()
                    .orElse("");
        }
        if (!configuredDescription.isBlank()) return configuredDescription;
        return benefits.stream().filter(benefit -> matchesCardLevel(string(benefit.get("cardLevel")), cardLevel))
                .map(benefit -> string(benefit.get("benefitText"))).findFirst().orElse("以当前商户到店消费规则为准");
                .map(benefit -> string(benefit.get("benefitText"))).findFirst().orElse(DEFAULT_BENEFIT_TEXT);
    }
    /** 后台没给该人才等级填权益描述时的兜底:页面上至少要显示这条规则自己的权益文案。 */
    private static final String DEFAULT_BENEFIT_TEXT = "以当前商户到店消费规则为准";
    /**
     * 「当前权益」文案:优先用后台按人才等级配置的权益描述;没配置时用匹配到的规则文案
     * (如「到店消费8折」)顶上,别给商户看「以当前商户到店消费规则为准」这种没有信息量的兜底话术。
     */
    private String benefitTextForLevel(Long merchantId, String cardLevel, String sourceAreaType,
                                       List<Map<String, Object>> matchedRules) {
        String configured = benefitText(merchantId, cardLevel, sourceAreaType);
        if (!DEFAULT_BENEFIT_TEXT.equals(configured)) return configured;
        for (Map<String, Object> rule : matchedRules) {
            String text = ruleBenefitText(rule);
            if (!text.isBlank()) return text;
        }
        return configured;
    }
    /** 规则自身的权益文案:结构化配置里已经算好了;旧的门店规则表只有名字和折扣,按折扣拼一句。 */
    private String ruleBenefitText(Map<String, Object> rule) {
        String configured = string(rule.get("benefitText"));
        if (!configured.isBlank()) return configured;
        String name = string(rule.get("name"));
        if (!name.isBlank() && (name.contains("折") || name.contains("减"))) return name;
        BigDecimal rate = decimal(rule.get("discountRate"));
        if (rate.signum() > 0) return "到店消费" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折";
        BigDecimal reduction = decimal(rule.get("fixedReduction"));
        if (reduction.signum() > 0) return "到店消费立减" + reduction.stripTrailingZeros().toPlainString() + "元";
        return name;
    }
    private String consumeDescFromRights(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> configs = rightsConfigs(merchantId).stream()
                .filter(right -> booleanValue(right.get("enabled")))
                .toList();
        String description = firstRightsText(configs, cardLevel, sourceAreaType, "consumeDesc");
        if (!description.isBlank()) return description;
        return firstRightsText(configs, cardLevel, sourceAreaType, "detail");
    }
    private String firstRightsText(List<Map<String, Object>> configs, String cardLevel, String sourceAreaType, String field) {
        for (Map<String, Object> right : configs) {
            if (!matchesConfiguredLevel(string(right.get("level")), cardLevel, sourceAreaType)) continue;
            String text = string(right.get(field)).trim();
            if (!text.isBlank()) return text;
        }
        if (!sourceAreaKnown(sourceAreaType)) {
            for (Map<String, Object> right : configs) {
                if (!matchesCardLevel(string(right.get("level")), cardLevel)) continue;
                String text = string(right.get(field)).trim();
                if (!text.isBlank()) return text;
            }
        }
        return "";
    }
    private boolean sourceAreaKnown(String sourceAreaType) {
        return localArea(sourceAreaType) || outsideArea(sourceAreaType);
    }
    private boolean localArea(String sourceAreaType) {
        String area = string(sourceAreaType).trim().toUpperCase();
        return area.contains("LOCAL") || area.contains("本地");
    }
    private boolean outsideArea(String sourceAreaType) {
        String area = string(sourceAreaType).trim().toUpperCase();
        return area.contains("OUTSIDE") || area.contains("外地");
    }
    /** Build the displayed benefit from the same structured fields used for pricing. */
    private String configuredBenefitText(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) {
            BigDecimal rate = configuredDiscountRate(right);
            return rate.signum() > 0 ? "到店消费" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折" : "";
        }
        List<String> parts = new ArrayList<>();
        String consumeType = string(right.get("consumeType"));
        if ("优惠".equals(consumeType)) {
@@ -357,7 +558,24 @@
    }
    String benefitTextForMerchant(Long merchantId, String cardLevel) {
        return benefitText(merchantId, cardLevel);
        return benefitText(merchantId, cardLevel, "");
    }
    String benefitTextForMerchant(Long merchantId, String cardLevel, String sourceAreaType) {
        return benefitText(merchantId, cardLevel, sourceAreaType);
    }
    boolean matchesConfiguredLevel(String configured, String actual, String sourceAreaType) {
        if (!"EXCELLENT".equals(normalizedCardLevel(actual))) return matchesCardLevel(configured, actual);
        String configuredUpper = configured.trim().toUpperCase();
        String areaUpper = string(sourceAreaType).trim().toUpperCase();
        if (configuredUpper.contains("本地") || configuredUpper.contains("LOCAL")) {
            return areaUpper.contains("LOCAL") || areaUpper.contains("本地");
        }
        if (configuredUpper.contains("外地") || configuredUpper.contains("OUTSIDE")) {
            return areaUpper.contains("OUTSIDE") || areaUpper.contains("外地");
        }
        return matchesCardLevel(configured, actual);
    }
    private boolean matchesCardLevel(String configured, String actual) {
@@ -395,6 +613,12 @@
        return name.length() == 1 ? name + "*" : name.substring(0, 1) + "*".repeat(name.length() - 1);
    }
    private String displayName(String name, Long gender) {
        if (name.isBlank()) return "持卡人";
        if (name.endsWith("先生") || name.endsWith("女士")) return name;
        return name + (gender != null && gender == 2 ? "女士" : "先生");
    }
    private String maskMobile(String mobile) {
        if (mobile.length() < 7) return "";
        return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);