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; public ManualConsumptionService(JdbcTemplate jdbc) { this.jdbc = jdbc; } @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"))); result.put("benefitText", benefitText(principal.merchantId(), string(source.get("cardLevel")))); result.put("rules", rules(principal.merchantId(), string(source.get("cardLevel")))); 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")), 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")), 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()); 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); 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, 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, 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(), 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"), 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 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); } private void ensurePending(Map source) { if (number(source.get("attemptStatus")) != ATTEMPT_PENDING) { throw new BusinessException(ErrorCode.CONFLICT, "该人才码已处理,请重新扫码"); } } private List> rules(Long merchantId, String cardLevel) { List> configured = configuredRules(merchantId, cardLevel); if (!configured.isEmpty()) 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(); } private List> configuredRules(Long merchantId, String cardLevel) { List> 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"); if (raw == null || string(raw).isBlank()) return List.of(); 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(string(raw), 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 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(); } private 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.get("discount"))); result.put("subsidyEnabled", booleanValue(right.get("governmentSubsidy")) ? 1 : 0); result.put("consumeDesc", right.get("consumeDesc")); result.put("benefitText", configuredBenefitText(right)); return result; } private Map rule(Long merchantId, String cardLevel, Long ruleId) { List> configured = configuredRules(merchantId, cardLevel); 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) { 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 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; List> 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 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 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(Object value) { try { 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 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); } private 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 = originalAmount - payAmount; 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 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) { 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); String configuredDescription = configuredRules(merchantId, cardLevel).stream() .map(rule -> string(rule.get("consumeDesc"))) .filter(value -> !value.isBlank()) .findFirst() .orElse(""); if (configuredDescription.isBlank()) { configuredDescription = configuredRules(merchantId, cardLevel).stream() .map(rule -> string(rule.get("benefitText"))) .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("以当前商户到店消费规则为准"); } /** Build the displayed benefit from the same structured fields used for pricing. */ private String configuredBenefitText(Map right) { 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); } 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)); } }