| | |
| | | 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; |
| | |
| | | |
| | | 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) { |
| | | 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) |
| | |
| | | if (query != null && "stay".equals(normalize(query.category()))) { |
| | | return freeStayOrders(merchantId, storeId, page, size, query); |
| | | } |
| | | // 观影券(票包领取记录)在商户眼里也是「团购」的一份流水,和 benefit_order 混排分页。 |
| | | if (query != null && "group".equals(normalize(query.category()))) { |
| | | return groupOrders(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); |
| | |
| | | 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.created_at, verified.verify_time |
| | | 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 |
| | |
| | | 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, |
| | | p.cover_url as product_cover_url, m.cover_url as merchant_cover_url, |
| | | 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.created_at, |
| | | 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 |
| | |
| | | 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, |
| | | m.cover_url as merchant_cover_url, |
| | | '免费入住' 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, |
| | | 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 |
| | |
| | | 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, |
| | | 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 |
| | |
| | | return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); |
| | | } |
| | | |
| | | /** |
| | | * 团购列表:普通团购订单与观影券(票包领取记录)混排。 |
| | | * |
| | | * <p>观影券不是 benefit_order:兑换码由后台票包码池批量发放,领取记录落在 ticket_package_claim, |
| | | * 只有核销时才写 benefit_verify_record。但商户在「团购」里看到的应该是一份按时间倒序的流水, |
| | | * 所以这里把两类记录并成一张表再统一分页,避免前端拿到两段各自分页的数据拼不齐。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | private PageResult<Map<String, Object>> groupOrders(Long merchantId, Long storeId, int page, int size, MerchantOrderQuery 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 claimStatusCondition = switch (normalize(query == null ? null : query.status())) { |
| | | case "", "all" -> ""; |
| | | case "pending_verification" -> " and cl.status = 'ISSUED'"; |
| | | case "completed" -> " and cl.status = 'USED'"; |
| | | case "pending_payment" -> " and 1 = 0"; |
| | | case "closed" -> " and cl.status in ('EXPIRED', 'CANCELLED')"; |
| | | default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单状态无效"); |
| | | }; |
| | | LocalDate startDate = date(query == null ? null : query.startDate(), "开始日期"); |
| | | LocalDate endDate = date(query == null ? null : query.endDate(), "结束日期"); |
| | | StringBuilder claimConditions = new StringBuilder(claimStatusCondition); |
| | | List<Object> claimArguments = new ArrayList<>(); |
| | | claimArguments.add(merchantId); |
| | | // 时间筛选沿用「下单时间」口径:观影券的下单时间就是领取时间。 |
| | | if (startDate != null) { |
| | | claimConditions.append(" and cl.created_at >= ?"); |
| | | claimArguments.add(Timestamp.valueOf(startDate.atStartOfDay())); |
| | | } |
| | | if (endDate != null) { |
| | | claimConditions.append(" and cl.created_at < ?"); |
| | | claimArguments.add(Timestamp.valueOf(endDate.plusDays(1).atStartOfDay())); |
| | | } |
| | | String orderSource = """ |
| | | select cast(o.id as char) as id, o.order_no as order_no, 'GROUP_BUY' as order_type, |
| | | tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, |
| | | coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level, |
| | | o.product_name_snapshot, b.benefit_name, o.order_amount, o.subsidy_amount, o.pay_amount, |
| | | o.order_status, o.verify_status, o.refund_status, o.created_at, |
| | | verified.verify_time, '' as source_type, null as claim_id, o.created_at as sort_time, 0 as source_rank, o.id as sort_seq |
| | | 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 |
| | | where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?) |
| | | """ + filters.conditions(); |
| | | String claimSource = """ |
| | | select concat('TP', cl.id) as id, cl.claim_no as order_no, 'GROUP_BUY' as order_type, |
| | | cl.talent_name as talent_name, cl.talent_mobile as talent_mobile, tu.gender, |
| | | cl.card_level as card_level, |
| | | '观影券1张' as product_name_snapshot, '观影券' as benefit_name, |
| | | 0 as order_amount, 0 as subsidy_amount, 0 as pay_amount, |
| | | case when cl.status in ('ISSUED', 'USED') then 1 else 2 end as order_status, |
| | | case when cl.status = 'USED' then 1 else 0 end as verify_status, |
| | | 0 as refund_status, cl.created_at, |
| | | case when cl.status = 'USED' then cl.used_at else null end as verify_time, |
| | | 'TICKET_PACKAGE' as source_type, cl.id as claim_id, cl.created_at as sort_time, 1 as source_rank, cl.id as sort_seq |
| | | from ticket_package_claim cl |
| | | left join talent_user tu on tu.id = cl.talent_id |
| | | where cl.deleted = 0 and cl.merchant_id = ? |
| | | """ + claimConditions; |
| | | String recordSql = """ |
| | | select t.id, t.order_no, t.order_type, t.talent_name, t.talent_mobile, t.gender, t.card_level, |
| | | t.product_name_snapshot, t.benefit_name, t.order_amount, t.subsidy_amount, t.pay_amount, |
| | | t.order_status, t.verify_status, t.refund_status, t.created_at, t.verify_time, t.source_type, t.claim_id |
| | | from ( |
| | | """ + orderSource + "\n union all \n" + claimSource + """ |
| | | ) t |
| | | order by t.sort_time desc, t.source_rank asc, t.sort_seq desc |
| | | limit ? offset ? |
| | | """; |
| | | List<Object> recordArguments = new ArrayList<>(); |
| | | recordArguments.add(merchantId); |
| | | recordArguments.add(storeId); |
| | | recordArguments.addAll(filters.arguments()); |
| | | recordArguments.addAll(claimArguments); |
| | | recordArguments.add(safeSize); |
| | | recordArguments.add((safePage - 1) * safeSize); |
| | | List<Map<String, Object>> records = query(recordSql, recordArguments.toArray()).stream().map(this::toListItem).toList(); |
| | | String countSql = "select (select count(*) from benefit_order o where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)" |
| | | + filters.conditions() + ") + (select count(*) from ticket_package_claim cl where cl.deleted = 0 and cl.merchant_id = ?" |
| | | + claimConditions + ")"; |
| | | List<Object> countArguments = new ArrayList<>(); |
| | | countArguments.add(merchantId); |
| | | countArguments.add(storeId); |
| | | countArguments.addAll(filters.arguments()); |
| | | countArguments.addAll(claimArguments); |
| | | Long total = jdbcTemplate.queryForObject(countSql, Long.class, countArguments.toArray()); |
| | | return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); |
| | | } |
| | | |
| | | /** |
| | | * 观影券领取记录详情:商户在「团购」里点开观影券那一条时看到的信息。 |
| | | * |
| | | * <p>观影券没有 benefit_order,行项目来自 ticket_package_claim;核销时间取 used_at。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public Map<String, Object> ticketPackageClaim(Long claimId, Long merchantId) { |
| | | if (claimId == null || claimId <= 0) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "观影券领取记录ID不能为空"); |
| | | } |
| | | List<Map<String, Object>> rows = query(""" |
| | | select cl.id, cl.claim_no, cl.package_id, p.package_name, cl.merchant_id, m.merchant_name, |
| | | m.merchant_rights_config, |
| | | cl.talent_name, cl.talent_mobile, tu.gender, cl.card_level, cl.card_no, |
| | | cl.status, cl.valid_end, cl.used_at, cl.created_at |
| | | from ticket_package_claim cl |
| | | join ticket_package p on p.id = cl.package_id and p.deleted = 0 |
| | | left join merchant m on m.id = cl.merchant_id and m.deleted = 0 |
| | | left join talent_user tu on tu.id = cl.talent_id |
| | | where cl.id = ? and cl.deleted = 0 and cl.merchant_id = ? |
| | | limit 1 |
| | | """, claimId, merchantId); |
| | | if (rows.isEmpty()) { |
| | | throw new BusinessException(ErrorCode.NOT_FOUND, "观影券领取记录不存在或不属于当前商户"); |
| | | } |
| | | Map<String, Object> row = rows.get(0); |
| | | boolean used = "USED".equalsIgnoreCase(text(row.get("status"))); |
| | | String cardLevel = text(row.get("cardLevel")); |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("type", "TICKET_PACKAGE"); |
| | | result.put("claimId", row.get("id")); |
| | | result.put("claimNo", text(row.get("claimNo"))); |
| | | result.put("title", "观影券1张"); |
| | | result.put("quantity", 1); |
| | | result.put("packageName", text(row.get("packageName"))); |
| | | result.put("merchantName", text(row.get("merchantName"))); |
| | | result.put("customerName", displayName(row.get("talentName"), number(row.get("gender")))); |
| | | result.put("customerMobile", maskMobile(String.valueOf(row.get("talentMobile")))); |
| | | result.put("cardLevel", cardLevel); |
| | | result.put("status", used ? "completed" : "pending_verification"); |
| | | result.put("statusText", used ? "已核销" : "待核销"); |
| | | result.put("createdAt", row.get("createdAt")); |
| | | result.put("usedAt", row.get("usedAt")); |
| | | result.put("benefitText", ticketPackageBenefitText(row.get("merchantRightsConfig"), merchantId, cardLevel)); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 观影券详情的「当前权益」:与到店消费同一套口径——先取商家人才权益里该等级配置的描述, |
| | | * 没配再回落到商户的到店消费权益文案,避免商户在观影券详情看到一句没有信息量的兜底话术。 |
| | | */ |
| | | private String ticketPackageBenefitText(Object rightsConfig, Long merchantId, String cardLevel) { |
| | | if (cardLevel.isBlank()) return ""; |
| | | String configured = merchantBenefitDetail(rightsConfig, cardLevel); |
| | | if (!configured.isBlank()) return configured; |
| | | return manualConsumptionService.benefitTextForMerchant(merchantId, cardLevel); |
| | | } |
| | | |
| | | @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()); |
| | |
| | | } |
| | | return order(orderNo, principal.merchantId(), principal.storeId()); |
| | | } |
| | | if (((Number) order.get("orderStatus")).intValue() != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销"); |
| | | } |
| | | if (((Number) order.get("verifyStatus")).intValue() == 1) { |
| | | 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, "订单已核销"); |
| | | } |
| | | |
| | | String verifyNo = BusinessIdGenerator.next("V"); |
| | | jdbcTemplate.update(""" |
| | | insert into verify_record(verify_no, order_id, talent_id, card_id, benefit_id, merchant_id, |
| | | staff_id, verify_type, verify_amount, subsidy_amount, remark, idempotency_key) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| | | """, |
| | | verifyNo, |
| | | order.get("id"), |
| | | order.get("talentId"), |
| | | order.get("cardId"), |
| | | order.get("benefitId"), |
| | | order.get("merchantId"), |
| | | principal.operatorId(), |
| | | request.verifyType(), |
| | | order.get("payAmount"), |
| | | order.get("subsidyAmount"), |
| | | request.remark(), idempotencyKey.trim()); |
| | | jdbcTemplate.update("update benefit_order set verify_status = 1 where order_no = ? and verify_status = 0", orderNo); |
| | | return order(orderNo, principal.merchantId(), principal.storeId()); |
| | | } |
| | | |
| | | /** 核销方式不传时按商户现场核销处理。 */ |
| | | private String verifyType(String requested) { |
| | | return requested == null || requested.isBlank() ? "merchant" : requested.trim(); |
| | | } |
| | | |
| | | /** |
| | | * 手输 6 位数字核销码之后的确认核销。 |
| | | * |
| | | * <p>为什么不复用 {@code /api/merchant/orders/{orderNo}/verify}:手输那条路的扫码答复刻意不返回 |
| | | * 「这个码属于哪笔订单」(6 位只有 100 万空间,带回去等于给枚举一张映射表),所以客户端拿不到单号。 |
| | | * 这里由服务端从扫码尝试反查订单,再走同一套核销实现——幂等、行锁、资金确认都不重复写第二份。 |
| | | */ |
| | | @Transactional |
| | | public Map<String, Object> confirmNumericVerification(MerchantPrincipal principal, String verificationId, String idempotencyKey) { |
| | | if (idempotencyKey == null || idempotencyKey.isBlank()) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); |
| | | } |
| | | String attemptId = verificationId == null ? "" : verificationId.trim(); |
| | | if (attemptId.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少核销凭证编号"); |
| | | List<Map<String, Object>> orders = query(""" |
| | | select o.order_no |
| | | from merchant_verification_attempt a |
| | | join order_dynamic_verification_code c on c.id = a.order_verification_code_id |
| | | join benefit_order o on o.id = c.order_id and o.deleted = 0 |
| | | where a.verification_id = ? and a.credential_type = 'ORDER_CODE' |
| | | and a.merchant_id = ? and a.store_id = ? and a.operator_id = ? |
| | | limit 1 |
| | | """, attemptId, principal.merchantId(), principal.storeId(), principal.operatorId()); |
| | | if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作"); |
| | | return verify(String.valueOf(orders.get(0).get("orderNo")).trim(), principal, |
| | | new VerifyRequest("ORDER_CODE", "", attemptId), idempotencyKey); |
| | | } |
| | | |
| | | /** 扫码尝试:核销成功后要标记完成,并且只有扫码码未过期才能核销。 */ |
| | | 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 |
| | |
| | | List<Map<String, Object>> keyOwner = query(""" |
| | | select order_id from verify_record where revoke_idempotency_key = ? limit 1 |
| | | """, normalizedIdempotencyKey); |
| | | if (!keyOwner.isEmpty() && number(keyOwner.get(0).get("orderId")) != orderId) { |
| | | // 幂等键归属必须按值比较:两个 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(""" |
| | |
| | | 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 = ?) |
| | | for update |
| | | """, 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, "当前业务类型不支持撤销核销"); |
| | | } |
| | |
| | | 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(""" |
| | |
| | | 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 = orderStatus != null && orderStatus == 0 ? "pending_payment" : (verified ? "completed" : "pending_verification"); |
| | | // 与列表同一口径:待支付 → 退款中/已退款 → 已取消 → 已完成 → 待核销。 |
| | | 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", "pending_payment".equals(status) ? "待支付" : ("completed".equals(status) ? "已完成" : "待核销")); |
| | | result.put("statusText", statusText(status, refundStatus, category)); |
| | | 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)); |
| | |
| | | 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)) { |
| | |
| | | } |
| | | result.put("talentDiscountAmount", talentDiscountAmount); |
| | | result.put("couponAmount", couponAmount); |
| | | result.put("subsidyAmount", totalDiscount); |
| | | // 免费入住没有 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 ? "" : ("GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销")); |
| | | result.put("revokeReason", canRevoke ? "" : revokeReason(orderType)); |
| | | return result; |
| | | } |
| | | |
| | |
| | | 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"); |
| | | // 待核销要排除退款中/已退款的订单:它们已经离开可核销范围,点核销只会被资金边界拒绝。 |
| | | 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, "订单状态无效"); |
| | | } |
| | | } |
| | |
| | | private Map<String, Object> toListItem(Map<String, Object> order) { |
| | | Map<String, Object> result = new LinkedHashMap<>(order); |
| | | String category = category(String.valueOf(order.get("orderType"))); |
| | | String status = status(number(order.get("orderStatus")), number(order.get("verifyStatus"))); |
| | | 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)); |
| | | result.put("statusText", statusText(status, refundStatus, category)); |
| | | 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 "group"; |
| | | } |
| | | |
| | | private String status(Long orderStatus, Long verifyStatus) { |
| | | 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) { |
| | | private String statusText(String status, Long refundStatus, String category) { |
| | | if ("pending_payment".equals(status)) return "待支付"; |
| | | if ("completed".equals(status)) return "已完成"; |
| | | // 团购是到店核销的业务:已完成在商户眼里就是「已核销」,原型与列表口径都这么写。 |
| | | if ("completed".equals(status)) return "group".equals(category) ? "已核销" : "已完成"; |
| | | if ("cancelled".equals(status)) return "已取消"; |
| | | if ("refunded".equals(status)) return refundStatus != null && refundStatus == 1 ? "退款中" : "已退款"; |
| | | return "待核销"; |
| | | } |
| | | |
| | |
| | | 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(); |