package com.talentcard.server.business.merchant; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.ArrayList; import java.util.List; import java.util.Map; import com.alibaba.fastjson.JSON; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.merchant.auth.MerchantPrincipal; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class ManualConsumptionService { private static final String TALENT_CODE = "TALENT_CODE"; private static final int ATTEMPT_PENDING = 0; private static final int ATTEMPT_ORDER_CREATED = 3; private final JdbcTemplate jdbc; private final MerchantPaymentReadinessService paymentReadinessService; public ManualConsumptionService(JdbcTemplate jdbc, MerchantPaymentReadinessService paymentReadinessService) { this.jdbc = jdbc; this.paymentReadinessService = paymentReadinessService; } @Transactional(readOnly = true) public Map detail(MerchantPrincipal principal, String verificationId) { Map source = source(principal, verificationId, false); ensurePending(source); Map result = new LinkedHashMap<>(); result.put("verificationId", verificationId); 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"))); List> 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; } @Transactional(readOnly = true) public Map quote(MerchantPrincipal principal, ManualConsumptionCreateRequest request) { Map source = source(principal, request.verificationId(), false); ensurePending(source); Map rule = rule(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")), request.ruleId()); Map coupon = usableCoupon(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel")), request.couponId(), discountedAmount(rule, request.amount())); return quote(rule, coupon, request.amount()); } @Transactional public Map create(MerchantPrincipal principal, ManualConsumptionCreateRequest request, String idempotencyKey) { if (idempotencyKey == null || idempotencyKey.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); } Map source = source(principal, request.verificationId(), true); Long existingOrderId = number(source.get("manualOrderId")); if (number(source.get("attemptStatus")) == ATTEMPT_ORDER_CREATED && existingOrderId != null) { return createdOrder(existingOrderId); } ensurePending(source); Map rule = rule(principal.merchantId(), string(source.get("cardLevel")), string(source.get("sourceAreaType")), request.ruleId()); Map coupon = usableCoupon(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel")), request.couponId(), discountedAmount(rule, request.amount())); Map 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 = consumeScannedTalentCode(principal, dynamicCodeId); if (codeUpdated == 0) { throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效"); } Map merchant = merchant(principal); String orderNo = BusinessIdGenerator.next("MC"); jdbc.update(""" 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")), 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(""" update merchant_verification_attempt set status = ?, manual_order_id = ? where id = ? and status = ? """, ATTEMPT_ORDER_CREATED, orderId, number(source.get("attemptId")), ATTEMPT_PENDING); return createdOrder(orderId); } private Map source(MerchantPrincipal principal, String verificationId, boolean forUpdate) { String lock = forUpdate ? " for update" : ""; List> 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.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 join talent_user t on t.id = d.talent_id and t.deleted = 0 where a.verification_id = ? and a.credential_type = ? and a.merchant_id = ? and a.store_id = ? and a.operator_id = ? limit 1 """ + lock, verificationId.trim(), TALENT_CODE, principal.merchantId(), principal.storeId(), principal.operatorId()); if (sources.isEmpty()) { throw new BusinessException(ErrorCode.NOT_FOUND, "人才码核销凭证不存在或无权操作"); } 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 source) { if (number(source.get("attemptStatus")) != ATTEMPT_PENDING) { throw new BusinessException(ErrorCode.CONFLICT, "该人才码已处理,请重新扫码"); } } private List> rules(Long merchantId, String cardLevel, String sourceAreaType) { List> 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 where merchant_id = ? and status = 1 and (valid_start is null or valid_start <= current_date) and (valid_end is null or valid_end >= current_date) order by sort_no desc, id desc """, merchantId).stream().filter(rule -> matchesCardLevel(string(rule.get("cardLevel")), cardLevel)).map(rule -> { Map result = new LinkedHashMap<>(); result.put("id", String.valueOf(rule.get("id"))); result.put("name", rule.get("ruleName")); result.put("discountRate", decimal(rule.get("discountRate"))); result.put("subsidyEnabled", number(rule.get("subsidyEnabled")) == 1); return result; }).toList(); } /** * 免费入住券要显示「超过免费天数按门店配置的到店消费规则计算」:门店给该人才卡配了折扣就是 * 「门市价X折」,配的是立减就是「立减X元」(走和到店消费页同一套解析,结构化配置和旧的门店 * 规则表都能取到)。 * *

以前只认折扣,酒店把到店消费配成「优惠(立减)」时整段是空的——而酒店最常见的配法恰恰 * 是立减(见商家权益里的到店消费二选一),页面上就什么都不显示,看起来像功能没做。 * *

都没有配时返回空串,调用方据此不显示这段说明。 */ String freeStayExceedDiscountText(Long merchantId, String cardLevel, String sourceAreaType) { for (Map rule : rules(merchantId, cardLevel, sourceAreaType)) { String text = exceedDiscountText(rule); if (!text.isBlank()) return text; } return ""; } /** 单条规则的「超过免费天数」文案:折扣优先,没配折扣(门店选的是立减)时用立减。 */ String exceedDiscountText(Map rule) { BigDecimal rate = rule.get("discountRate") == null ? BigDecimal.ZERO : decimal(rule.get("discountRate")); if (rate.signum() > 0) return "门市价" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折"; BigDecimal reduction = rule.get("fixedReduction") == null ? BigDecimal.ZERO : decimal(rule.get("fixedReduction")); if (reduction.signum() > 0) return "立减" + reduction.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> rightsConfigs(Long merchantId) { List> 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(); List> 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> 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 parkingRight : parkingRights) { String level = string(parkingRight.get("level")); Map merged = configs.stream() .filter(right -> matchesCardLevel(string(right.get("level")), level)) .findFirst().map(LinkedHashMap::new).orElseGet(LinkedHashMap::new); for (Map.Entry 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> 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> configs = new ArrayList<>(); try { if (raw instanceof List list) { for (Object item : list) if (item instanceof Map map) { Map value = new LinkedHashMap<>(); for (Map.Entry entry : map.entrySet()) value.put(String.valueOf(entry.getKey()), entry.getValue()); configs.add(value); } } else { for (Object item : JSON.parseArray(json, Map.class)) if (item instanceof Map map) { Map value = new LinkedHashMap<>(); for (Map.Entry entry : map.entrySet()) value.put(String.valueOf(entry.getKey()), entry.getValue()); configs.add(value); } } } catch (Exception ignored) { return new ArrayList<>(); } return configs; } private List> 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 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 configuredRule(Map right) { String level = normalizedCardLevel(string(right.get("level"))); Map 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)); 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 rule(Long merchantId, String cardLevel, String sourceAreaType, Long ruleId) { List> configured = configuredRules(merchantId, cardLevel, sourceAreaType); for (Map candidate : configured) if (String.valueOf(candidate.get("id")).equals(String.valueOf(ruleId))) return candidate; List> found = rows(""" select id, card_level, rule_name, discount_rate, subsidy_enabled from merchant_store_consume_rule where id = ? and merchant_id = ? and status = 1 and (valid_start is null or valid_start <= current_date) and (valid_end is null or valid_end >= current_date) limit 1 """, ruleId, merchantId); if (found.isEmpty() || !matchesCardLevel(string(found.get(0).get("cardLevel")), cardLevel)) throw new BusinessException(ErrorCode.CONFLICT, "当前人才卡不可使用所选优惠方式"); return found.get(0); } private List> 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, 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) and not exists ( select 1 from benefit_order o where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0 and (o.expires_at is null or o.expires_at > current_timestamp) ) order by c.coupon_amount desc, c.id desc """, merchantId, talentId).stream().filter(c -> supportsCard(string(c.get("talentLevels")), cardLevel)).map(c -> { Map result = new LinkedHashMap<>(c); result.put("couponAmount", decimal(c.get("couponAmount")).movePointRight(2).longValue()); return result; }).toList(); } private Map 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> found = rows(""" 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) and not exists ( select 1 from benefit_order o where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0 and (o.expires_at is null or o.expires_at > current_timestamp) ) """, couponId, merchantId, talentId); if (found.isEmpty() || !supportsCard(string(found.get(0).get("talentLevels")), cardLevel)) throw new BusinessException(ErrorCode.CONFLICT, "优惠券当前不可用,请重新选择"); Map coupon = found.get(0); String condition = string(coupon.get("conditionType")); if (!"NONE".equalsIgnoreCase(condition) && coupon.get("conditionAmount") != null && amount.compareTo(decimal(coupon.get("conditionAmount"))) < 0) throw new BusinessException(ErrorCode.CONFLICT, "消费金额未达到优惠券使用条件"); return coupon; } private boolean storeConsumptionCouponsEnabled(Long merchantId) { List> 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)); } private long configRuleId(String level) { return switch (level) { case "EXCELLENT" -> -1L; case "OUTSTANDING" -> -2L; case "ELITE" -> -3L; default -> -9L; }; } private BigDecimal configuredDiscountRate(Map 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 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 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) { if (value instanceof Boolean bool) return bool; if (value instanceof Number number) return number.intValue() != 0; String text = string(value).toLowerCase(); return "true".equals(text) || "1".equals(text) || "yes".equals(text); } Map quote(Map rule, Map 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 = booleanValue(rule.get("subsidyEnabled")) ? discountAmount : 0; Map result = new LinkedHashMap<>(); result.put("originalAmount", originalAmount); result.put("discountAmount", discountAmount); result.put("payAmount", payAmount); result.put("basePayAmount", basePayAmount); result.put("couponAmount", couponAmount); result.put("subsidyAmount", subsidyAmount); result.put("ruleName", rule.get("ruleName")); return result; } private BigDecimal discountedAmount(Map rule, BigDecimal inputAmount) { long originalAmount = inputAmount.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValueExact(); return BigDecimal.valueOf(discountedAmountCents(rule, originalAmount), 2); } private long discountedAmountCents(Map 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(); } private Map merchant(MerchantPrincipal principal) { List> merchants = rows(""" select merchant_name, address, contact_mobile, longitude, latitude from merchant where id = ? and status = 1 and deleted = 0 limit 1 """, principal.merchantId()); if (merchants.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前商户不可创建到店消费订单"); return merchants.get(0); } private Map createdOrder(Long orderId) { List> orders = rows(""" select order_no, order_amount, pay_amount, subsidy_amount, price_version, expires_at from benefit_order where id = ? and deleted = 0 limit 1 """, orderId); if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "到店消费订单不存在"); Map order = orders.get(0); Map result = new LinkedHashMap<>(); result.put("orderNo", order.get("orderNo")); result.put("originalAmount", order.get("orderAmount")); result.put("payAmount", order.get("payAmount")); result.put("subsidyAmount", order.get("subsidyAmount")); result.put("priceVersion", order.get("priceVersion")); result.put("expiresAt", order.get("expiresAt")); return result; } private String benefitText(Long merchantId, String cardLevel, String sourceAreaType) { List> 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> 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> 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); return benefits.stream().filter(benefit -> matchesCardLevel(string(benefit.get("cardLevel")), cardLevel)) .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> matchedRules) { String configured = benefitText(merchantId, cardLevel, sourceAreaType); if (!DEFAULT_BENEFIT_TEXT.equals(configured)) return configured; for (Map rule : matchedRules) { String text = ruleBenefitText(rule); if (!text.isBlank()) return text; } return configured; } /** 规则自身的权益文案:结构化配置里已经算好了;旧的门店规则表只有名字和折扣,按折扣拼一句。 */ private String ruleBenefitText(Map 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> 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> configs, String cardLevel, String sourceAreaType, String field) { for (Map 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 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 right) { if (!string(right.get("parkingType")).isBlank()) { BigDecimal rate = configuredDiscountRate(right); return rate.signum() > 0 ? "到店消费" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折" : ""; } List parts = new ArrayList<>(); String consumeType = string(right.get("consumeType")); if ("优惠".equals(consumeType)) { String amount = string(right.get("consumeCoupon")); if (!amount.isBlank()) parts.add("到店消费立减" + amount + "元"); } else { String discount = "折扣".equals(consumeType) ? string(right.get("consumeDiscount")) : string(right.get("discount")); if (!discount.isBlank()) parts.add("到店消费" + discount + "折"); } return String.join(",", parts); } String benefitTextForMerchant(Long merchantId, String 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) { return normalizedCardLevel(configured).equals(normalizedCardLevel(actual)); } private String normalizedCardLevel(String value) { String upper = string(value).trim().toUpperCase(); if (upper.contains("卓越") || upper.equals("EXCELLENT")) return "EXCELLENT"; if (upper.contains("杰出") || upper.equals("OUTSTANDING")) return "OUTSTANDING"; if (upper.contains("菁英") || upper.equals("ELITE")) return "ELITE"; return upper; } private boolean expired(Object value) { return value instanceof LocalDateTime time && !time.isAfter(LocalDateTime.now()); } private Long number(Object value) { return value instanceof Number number ? number.longValue() : null; } private BigDecimal decimal(Object value) { if (value instanceof BigDecimal decimal) return decimal; if (value instanceof Number number) return BigDecimal.valueOf(number.doubleValue()); return new BigDecimal(string(value)); } private String string(Object value) { return value == null ? "" : String.valueOf(value); } private String maskName(String name) { if (name.isBlank()) return "持卡人"; 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); } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } }