package com.talentcard.server.business.merchant;
|
|
import java.sql.Timestamp;
|
import java.time.LocalDate;
|
import java.time.LocalDateTime;
|
import java.time.Duration;
|
import java.math.BigDecimal;
|
import java.util.ArrayList;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
|
import com.talentcard.server.business.BusinessIdGenerator;
|
import com.talentcard.server.business.DatabaseRows;
|
import com.talentcard.server.business.PageResult;
|
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
|
import com.talentcard.server.business.order.OrderFundBoundaryService;
|
import com.talentcard.server.business.payment.PaymentFundConfirmationService;
|
import com.talentcard.server.business.payment.HuifuRefundService;
|
import com.talentcard.server.business.portal.ProductPriceCalculator;
|
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;
|
import tools.jackson.core.type.TypeReference;
|
import tools.jackson.databind.ObjectMapper;
|
|
@Service
|
public class MerchantWorkService {
|
private static final long REVOCATION_HOURS = 3;
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
private static final TypeReference<List<Map<String, Object>>> RIGHTS_TYPE = new TypeReference<>() {};
|
|
private final JdbcTemplate jdbcTemplate;
|
private final ManualConsumptionService manualConsumptionService;
|
private final OrderFundBoundaryService orderFundBoundary;
|
private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService;
|
private final HuifuRefundService refundService;
|
private final PaymentFundConfirmationService fundConfirmation;
|
|
public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService,
|
OrderFundBoundaryService orderFundBoundary,
|
com.talentcard.server.business.order.OrderVerificationService orderVerificationService,
|
HuifuRefundService refundService,
|
PaymentFundConfirmationService fundConfirmation) {
|
this.jdbcTemplate = jdbcTemplate;
|
this.manualConsumptionService = manualConsumptionService;
|
this.orderFundBoundary = orderFundBoundary;
|
this.orderVerificationService = orderVerificationService;
|
this.refundService = refundService;
|
this.fundConfirmation = fundConfirmation;
|
}
|
|
@Transactional(readOnly = true)
|
public PageResult<Map<String, Object>> orders(Long merchantId, Long storeId, int page, int size, MerchantOrderQuery query) {
|
if (query != null && "stay".equals(normalize(query.category()))) {
|
return freeStayOrders(merchantId, storeId, page, size, query);
|
}
|
// 退款到不到最终结果只有通道知道,退款通知又会遇到和支付通知一样的问题(本地/内网环境收不到)。
|
// 读列表之前先把这家商户在途的退款收敛一次,免得商户看到的退款状态比人才端晚几分钟。这一步必须
|
// 排在本事务的第一条读语句之前,否则列表读到的是收敛前的旧快照。免费入住没有退款,走上面的分支。
|
refundService.confirmPendingRefundsOfMerchant(merchantId, HuifuRefundService.PENDING_CONFIRM_LIMIT);
|
int safePage = Math.max(page, 1);
|
int safeSize = Math.min(Math.max(size, 1), 100);
|
OrderFilters filters = filters(query);
|
String conditions = " where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)" + filters.conditions();
|
List<Object> arguments = new ArrayList<>();
|
arguments.add(merchantId);
|
arguments.add(storeId);
|
arguments.addAll(filters.arguments());
|
|
String recordSql = """
|
select o.id, o.order_no, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
|
coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level,
|
b.benefit_name, o.product_name_snapshot, o.order_type, o.order_amount, o.subsidy_amount, o.pay_amount,
|
o.order_status, o.verify_status, o.refund_status, o.created_at, verified.verify_time
|
from benefit_order o
|
left join talent_user tu on tu.id = o.talent_id
|
left join talent_card tc on tc.id = o.card_id
|
left join benefit b on b.id = o.benefit_id
|
left join (select order_id, max(verify_time) verify_time from verify_record where status = 1 group by order_id) verified on verified.order_id = o.id
|
""" + conditions + " order by o.created_at desc, o.id desc limit ? offset ?";
|
List<Object> recordArguments = new ArrayList<>(arguments);
|
recordArguments.add(safeSize);
|
recordArguments.add((safePage - 1) * safeSize);
|
List<Map<String, Object>> records = query(recordSql, recordArguments.toArray()).stream().map(this::toListItem).toList();
|
Long total = jdbcTemplate.queryForObject("select count(*) from benefit_order o" + conditions, Long.class, arguments.toArray());
|
return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
|
}
|
|
public PageResult<Map<String, Object>> orders(Long merchantId, Long storeId, int page, int size) {
|
return orders(merchantId, storeId, page, size, new MerchantOrderQuery(null, null, null, null));
|
}
|
|
@Transactional(readOnly = true)
|
public Map<String, Object> order(String orderNo, Long merchantId, Long storeId) {
|
List<Map<String, Object>> rows = query("""
|
select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
|
o.card_id, tc.card_no, coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level,
|
o.benefit_id, b.benefit_name,
|
o.merchant_id, m.merchant_name, m.merchant_rights_config,
|
o.product_id, o.product_name_snapshot, p.rights_config,
|
o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount,
|
o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.refund_status, o.created_at,
|
vr.verify_time, vr.status as verify_record_status
|
from benefit_order o
|
left join talent_user tu on tu.id = o.talent_id
|
left join talent_card tc on tc.id = o.card_id
|
left join benefit b on b.id = o.benefit_id
|
left join merchant m on m.id = o.merchant_id
|
left join merchant_product p on p.id = o.product_id and p.deleted = 0
|
left join verify_record vr on vr.order_id = o.id and vr.status = 1
|
where o.deleted = 0 and o.order_no = ? and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
|
""", orderNo, merchantId, storeId);
|
if (rows.isEmpty()) {
|
List<Map<String, Object>> freeStayRows = query("""
|
select r.id, r.verify_no as order_no, r.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
|
r.card_id, tc.card_no, tc.card_level, r.merchant_id, m.merchant_name, m.merchant_rights_config,
|
'免费入住' as benefit_name, '1天免费入住' as product_name_snapshot,
|
'FREE_STAY' as order_type, 0 as order_amount, 0 as base_pay_amount, 0 as coupon_amount,
|
r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, 0 as refund_status,
|
r.verify_time as created_at, r.verify_time, r.status as verify_record_status
|
from verify_record r
|
left join talent_user tu on tu.id = r.talent_id
|
left join talent_card tc on tc.id = r.card_id
|
left join merchant m on m.id = r.merchant_id
|
where r.verify_no = ? and r.verify_type = 'HOTEL_FREE' and r.status = 1
|
and r.merchant_id = ? and (r.store_id is null or r.store_id = ?)
|
limit 1
|
""", orderNo, merchantId, storeId);
|
if (freeStayRows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在");
|
return enrichOrder(freeStayRows.get(0));
|
}
|
return enrichOrder(rows.get(0));
|
}
|
|
private PageResult<Map<String, Object>> freeStayOrders(Long merchantId, Long storeId, int page, int size, MerchantOrderQuery query) {
|
int safePage = Math.max(page, 1);
|
int safeSize = Math.min(Math.max(size, 1), 100);
|
StringBuilder condition = new StringBuilder(" where r.verify_type = 'HOTEL_FREE' and r.status = 1 and r.merchant_id = ? and (r.store_id is null or r.store_id = ?)");
|
List<Object> arguments = new ArrayList<>();
|
arguments.add(merchantId);
|
arguments.add(storeId);
|
LocalDate startDate = date(query.startDate(), "开始日期");
|
LocalDate endDate = date(query.endDate(), "结束日期");
|
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期");
|
}
|
if (startDate != null) {
|
condition.append(" and r.verify_time >= ?");
|
arguments.add(Timestamp.valueOf(startDate.atStartOfDay()));
|
}
|
if (endDate != null) {
|
condition.append(" and r.verify_time < ?");
|
arguments.add(Timestamp.valueOf(endDate.plusDays(1).atStartOfDay()));
|
}
|
String select = """
|
select r.id, r.verify_no as order_no, r.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
|
r.card_id, tc.card_level, r.merchant_id, m.merchant_name,
|
'免费入住' as benefit_name, '1天免费入住' as product_name_snapshot,
|
'FREE_STAY' as order_type, 0 as order_amount, 0 as base_pay_amount, 0 as coupon_amount,
|
r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, 0 as refund_status,
|
r.verify_time as created_at, r.verify_time
|
from verify_record r
|
left join talent_user tu on tu.id = r.talent_id
|
left join talent_card tc on tc.id = r.card_id
|
left join merchant m on m.id = r.merchant_id
|
""" + condition + " order by r.verify_time desc, r.id desc limit ? offset ?";
|
List<Object> recordArguments = new ArrayList<>(arguments);
|
recordArguments.add(safeSize);
|
recordArguments.add((safePage - 1) * safeSize);
|
List<Map<String, Object>> records = query(select, recordArguments.toArray()).stream().map(this::toListItem).toList();
|
Long total = jdbcTemplate.queryForObject("select count(*) from verify_record r" + condition, Long.class, arguments.toArray());
|
return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
|
}
|
|
@Transactional
|
public Map<String, Object> verify(String orderNo, MerchantPrincipal principal, VerifyRequest request, String idempotencyKey) {
|
if (idempotencyKey == null || idempotencyKey.isBlank()) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
|
}
|
Map<String, Object> order = order(orderNo, principal.merchantId(), principal.storeId());
|
// 先锁订单行,再复查幂等键和资金状态:并发核销(同键或不同键)都排队在这把锁上,
|
// 后来者要么拿到首次结果,要么看到订单已核销。
|
OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id")));
|
List<Map<String, Object>> repeated = query("""
|
select order_id from verify_record where idempotency_key = ? and merchant_id = ? limit 1
|
""", idempotencyKey.trim(), principal.merchantId());
|
if (!repeated.isEmpty()) {
|
if (!String.valueOf(order.get("id")).equals(String.valueOf(repeated.get(0).get("orderId")))) {
|
throw new BusinessException(ErrorCode.CONFLICT, "幂等键已用于其他订单");
|
}
|
return order(orderNo, principal.merchantId(), principal.storeId());
|
}
|
locked.requireVerifiable();
|
|
// 扫码后核销会带上扫码尝试编号:核销成功时要把这次尝试标记为已完成,并校验扫码码未过期。
|
Attempt attempt = attempt(request.verificationId(), principal);
|
// 核销只有一份实现:置订单已核销、写核销记录、确认延迟入账的资金。确认失败不会回滚核销
|
// (用户的服务已经核销掉了),资金保持待确认并由补偿任务重试。
|
String verifyNo = orderVerificationService.verifyByMerchant(principal.merchantId(), principal.storeId(),
|
principal.operatorId(), locked.orderId(),
|
verifyType(request.verifyType()), value(order.get("payAmount")), value(order.get("subsidyAmount")),
|
request.remark(), attempt.id(), idempotencyKey.trim(), attempt.expiresAt());
|
if (verifyNo.isBlank()) {
|
throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
|
}
|
return order(orderNo, principal.merchantId(), principal.storeId());
|
}
|
|
/** 核销方式不传时按商户现场核销处理。 */
|
private String verifyType(String requested) {
|
return requested == null || requested.isBlank() ? "merchant" : requested.trim();
|
}
|
|
/** 扫码尝试:核销成功后要标记完成,并且只有扫码码未过期才能核销。 */
|
private record Attempt(Long id, java.time.LocalDateTime expiresAt) { }
|
|
/** 把扫码尝试编号解析成主键与有效期;不属于当前商户/门店/操作员的尝试视为不存在。 */
|
private Attempt attempt(String verificationId, MerchantPrincipal principal) {
|
if (verificationId == null || verificationId.isBlank()) return new Attempt(null, null);
|
List<Map<String, Object>> attempts = query("""
|
select id, expires_at from merchant_verification_attempt
|
where verification_id = ? and merchant_id = ? and store_id = ? and operator_id = ?
|
limit 1
|
""", verificationId.trim(), principal.merchantId(), principal.storeId(), principal.operatorId());
|
if (attempts.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作");
|
return new Attempt(number(attempts.get(0).get("id")), time(attempts.get(0).get("expiresAt")));
|
}
|
|
@Transactional
|
public Map<String, Object> revokeVerification(MerchantPrincipal principal, MerchantRevokeVerificationRequest request, String idempotencyKey) {
|
if (idempotencyKey == null || idempotencyKey.isBlank()) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
|
}
|
String normalizedIdempotencyKey = idempotencyKey.trim();
|
Long orderId;
|
try {
|
orderId = Long.valueOf(request.orderId().trim());
|
} catch (NumberFormatException exception) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "订单编号无效");
|
}
|
List<Map<String, Object>> keyOwner = query("""
|
select order_id from verify_record where revoke_idempotency_key = ? limit 1
|
""", normalizedIdempotencyKey);
|
// 幂等键归属必须按值比较:两个 Long 用 != 只在 -128..127 的缓存区间内碰巧相等,
|
// 订单 ID 一旦超过这个区间,重复撤销就会被误判成「幂等键已用于其他订单」。
|
if (!keyOwner.isEmpty() && !Objects.equals(number(keyOwner.get(0).get("orderId")), orderId)) {
|
throw new BusinessException(ErrorCode.CONFLICT, "幂等键已用于其他订单");
|
}
|
List<Map<String, Object>> repeated = query("""
|
select vr.revoked_at
|
from verify_record vr
|
join benefit_order o on o.id = vr.order_id
|
where vr.revoke_idempotency_key = ? and o.id = ? and o.deleted = 0
|
and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
|
limit 1
|
""", normalizedIdempotencyKey, orderId, principal.merchantId(), principal.storeId());
|
if (!repeated.isEmpty()) {
|
return revokeResult(orderId, time(repeated.get(0).get("revokedAt")));
|
}
|
List<Map<String, Object>> orders = query("""
|
select o.id, o.order_no, o.order_type, o.order_status, o.verify_status,
|
o.merchant_id, o.store_id, vr.id verify_record_id, vr.verify_time
|
from benefit_order o
|
left join verify_record vr on vr.order_id = o.id and vr.status = 1
|
where o.id = ? and o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
|
""", orderId, principal.merchantId(), principal.storeId());
|
if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在");
|
Map<String, Object> order = orders.get(0);
|
// 撤销核销与核销、退款共用同一把订单行锁;锁内读到的核销记录才是稳定的。
|
orderFundBoundary.lock(orderId);
|
if (!"GROUP_BUY".equals(String.valueOf(order.get("orderType")))) {
|
throw new BusinessException(ErrorCode.CONFLICT, "当前业务类型不支持撤销核销");
|
}
|
if (number(order.get("verifyStatus")) == null || number(order.get("verifyStatus")) != 1
|
|| number(order.get("verifyRecordId")) == null) {
|
throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可撤销核销");
|
}
|
LocalDateTime verifyTime = time(order.get("verifyTime"));
|
if (verifyTime == null || Duration.between(verifyTime, LocalDateTime.now()).toHours() >= REVOCATION_HOURS) {
|
throw new BusinessException(ErrorCode.CONFLICT, "已过可撤销时间");
|
}
|
// 核销成功时钱已经确认给了商户(基本户),撤销核销必须先把钱退回通道延迟户。回退没成功就不许
|
// 翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次。
|
String revertOutcome = fundConfirmation.revertForOrder(orderId);
|
if (!PaymentFundConfirmationService.moneyBackInDelayAccount(revertOutcome)) {
|
throw new BusinessException(ErrorCode.CONFLICT,
|
PaymentFundConfirmationService.revertRefusalMessage(revertOutcome));
|
}
|
String reason = request.reason() == null || request.reason().isBlank() ? "商户撤销核销" : request.reason().trim();
|
int updated = jdbcTemplate.update("""
|
update verify_record set status = 2, revoked_at = now(), revoked_by = ?, revoke_reason = ?, revoke_idempotency_key = ?
|
where id = ? and status = 1
|
""", principal.operatorId(), reason, normalizedIdempotencyKey, number(order.get("verifyRecordId")));
|
if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "核销已被处理,请刷新后重试");
|
jdbcTemplate.update("update benefit_order set verify_status = 0 where id = ? and verify_status = 1", orderId);
|
return revokeResult(orderId, LocalDateTime.now());
|
}
|
|
private Map<String, Object> revokeResult(Long orderId, LocalDateTime revokedAt) {
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("orderId", String.valueOf(orderId));
|
result.put("orderStatus", "pending_verification");
|
result.put("orderStatusText", "待核销");
|
result.put("revokedAt", (revokedAt == null ? LocalDateTime.now() : revokedAt).toString());
|
return result;
|
}
|
|
private String revokeReason(String orderType) {
|
return "GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销";
|
}
|
|
private Map<String, Object> enrichOrder(Map<String, Object> order) {
|
Map<String, Object> result = new LinkedHashMap<>(order);
|
String orderType = String.valueOf(order.get("orderType"));
|
String category = "GROUP_BUY".equals(orderType) ? "group" : ("STORE_CONSUME".equals(orderType) ? "store" : "stay");
|
Long orderStatus = number(order.get("orderStatus"));
|
Long verifyStatus = number(order.get("verifyStatus"));
|
Long refundStatus = number(order.get("refundStatus"));
|
LocalDateTime verifyTime = time(order.get("verifyTime"));
|
// 核销状态必须同时有有效核销记录和核销时间。历史数据可能只更新了
|
// benefit_order.verify_status,不能据此把订单展示为已完成。
|
boolean verified = verifyStatus != null && verifyStatus == 1 && verifyTime != null;
|
// 与列表同一口径:待支付 → 退款中/已退款 → 已取消 → 已完成 → 待核销。
|
String status;
|
if (orderStatus != null && orderStatus == 0) status = "pending_payment";
|
else if (refundStatus != null && refundStatus != 0) status = "refunded";
|
else if (orderStatus != null && orderStatus == 2) status = "cancelled";
|
else status = verified ? "completed" : "pending_verification";
|
result.put("category", category);
|
result.put("status", status);
|
result.put("statusText", statusText(status, refundStatus));
|
result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
|
result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
|
result.put("title", orderTitle(order, category));
|
result.put("benefitName", currentBenefitText(order, category));
|
long orderAmount = number(order.get("orderAmount")) == null ? 0 : number(order.get("orderAmount"));
|
long payAmount = number(order.get("payAmount")) == null ? 0 : number(order.get("payAmount"));
|
long basePayAmount = number(order.get("basePayAmount")) == null ? orderAmount : number(order.get("basePayAmount"));
|
long couponAmount = number(order.get("couponAmount")) == null ? 0 : number(order.get("couponAmount"));
|
long configuredSubsidyAmount = number(order.get("subsidyAmount")) == null ? 0 : number(order.get("subsidyAmount"));
|
long totalDiscount = Math.max(0, orderAmount - payAmount);
|
long talentDiscountAmount = Math.max(0, orderAmount - basePayAmount);
|
if ("GROUP_BUY".equals(orderType)) {
|
couponAmount = Math.max(0, Math.min(couponAmount, totalDiscount));
|
// Older group-buy orders were created before all price snapshots were
|
// consistently persisted. Reconcile the displayed breakdown with the
|
// actual paid amount while preserving any valid coupon snapshot.
|
if (talentDiscountAmount + couponAmount != totalDiscount) {
|
talentDiscountAmount = Math.max(0, totalDiscount - couponAmount);
|
}
|
result.put("subsidyAmount", totalDiscount);
|
}
|
result.put("talentDiscountAmount", talentDiscountAmount);
|
result.put("couponAmount", couponAmount);
|
// 免费入住没有 benefit_order 金额,补贴来自核销记录中的后台配置,不能按原价减实付覆盖为 0。
|
result.put("subsidyAmount", "FREE_STAY".equals(orderType) ? configuredSubsidyAmount : totalDiscount);
|
boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1;
|
result.put("canRevoke", canRevoke);
|
result.put("revokeDeadline", verifyTime == null ? null : verifyTime.plusHours(REVOCATION_HOURS).toString());
|
result.put("revokeReason", canRevoke ? "" : revokeReason(orderType));
|
return result;
|
}
|
|
private List<Map<String, Object>> query(String sql, Object... args) {
|
return DatabaseRows.normalize(jdbcTemplate.queryForList(sql, args));
|
}
|
|
private OrderFilters filters(MerchantOrderQuery query) {
|
MerchantOrderQuery value = query == null ? new MerchantOrderQuery(null, null, null, null) : query;
|
StringBuilder conditions = new StringBuilder();
|
List<Object> arguments = new ArrayList<>();
|
String category = normalize(value.category());
|
if (!category.isEmpty()) {
|
String orderType = switch (category) {
|
case "group" -> "GROUP_BUY";
|
case "store" -> "STORE_CONSUME";
|
case "stay" -> "FREE_STAY";
|
default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单业务类型无效");
|
};
|
conditions.append(" and o.order_type = ?");
|
arguments.add(orderType);
|
}
|
String status = normalize(value.status());
|
if (!status.isEmpty() && !"all".equals(status)) {
|
switch (status) {
|
// 待核销要排除退款中/已退款的订单:它们已经离开可核销范围,点核销只会被资金边界拒绝。
|
case "pending_verification" -> conditions.append(" and o.order_status = 1 and o.verify_status = 0 and o.refund_status = 0");
|
case "pending_payment" -> conditions.append(" and o.order_status = 0");
|
case "completed" -> conditions.append(" and o.order_status = 1 and o.verify_status = 1");
|
// 离开支付流程的订单:未支付被取消,或已经退款(含退款中)。
|
case "closed" -> conditions.append(" and (o.order_status = 2 or o.refund_status <> 0)");
|
default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单状态无效");
|
}
|
}
|
LocalDate startDate = date(value.startDate(), "开始日期");
|
LocalDate endDate = date(value.endDate(), "结束日期");
|
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期");
|
}
|
if (startDate != null) {
|
conditions.append(" and o.created_at >= ?");
|
arguments.add(Timestamp.valueOf(startDate.atStartOfDay()));
|
}
|
if (endDate != null) {
|
conditions.append(" and o.created_at < ?");
|
arguments.add(Timestamp.valueOf(endDate.plusDays(1).atStartOfDay()));
|
}
|
return new OrderFilters(conditions.toString(), arguments);
|
}
|
|
private Map<String, Object> toListItem(Map<String, Object> order) {
|
Map<String, Object> result = new LinkedHashMap<>(order);
|
String category = category(String.valueOf(order.get("orderType")));
|
Long refundStatus = number(order.get("refundStatus"));
|
String status = status(number(order.get("orderStatus")), number(order.get("verifyStatus")), refundStatus);
|
result.put("category", category);
|
result.put("status", status);
|
result.put("statusText", statusText(status, refundStatus));
|
result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
|
result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
|
result.put("title", orderTitle(order, category));
|
return result;
|
}
|
|
private String orderTitle(Map<String, Object> order, String category) {
|
String snapshot = text(order.get("productNameSnapshot"));
|
if (!snapshot.isBlank()) return snapshot;
|
String benefit = text(order.get("benefitName"));
|
return benefit.isBlank() ? categoryTitle(category) : benefit;
|
}
|
|
private String currentBenefitText(Map<String, Object> order, String category) {
|
String cardLevel = text(order.get("cardLevel"));
|
String merchantDetail = merchantBenefitDetail(order.get("merchantRightsConfig"), cardLevel);
|
if (!merchantDetail.isBlank()) return merchantDetail;
|
if ("store".equals(category)) {
|
Long merchantId = number(order.get("merchantId"));
|
return merchantId == null ? "以当前商户到店消费规则为准" : manualConsumptionService.benefitTextForMerchant(merchantId, cardLevel);
|
}
|
if ("group".equals(category)) {
|
ProductPriceCalculator.ProductRight right = ProductPriceCalculator.productRight(order.get("rightsConfig"), cardLevel);
|
if (right != null) return productBenefitText(right);
|
}
|
String configured = text(order.get("benefitName"));
|
return configured.isBlank() ? "以当前订单权益规则为准" : configured;
|
}
|
|
private String merchantBenefitDetail(Object rawConfig, String cardLevel) {
|
if (rawConfig == null || cardLevel.isBlank()) return "";
|
try {
|
List<Map<String, Object>> configs = rawConfig instanceof List<?>
|
? ((List<?>) rawConfig).stream().filter(Map.class::isInstance)
|
.map(item -> (Map<String, Object>) item).toList()
|
: OBJECT_MAPPER.readValue(String.valueOf(rawConfig), RIGHTS_TYPE);
|
for (Map<String, Object> config : configs) {
|
if (!booleanValue(config.get("enabled")) || !sameCardLevel(config.get("level"), cardLevel)) continue;
|
String detail = text(config.get("detail"));
|
if (!detail.isBlank()) return detail;
|
}
|
} catch (Exception ignored) {
|
// Invalid legacy configuration should fall back to the structured rule text.
|
}
|
return "";
|
}
|
|
private boolean sameCardLevel(Object configured, String actual) {
|
return normalizeCardLevel(configured).equals(normalizeCardLevel(actual));
|
}
|
|
private String normalizeCardLevel(Object value) {
|
String raw = text(value).toUpperCase();
|
if (raw.contains("卓越") || "EXCELLENT".equals(raw)) return "EXCELLENT";
|
if (raw.contains("杰出") || "OUTSTANDING".equals(raw)) return "OUTSTANDING";
|
if (raw.contains("菁英") || "ELITE".equals(raw)) return "ELITE";
|
return raw;
|
}
|
|
private boolean booleanValue(Object value) {
|
return value instanceof Boolean bool ? bool : "1".equals(text(value)) || "true".equalsIgnoreCase(text(value));
|
}
|
|
private String productBenefitText(ProductPriceCalculator.ProductRight right) {
|
String discountText = discountText(right.discount());
|
if (!right.free()) return discountText.isBlank() ? "以商品配置为准" : "按市价" + discountText;
|
String freeText = right.limitCount() > 0 ? "免费" + right.limitCount() + "次/" + cycleText(right.resetCycle()) : "免费";
|
return discountText.isBlank() ? freeText : freeText + ",超出次数按市价" + discountText;
|
}
|
|
private String discountText(BigDecimal discount) {
|
if (discount == null || discount.signum() <= 0) return "";
|
return discount.stripTrailingZeros().toPlainString() + "折";
|
}
|
|
private String cycleText(String cycle) {
|
String value = normalize(cycle).toUpperCase();
|
return switch (value) {
|
case "DAY", "DAILY", "每天" -> "天";
|
case "WEEK", "WEEKLY", "每周" -> "周";
|
case "MONTH", "MONTHLY", "每月" -> "月";
|
default -> "年";
|
};
|
}
|
|
private String category(String orderType) {
|
if ("GROUP_BUY".equals(orderType)) return "group";
|
if ("STORE_CONSUME".equals(orderType)) return "store";
|
if ("FREE_STAY".equals(orderType)) return "stay";
|
return "group";
|
}
|
|
private String status(Long orderStatus, Long verifyStatus, Long refundStatus) {
|
if (orderStatus != null && orderStatus == 0) return "pending_payment";
|
// 退款中/已退款、已取消的订单都已经离开「待核销」:钱退给了人才,或者订单根本没付过钱。
|
// 把它们兜底成「待核销」会让商户以为还有一单要核销,点核销又会被资金边界拒绝。
|
if (refundStatus != null && refundStatus != 0) return "refunded";
|
if (orderStatus != null && orderStatus == 2) return "cancelled";
|
if (verifyStatus != null && verifyStatus == 1) return "completed";
|
return "pending_verification";
|
}
|
|
private String statusText(String status, Long refundStatus) {
|
if ("pending_payment".equals(status)) return "待支付";
|
if ("completed".equals(status)) return "已完成";
|
if ("cancelled".equals(status)) return "已取消";
|
if ("refunded".equals(status)) return refundStatus != null && refundStatus == 1 ? "退款中" : "已退款";
|
return "待核销";
|
}
|
|
private String categoryTitle(String category) {
|
if ("store".equals(category)) return "到店消费订单";
|
if ("stay".equals(category)) return "免费入住";
|
return "团购订单";
|
}
|
|
private String maskName(String value) {
|
if (value == null || value.isBlank() || "null".equals(value)) return "";
|
return value.length() == 1 ? value + "*" : value.substring(0, 1) + "*".repeat(value.length() - 1);
|
}
|
|
private String displayName(Object rawName, Long gender) {
|
String name = text(rawName);
|
if (name.isBlank()) return "";
|
if (gender != null && gender == 1) return name.substring(0, 1) + "先生";
|
if (gender != null && gender == 2) return name.substring(0, 1) + "女士";
|
return maskName(name);
|
}
|
|
private String text(Object value) {
|
return value == null || "null".equals(String.valueOf(value)) ? "" : String.valueOf(value).trim();
|
}
|
|
private String maskMobile(String value) {
|
if (value == null || value.length() < 7 || "null".equals(value)) return "";
|
return value.substring(0, 3) + "****" + value.substring(value.length() - 4);
|
}
|
|
private String normalize(String value) {
|
return value == null ? "" : value.trim();
|
}
|
|
private LocalDate date(String value, String field) {
|
if (value == null || value.isBlank()) return null;
|
try {
|
return LocalDate.parse(value);
|
} catch (Exception exception) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, field + "格式无效");
|
}
|
}
|
|
private Long number(Object value) {
|
return value instanceof Number number ? number.longValue() : null;
|
}
|
|
/** 与 {@link #number} 同义,但缺值时按 0 处理:核销金额为空就是 0 分。 */
|
private long value(Object value) {
|
return value instanceof Number number ? number.longValue() : 0L;
|
}
|
|
private LocalDateTime time(Object value) {
|
if (value instanceof LocalDateTime dateTime) return dateTime;
|
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
|
return null;
|
}
|
|
private record OrderFilters(String conditions, List<Object> arguments) {
|
}
|
}
|