shinnosuke
2026-08-28 3eb95d53e1037cc5e10b1da3a62b0ff4d5fc9536
fix: align merchant consumption benefit matching
6个文件已修改
231 ■■■■■ 已修改文件
rencaika_merchant/pages/consume-input/consume-input.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/ManualConsumptionServiceTests.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/consume-input/consume-input.uvue
@@ -38,11 +38,13 @@
    let cancelDetailRequest: (() => void) | null = null
    let cancelQuoteRequest: (() => void) | null = null
    let cancelCreateRequest: (() => void) | null = null
    let quoteTimer = 0
    const formatMoney = (amount: number): string => (amount / 100).toFixed(2)
    const amountValue = (): number => Number(amountText.value)
    const validAmount = (): boolean => /^\d+(\.\d{1,2})?$/.test(amountText.value) && amountValue() > 0
    const canSubmit = computed((): boolean => validAmount() && selectedRuleId.value.length > 0 && quote.value.payAmount >= 0 && quote.value.originalAmount > 0 && !submitting.value)
    const quoteMatchesAmount = (): boolean => quote.value.originalAmount == Math.round(amountValue() * 100)
    const canSubmit = computed((): boolean => validAmount() && selectedRuleId.value.length > 0 && quote.value.payAmount >= 0 && quoteMatchesAmount() && !submitting.value)
    const selectedRuleName = (): string => {
        for (let index = 0; index < rules.value.length; index++) {
            if (rules.value[index].id == selectedRuleId.value) return rules.value[index].name
@@ -86,6 +88,7 @@
    }
    const quoteCurrentAmount = () => {
        if (quoteTimer != 0) { clearTimeout(quoteTimer); quoteTimer = 0 }
        if (cancelQuoteRequest != null) { cancelQuoteRequest(); cancelQuoteRequest = null }
        if (!validAmount()) { if (amountText.value.length > 0) amountError.value = '请输入正确的消费金额'; resetQuote(); return }
        if (selectedRuleId.value.length == 0) { resetQuote(); return }
@@ -101,10 +104,15 @@
    }
    const onAmountInput = (event: any) => {
        if (quoteTimer != 0) { clearTimeout(quoteTimer); quoteTimer = 0 }
        if (cancelQuoteRequest != null) { cancelQuoteRequest(); cancelQuoteRequest = null }
        const input = event != null && event.detail != null && event.detail.value != null ? '' + event.detail.value : ''
        amountText.value = input.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1')
        amountError.value = ''
        resetQuote()
        if (validAmount() && selectedRuleId.value.length > 0) {
            quoteTimer = setTimeout(() => { quoteTimer = 0; quoteCurrentAmount() }, 350)
        }
    }
    const selectRule = (ruleId: string) => {
@@ -145,7 +153,7 @@
        loadDetail()
    })
    onUnload(() => cancelRequests())
    onUnload(() => { if (quoteTimer != 0) clearTimeout(quoteTimer); cancelRequests() })
</script>
<style>
talent-card-admin/src/views/MerchantCreateView.vue
@@ -1532,11 +1532,12 @@
  }
  rightsConfigs.splice(0, rightsConfigs.length, ...defaultMerchantRights().map((item) => {
    const saved = Array.isArray(savedRights) ? savedRights.find((entry) => entry?.level === item.level) : undefined
    return {
    const merged = {
      ...item,
      ...(saved || {}),
      consumeEnabled: item.consumeEnabled
    }
    if (isParkingMerchant.value && merged.parkingType === 'paid') merged.consumeEnabled = true
    return merged
  }))
  syncCarWashJumpMeta()
}
@@ -1561,6 +1562,9 @@
    const payload = { ...item }
    const supportsItemStoreConsumption = supportsStoreConsumption.value
      || (isParkingMerchant.value && item.parkingType === 'paid')
    if (isParkingMerchant.value && item.parkingType === 'paid') {
      payload.consumeEnabled = true
    }
    if (!supportsItemStoreConsumption) {
      payload.storeConsume = false
      payload.discount = ''
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java
@@ -41,8 +41,10 @@
        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", benefitText(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType"))));
        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 +54,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,7 +70,7 @@
            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());
        Long dynamicCodeId = number(source.get("dynamicCodeId"));
@@ -104,7 +106,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.gender
                       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
@@ -131,9 +133,9 @@
        }
    }
    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
