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<String, Object> detail(MerchantPrincipal principal, String verificationId) {
|
Map<String, Object> source = source(principal, verificationId, false);
|
ensurePending(source);
|
Map<String, Object> 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<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;
|
}
|
|
@Transactional(readOnly = true)
|
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")), 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());
|
}
|
|
@Transactional
|
public Map<String, Object> create(MerchantPrincipal principal, ManualConsumptionCreateRequest request, String idempotencyKey) {
|
if (idempotencyKey == null || idempotencyKey.isBlank()) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
|
}
|
Map<String, Object> 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<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 = consumeScannedTalentCode(principal, dynamicCodeId);
|
if (codeUpdated == 0) {
|
throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效");
|
}
|
|
Map<String, Object> 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<String, Object> source(MerchantPrincipal principal, String verificationId, boolean forUpdate) {
|
String lock = forUpdate ? " for update" : "";
|
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.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<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, 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
|
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<String, Object> 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元」(走和到店消费页同一套解析,结构化配置和旧的门店
|
* 规则表都能取到)。
|
*
|
* <p>以前只认折扣,酒店把到店消费配成「优惠(立减)」时整段是空的——而酒店最常见的配法恰恰
|
* 是立减(见商家权益里的到店消费二选一),页面上就什么都不显示,看起来像功能没做。
|
*
|
* <p>都没有配时返回空串,调用方据此不显示这段说明。
|
*/
|
String freeStayExceedDiscountText(Long merchantId, String cardLevel, String sourceAreaType) {
|
for (Map<String, Object> rule : rules(merchantId, cardLevel, sourceAreaType)) {
|
String text = exceedDiscountText(rule);
|
if (!text.isBlank()) return text;
|
}
|
return "";
|
}
|
|
/** 单条规则的「超过免费天数」文案:折扣优先,没配折扣(门店选的是立减)时用立减。 */
|
String exceedDiscountText(Map<String, Object> 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<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();
|
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) {
|
for (Object item : list) 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);
|
}
|
} else {
|
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 new ArrayList<>();
|
}
|
return configs;
|
}
|
|
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));
|
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, 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
|
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<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, 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<String,Object> result = new LinkedHashMap<>(c); result.put("couponAmount", decimal(c.get("couponAmount")).movePointRight(2).longValue()); return result;
|
}).toList();
|
}
|
|
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, 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<String,Object> 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<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));
|
}
|
|
private long configRuleId(String level) {
|
return switch (level) { case "EXCELLENT" -> -1L; case "OUTSTANDING" -> -2L; case "ELITE" -> -3L; default -> -9L; };
|
}
|
|
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) {
|
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<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 = booleanValue(rule.get("subsidyEnabled")) ? discountAmount : 0;
|
Map<String, Object> 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<String, Object> 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<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();
|
}
|
private Map<String, Object> merchant(MerchantPrincipal principal) {
|
List<Map<String, Object>> 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<String, Object> createdOrder(Long orderId) {
|
List<Map<String, Object>> 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<String, Object> order = orders.get(0);
|
Map<String, Object> 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<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);
|
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<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)) {
|
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<Map<String, Object>> rows(String sql, Object... args) {
|
return DatabaseRows.normalize(jdbc.queryForList(sql, args));
|
}
|
}
|