@@ -150,7 +152,18 @@
        }).toList();
    }
    private List<Map<String, Object>> configuredRules(Long merchantId, String cardLevel) {
    boolean hasApplicableRule(Long merchantId, String cardLevel, String sourceAreaType) {
        return !rules(merchantId, cardLevel, sourceAreaType).isEmpty();
    }
    private boolean hasStructuredRightsConfig(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_rights_config from merchant where id = ? and deleted = 0 limit 1", merchantId);
        if (merchants.isEmpty()) return false;
        Object raw = merchants.get(0).get("merchantRightsConfig");
        return raw != null && !string(raw).isBlank();
    }
    private List<Map<String, Object>> configuredRules(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> merchants = rows("select 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");
@@ -173,26 +186,38 @@
        } catch (Exception ignored) {
            return List.of();
        }
        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.stream()
                .filter(right -> booleanValue(right.get("enabled"))
                        && configuredStoreConsumptionEnabled(right)
                        && matchesConfiguredLevel(string(right.get("level")), cardLevel, sourceAreaType))
                .map(this::configuredRule)
                .filter(rule -> decimal(rule.get("discountRate")).signum() > 0 || decimal(rule.get("fixedReduction")).signum() > 0)
                .toList();
    }
    private Map<String, Object> configuredRule(Map<String, Object> right) {
    boolean configuredStoreConsumptionEnabled(Map<String, Object> right) {
        if (!booleanValue(right.get("storeConsume"))) return false;
        return booleanValue(right.get("consumeEnabled")) || "paid".equalsIgnoreCase(string(right.get("parkingType")));
    }
    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));
        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
@@ -253,13 +278,31 @@
        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 = "折扣".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 (!"优惠".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) {
        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) {
@@ -269,14 +312,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);
@@ -294,6 +337,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();
@@ -325,25 +373,25 @@
        return result;
    }
    private String benefitText(Long merchantId, String cardLevel) {
    private String benefitText(Long merchantId, String cardLevel, String sourceAreaType) {
        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()
        String configuredDescription = configuredRules(merchantId, cardLevel, sourceAreaType).stream()
                .map(rule -> string(rule.get("consumeDesc")))
                .filter(value -> !value.isBlank())
                .findFirst()
                .orElse("");
        if (configuredDescription.isBlank()) {
            configuredDescription = configuredRules(merchantId, cardLevel).stream()
            configuredDescription = configuredRules(merchantId, cardLevel, sourceAreaType).stream()
                    .map(rule -> string(rule.get("merchantDetail")))
                    .filter(value -> !value.isBlank())
                    .findFirst()
                    .orElse("");
        }
        if (configuredDescription.isBlank()) {
            configuredDescription = configuredRules(merchantId, cardLevel).stream()
            configuredDescription = configuredRules(merchantId, cardLevel, sourceAreaType).stream()
                    .map(rule -> string(rule.get("benefitText")))
                    .filter(value -> !value.isBlank())
                    .findFirst()
@@ -369,7 +417,20 @@
    }
    String benefitTextForMerchant(Long merchantId, String cardLevel) {
        return benefitText(merchantId, cardLevel);
        return benefitText(merchantId, cardLevel, "");
    }
    boolean matchesConfiguredLevel(String configured, String actual, String sourceAreaType) {
        if (!"EXCELLENT".equals(normalizedCardLevel(actual))) return matchesCardLevel(configured, actual);
        String configuredUpper = configured.trim().toUpperCase();
        String areaUpper = 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) {
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -22,9 +22,11 @@
    private static final String FREE_STAY_CODE = "FREE_STAY_CODE";
    private final JdbcTemplate jdbc;
    private final ManualConsumptionService manualConsumptionService;
    public MerchantVerificationService(JdbcTemplate jdbc) {
    public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService) {
        this.jdbc = jdbc;
        this.manualConsumptionService = manualConsumptionService;
    }
    /**
@@ -85,6 +87,9 @@
        Long expectedMerchantId = number(code.get("expectedMerchantId"));
        if (expectedMerchantId != null && !expectedMerchantId.equals(principal.merchantId())) {
            throw new BusinessException(ErrorCode.CONFLICT, "该人才码不可在当前商户核销");
        }
        if (!manualConsumptionService.hasApplicableRule(principal.merchantId(), value(code.get("cardLevel"), ""), value(code.get("sourceAreaType"), ""))) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前商户未配置适用于该人才卡的到店消费权益");
        }
        String verificationId = createAttempt(principal, TALENT_CODE, number(code.get("dynamicCodeId")), null, null, code.get("expiresAt"));
        return scanResult(verificationId, TALENT_CODE, "人才码有效,请录入消费金额并发送待支付订单", code.get("talentName"),
@@ -241,7 +246,7 @@
        List<Map<String, Object>> codes = rows("""
                select d.id dynamic_code_id, d.expected_merchant_id, d.expires_at,
                       c.id card_id, c.card_level, c.status card_status, c.valid_end,
                       t.id talent_id, t.name talent_name, t.mobile talent_mobile, t.status talent_status
                       t.id talent_id, t.name talent_name, t.mobile talent_mobile, t.status talent_status, t.source_area_type
                from talent_dynamic_code d
                join talent_card c on c.id = d.card_id and c.deleted = 0
                join talent_user t on t.id = d.talent_id and t.deleted = 0
talent-card-server/src/test/java/com/talentcard/server/business/merchant/ManualConsumptionServiceTests.java
@@ -2,6 +2,10 @@
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -27,6 +31,27 @@
                    used_at timestamp
                )
                """);
        jdbc.execute("""
                create table merchant (
                    id bigint primary key,
                    merchant_rights_config varchar(4000),
                    deleted tinyint not null default 0
                )
                """);
        jdbc.execute("""
                create table merchant_store_consume_rule (
                    id bigint primary key,
                    merchant_id bigint not null,
                    card_level varchar(64) not null,
                    rule_name varchar(128) not null,
                    discount_rate decimal(8, 4) not null,
                    subsidy_enabled tinyint not null,
                    status tinyint not null,
                    valid_start date,
                    valid_end date,
                    sort_no int not null default 0
                )
                """);
        service = new ManualConsumptionService(jdbc);
        principal = new MerchantPrincipal(9L, 88L, 7L, "operator");
    }
@@ -48,4 +73,65 @@
        assertThat(service.consumeScannedTalentCode(principal, 3L)).isZero();
        assertThat(jdbc.queryForObject("select merchant_id from talent_dynamic_code where id = 3", Long.class)).isEqualTo(66L);
    }
    @Test
    void treatsLegacyPaidParkingConfigAsStoreConsumption() {
        Map<String, Object> right = new LinkedHashMap<>();
        right.put("storeConsume", true);
        right.put("consumeEnabled", false);
        right.put("parkingType", "paid");
        assertThat(service.configuredStoreConsumptionEnabled(right)).isTrue();
        right.put("parkingType", "free");
        assertThat(service.configuredStoreConsumptionEnabled(right)).isFalse();
    }
    @Test
    void calculatesConfiguredPercentageAndFixedReductionPrices() {
        Map<String, Object> dentalRight = configuredRight("卓越人才卡", "");
        dentalRight.put("consumeType", "折扣");
        dentalRight.put("consumeDiscount", "7");
        dentalRight.put("consumeDiscountSubsidy", true);
        Map<String, Object> dentalAmounts = service.quote(service.configuredRule(dentalRight), null, new BigDecimal("100.00"));
        assertThat(dentalAmounts).containsEntry("originalAmount", 10000L).containsEntry("payAmount", 7000L)
                .containsEntry("discountAmount", 3000L).containsEntry("subsidyAmount", 3000L);
        Map<String, Object> hotelRight = configuredRight("卓越人才卡(外地)", "");
        hotelRight.put("consumeType", "优惠");
        hotelRight.put("consumeCoupon", "80");
        hotelRight.put("consumeCouponSubsidy", true);
        Map<String, Object> hotelAmounts = service.quote(service.configuredRule(hotelRight), null, new BigDecimal("150.00"));
        assertThat(hotelAmounts).containsEntry("originalAmount", 15000L).containsEntry("payAmount", 7000L).containsEntry("discountAmount", 8000L);
        Map<String, Object> fullyReducedAmounts = service.quote(service.configuredRule(hotelRight), null, new BigDecimal("50.00"));
        assertThat(fullyReducedAmounts).containsEntry("payAmount", 0L).containsEntry("discountAmount", 5000L);
    }
    @Test
    void structuredRightsDoNotFallBackToLegacySeedRules() {
        jdbc.update("insert into merchant(id, merchant_rights_config) values (?, ?)", 88L,
                "[{\"level\":\"卓越人才卡\",\"enabled\":true,\"storeConsume\":false,\"consumeEnabled\":false}]");
        jdbc.update("""
                insert into merchant_store_consume_rule(id, merchant_id, card_level, rule_name, discount_rate, subsidy_enabled, status)
                values (1, 88, 'EXCELLENT', '历史默认9折', 0.9000, 1, 1)
                """);
        assertThat(service.hasApplicableRule(88L, "卓越人才卡", "OUTSIDE")).isFalse();
    }
    @Test
    void matchesExcellentHotelConfigByTalentSourceArea() {
        assertThat(service.matchesConfiguredLevel("卓越人才卡(外地)", "卓越人才卡", "OUTSIDE")).isTrue();
        assertThat(service.matchesConfiguredLevel("卓越人才卡(本地)", "卓越人才卡", "OUTSIDE")).isFalse();
        assertThat(service.matchesConfiguredLevel("卓越人才卡(本地)", "卓越人才卡", "LOCAL")).isTrue();
    }
    private Map<String, Object> configuredRight(String level, String discount) {
        Map<String, Object> right = new LinkedHashMap<>();
        right.put("level", level);
        right.put("tag", "到店消费优惠");
        right.put("discount", discount);
        right.put("governmentSubsidy", true);
        return right;
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java
@@ -115,6 +115,15 @@
    }
    @Test
    void talentCodeRejectsMerchantWithoutApplicableStoreConsumptionRule() {
        jdbc.update("delete from merchant_store_consume_rule where merchant_id = ?", merchantId);
        assertThatThrownBy(() -> verificationService.scan(principal, new VerificationScanRequest(token)))
                .isInstanceOf(com.talentcard.server.common.BusinessException.class)
                .hasMessageContaining("未配置适用于该人才卡的到店消费权益");
    }
    @Test
    void talentCodeStoreConsumptionUsesConfiguredCouponAfterTalentDiscount() {
        Map<String, Object> scanned = verificationService.scan(principal, new VerificationScanRequest(token));
        String verificationId = String.valueOf(scanned.get("verificationId"));