mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
604 lines
29 KiB
PHP
604 lines
29 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\services\order;
|
||
|
||
use app\dao\order\AfterSaleDao;
|
||
use app\dao\order\AfterSaleItemDao;
|
||
use app\dao\order\OrderCenterDao;
|
||
use app\dao\order\RefundOrderDao;
|
||
use app\model\order\OrderCenter;
|
||
use app\model\order\RefundOrder;
|
||
use app\services\pay\PayServices;
|
||
use crmeb\basic\BaseService;
|
||
use crmeb\trade\order\aftersale\AfterSaleRuleRegistry;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 退款聚合根服务:负责唯一退款意图、状态机、渠道提交/查询和本地效果恢复。
|
||
*/
|
||
class RefundOrderServices extends BaseService
|
||
{
|
||
protected RefundOrderDao $refundDao;
|
||
protected AfterSaleDao $afterSaleDao;
|
||
protected AfterSaleItemDao $itemDao;
|
||
protected OrderCenterDao $centerDao;
|
||
protected PayServices $payServices;
|
||
|
||
protected function initialize(): void
|
||
{
|
||
$this->refundDao = $this->app->make(RefundOrderDao::class);
|
||
$this->afterSaleDao = $this->app->make(AfterSaleDao::class);
|
||
$this->itemDao = $this->app->make(AfterSaleItemDao::class);
|
||
$this->centerDao = $this->app->make(OrderCenterDao::class);
|
||
$this->payServices = $this->app->make(PayServices::class);
|
||
}
|
||
|
||
/**
|
||
* 创建或读取唯一退款意图,并立即推进一次。
|
||
*/
|
||
public function request(int $afterSaleId, string $amount, array $context = []): array
|
||
{
|
||
if (!is_numeric($amount)) {
|
||
throw new \RuntimeException('退款金额格式错误');
|
||
}
|
||
$amount = bcadd((string)$amount, '0', 2);
|
||
// 后台选填的退款流水号:去空格并限制字段长度(eb_after_sale.refund_trade_no varchar(64))
|
||
$refundTradeNo = trim((string)($context['refund_trade_no'] ?? ''));
|
||
if ($refundTradeNo !== '' && mb_strlen($refundTradeNo) > 64) {
|
||
throw new \RuntimeException('退款流水号不能超过64个字符');
|
||
}
|
||
$context['refund_trade_no'] = $refundTradeNo;
|
||
$refund = $this->transaction(function () use ($afterSaleId, $amount, $context) {
|
||
$afterSale = $this->afterSaleDao->getByIdForUpdate($afterSaleId);
|
||
if (!$afterSale) {
|
||
throw new \RuntimeException('售后单不存在');
|
||
}
|
||
$order = $this->centerDao->getByIdForUpdate((int)$afterSale['order_center_id']);
|
||
if (!$order) {
|
||
throw new \RuntimeException('订单不存在,无法退款');
|
||
}
|
||
|
||
$existing = $this->refundDao->getByAfterSaleId($afterSaleId, true);
|
||
if ($existing) {
|
||
if (bccomp((string)$existing['amount'], $amount, 2) !== 0
|
||
|| (string)$existing['refund_method'] !== (string)($context['refund_type'] ?? 'original')) {
|
||
throw new \RuntimeException('该售后单已创建退款意图,不能修改退款金额或退款方式');
|
||
}
|
||
return $existing;
|
||
}
|
||
|
||
$this->assertRefundable($afterSale->toArray(), $amount);
|
||
if (bccomp($amount, '0.00', 2) > 0 && !in_array((int)$order['pay_status'], [
|
||
OrderCenter::PAY_PAID,
|
||
OrderCenter::PAY_PARTIAL_REFUND,
|
||
], true)) {
|
||
throw new \RuntimeException('订单当前支付状态不可退款');
|
||
}
|
||
$reserved = $this->refundDao->sumReservedByOrderCenterId((int)$order['id']);
|
||
if (bccomp(bcadd($reserved, $amount, 2), (string)$order['pay_price'], 2) > 0) {
|
||
throw new \RuntimeException('累计退款金额不能超过订单实付金额');
|
||
}
|
||
|
||
$items = $this->itemDao->getByAfterSaleId($afterSaleId);
|
||
if (!$items) {
|
||
$items = $this->materializeLegacyItems($afterSale->toArray());
|
||
}
|
||
$handler = AfterSaleRuleRegistry::getProvider('product_type', (string)$afterSale['product_type']);
|
||
if (!$handler) {
|
||
throw new \RuntimeException('产品类型未注册售后退款处理器');
|
||
}
|
||
$plan = $this->normalizePlan(
|
||
$handler->buildRefundPlan($afterSale->toArray(), $order->toArray(), $items, $context)
|
||
);
|
||
$frozenContext = $context;
|
||
$frozenContext['plan'] = $plan;
|
||
if (json_encode($frozenContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) === false) {
|
||
throw new \RuntimeException('退款上下文无法持久化');
|
||
}
|
||
|
||
$refundNo = 'RF' . (string)$afterSale['after_sale_no'];
|
||
$refund = $this->refundDao->save([
|
||
'refund_no' => substr($refundNo, 0, 48),
|
||
'after_sale_id' => $afterSaleId,
|
||
'order_center_id' => (int)$order['id'],
|
||
'order_no' => (string)$afterSale['order_no'],
|
||
'uid' => (int)$afterSale['uid'],
|
||
'amount' => $amount,
|
||
'refund_integral' => (int)($afterSale['refund_integral'] ?? 0),
|
||
'refund_method' => (string)($context['refund_type'] ?? 'original'),
|
||
'pay_channel' => (string)($order['pay_type'] ?? ''),
|
||
'state' => RefundOrder::STATE_PENDING,
|
||
'channel_refund_id' => '',
|
||
'channel_response' => [],
|
||
'context' => $frozenContext,
|
||
'attempts' => 0,
|
||
'next_retry_time' => 0,
|
||
'last_error' => '',
|
||
'version' => 0,
|
||
'channel_succeeded_at' => 0,
|
||
'completed_at' => 0,
|
||
'add_time' => time(),
|
||
'update_time' => time(),
|
||
]);
|
||
$afterSaleUpdate = ['status' => 'refunding'];
|
||
if ((string)($context['refund_trade_no'] ?? '') !== '') {
|
||
// 先落库后台填写的流水号,退款处理中即可在详情看到,成功回调也不会被覆盖
|
||
$afterSaleUpdate['refund_trade_no'] = (string)$context['refund_trade_no'];
|
||
}
|
||
$this->afterSaleDao->update($afterSaleId, $afterSaleUpdate);
|
||
/** @var OrderCenterServices $centerServices */
|
||
$centerServices = $this->app->make(OrderCenterServices::class);
|
||
$centerServices->syncStatus((string)$afterSale['order_no'], OrderCenter::STATUS_REFUNDING, [
|
||
'refund_status' => OrderCenter::REFUND_APPLYING,
|
||
]);
|
||
return $refund;
|
||
});
|
||
|
||
return $this->process((int)$refund['id']);
|
||
}
|
||
|
||
/**
|
||
* 推进一张退款单。可由同步请求、队列和恢复任务重复调用。
|
||
*/
|
||
public function process(int $refundOrderId): array
|
||
{
|
||
$refund = $this->refundDao->get($refundOrderId);
|
||
if (!$refund) {
|
||
throw new \RuntimeException('退款单不存在');
|
||
}
|
||
$state = (string)$refund['state'];
|
||
if ($state === RefundOrder::STATE_COMPLETED || $state === RefundOrder::STATE_MANUAL_REVIEW) {
|
||
if ($state === RefundOrder::STATE_COMPLETED) {
|
||
$this->app->make(RefundEffectServices::class)->deliverSuccess($refundOrderId);
|
||
}
|
||
return $refund->toArray();
|
||
}
|
||
if (in_array($state, [RefundOrder::STATE_CHANNEL_SUCCEEDED, RefundOrder::STATE_LOCAL_FAILED, RefundOrder::STATE_APPLYING], true)) {
|
||
return $this->applyLocalEffects($refund->toArray());
|
||
}
|
||
|
||
$plan = (array)($refund['context']['plan'] ?? []);
|
||
$channelMode = (string)($plan['channel_mode'] ?? 'auto');
|
||
if ($channelMode === 'manual') {
|
||
$this->refundDao->update($refundOrderId, [
|
||
'state' => RefundOrder::STATE_MANUAL_REVIEW,
|
||
'last_error' => '产品退款方案要求人工处理支付渠道',
|
||
'update_time' => time(),
|
||
]);
|
||
return $this->refundDao->get($refundOrderId)->toArray();
|
||
}
|
||
|
||
if (in_array($state, [RefundOrder::STATE_PENDING, RefundOrder::STATE_RETRYABLE_FAILED], true)) {
|
||
$this->prepareProductEffects($refund->toArray());
|
||
$refund = $this->refundDao->get($refundOrderId);
|
||
$state = (string)$refund['state'];
|
||
}
|
||
|
||
$isLocal = $channelMode === 'local'
|
||
|| $refund['refund_method'] === 'balance'
|
||
|| $refund['pay_channel'] === PayServices::YUE_PAY
|
||
|| bccomp((string)$refund['amount'], '0.00', 2) === 0;
|
||
if ($isLocal) {
|
||
$this->markChannelSucceeded($refundOrderId, ['status' => PayServices::REFUND_SUCCESS]);
|
||
return $this->applyLocalEffects($this->refundDao->get($refundOrderId)->toArray());
|
||
}
|
||
|
||
if (!in_array((string)$refund['pay_channel'], [
|
||
PayServices::WEIXIN_PAY,
|
||
PayServices::ALIAPY_PAY,
|
||
PayServices::ALLIN_PAY,
|
||
'weixin_v3',
|
||
], true)) {
|
||
$this->refundDao->update($refundOrderId, [
|
||
'state' => RefundOrder::STATE_MANUAL_REVIEW,
|
||
'last_error' => '支付方式不支持自动原路退款:' . $refund['pay_channel'],
|
||
'update_time' => time(),
|
||
]);
|
||
return $this->refundDao->get($refundOrderId)->toArray();
|
||
}
|
||
|
||
$action = $this->claimChannelAction($refundOrderId);
|
||
if ($action === 'wait') {
|
||
return $this->refundDao->get($refundOrderId)->toArray();
|
||
}
|
||
$refund = $this->refundDao->get($refundOrderId)->toArray();
|
||
$order = $this->centerDao->get((int)$refund['order_center_id']);
|
||
if (!$order) {
|
||
throw new \RuntimeException('订单不存在,无法推进退款');
|
||
}
|
||
$orderArray = $order->toArray();
|
||
if ($action === 'submit') {
|
||
$result = $this->payServices->submitRefund(
|
||
(string)$refund['pay_channel'],
|
||
(string)$refund['order_no'],
|
||
(string)$refund['refund_no'],
|
||
(string)$refund['amount'],
|
||
(string)$orderArray['pay_price'],
|
||
$orderArray
|
||
);
|
||
} else {
|
||
$result = $this->payServices->queryRefundStatus(
|
||
(string)$refund['pay_channel'],
|
||
(string)$refund['order_no'],
|
||
(string)$refund['refund_no'],
|
||
['order_info' => $orderArray]
|
||
);
|
||
}
|
||
|
||
$this->persistChannelResult($refundOrderId, $result);
|
||
$updated = $this->refundDao->get($refundOrderId);
|
||
if ($updated && $updated['state'] === RefundOrder::STATE_CHANNEL_SUCCEEDED) {
|
||
return $this->applyLocalEffects($updated->toArray());
|
||
}
|
||
return $updated ? $updated->toArray() : $refund;
|
||
}
|
||
|
||
/**
|
||
* 接收驱动已核验的 V2/V3 退款通知,确认原支付身份和金额后复用幂等完成流程。
|
||
* @param array $notify 解密后的退款核对字段
|
||
* @param string $payChannel 可信驱动渠道
|
||
* @return bool 本地处理完成才确认通知,否则由微信重试并由定时任务补偿
|
||
*/
|
||
public function handleWechatRefundNotify(array $notify, string $payChannel = 'weixin_v3'): bool
|
||
{
|
||
try {
|
||
if (!in_array($payChannel, [PayServices::WEIXIN_PAY, 'weixin_v3'], true)) {
|
||
return false;
|
||
}
|
||
$mode = $payChannel === PayServices::WEIXIN_PAY ? 'v2' : 'v3';
|
||
$refundOrderId = $this->transaction(function () use ($notify, $payChannel, $mode): int {
|
||
$refundNo = (string)($notify['out_refund_no'] ?? '');
|
||
if ($refundNo === '' || ($notify['refund_status'] ?? '') !== 'SUCCESS'
|
||
|| empty($notify['refund_id'])) {
|
||
throw new \RuntimeException('退款成功通知缺少必要字段');
|
||
}
|
||
$refund = $this->refundDao->get(['refund_no' => $refundNo]);
|
||
if (!$refund) {
|
||
throw new \RuntimeException('退款通知对应的退款单不存在');
|
||
}
|
||
$refund = $this->refundDao->getByIdForUpdate((int)$refund['id']);
|
||
$order = $this->centerDao->get((int)$refund['order_center_id']);
|
||
$amount = $notify['amount'] ?? [];
|
||
if (!$order || $refund['pay_channel'] !== $payChannel
|
||
|| $refund['refund_method'] !== 'original'
|
||
|| (string)($notify['out_trade_no'] ?? '') !== (string)$refund['order_no']
|
||
|| !isset($amount['refund'], $amount['total'])
|
||
|| !is_int($amount['refund']) || !is_int($amount['total'])
|
||
|| $amount['refund'] !== (int)bcmul((string)$refund['amount'], '100', 0)
|
||
|| $amount['total'] !== (int)bcmul((string)$order['pay_price'], '100', 0)) {
|
||
throw new \RuntimeException('退款通知的渠道、订单或金额不一致');
|
||
}
|
||
$extra = $order['extra_info'] ?? [];
|
||
$extra = is_string($extra) ? (json_decode($extra, true) ?: []) : $extra;
|
||
$payment = $extra['payment'] ?? [];
|
||
if (($payment['mode'] ?? $mode) !== $mode
|
||
|| (!empty($payment['mch_id']) && $payment['mch_id'] !== ($notify['mchid'] ?? ''))
|
||
|| (isset($payment['sub_mch_id']) && $payment['sub_mch_id'] !== ($notify['sub_mchid'] ?? ''))
|
||
|| (!empty($refund['channel_refund_id']) && $refund['channel_refund_id'] !== $notify['refund_id'])) {
|
||
throw new \RuntimeException('退款通知与原支付商户或退款流水不一致');
|
||
}
|
||
// 服务商查单的 sub_appid 为可选字段;缺省时以已验签的子商户与原交易绑定合同。
|
||
$notifyAppId = !empty($payment['sub_mch_id']) ? ($notify['sub_appid'] ?? '') : ($notify['appid'] ?? '');
|
||
if ($mode === 'v2' && (
|
||
($notify['payment_mode'] ?? '') !== 'v2'
|
||
|| (!empty($payment['app_id']) && (empty($payment['sub_mch_id']) || $notifyAppId !== '')
|
||
&& $payment['app_id'] !== $notifyAppId)
|
||
|| (!empty($order['trade_no']) && $order['trade_no'] !== ($notify['transaction_id'] ?? ''))
|
||
)) {
|
||
throw new \RuntimeException('V2退款通知与原支付应用或交易流水不一致');
|
||
}
|
||
$this->markChannelSucceeded((int)$refund['id'], [
|
||
'channel_refund_id' => $notify['refund_id'],
|
||
'response' => ['status' => 'SUCCESS', 'out_refund_no' => $refundNo, 'refund_id' => $notify['refund_id']],
|
||
]);
|
||
return (int)$refund['id'];
|
||
});
|
||
return ($this->process($refundOrderId)['state'] ?? '') === RefundOrder::STATE_COMPLETED;
|
||
} catch (\Throwable $e) {
|
||
Log::error('[RefundNotify] 退款成功通知处理失败:' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 主动查询一笔既有 V3 退款并补完本地效果,不重新提交退款。
|
||
* @param string $refundNo 系统退款单号
|
||
* @return array 当前退款状态及渠道查询结果
|
||
*/
|
||
public function syncWechatRefund(string $refundNo): array
|
||
{
|
||
$refund = $this->refundDao->get(['refund_no' => $refundNo]);
|
||
if (!$refund || $refund['pay_channel'] !== 'weixin_v3' || $refund['refund_method'] !== 'original') {
|
||
throw new \RuntimeException('未找到对应的微信 V3 原路退款单');
|
||
}
|
||
$id = (int)$refund['id'];
|
||
if (in_array($refund['state'], [RefundOrder::STATE_COMPLETED, RefundOrder::STATE_CHANNEL_SUCCEEDED,
|
||
RefundOrder::STATE_LOCAL_FAILED, RefundOrder::STATE_APPLYING], true)) {
|
||
return $this->process($id);
|
||
}
|
||
$order = $this->centerDao->get((int)$refund['order_center_id']);
|
||
if (!$order) {
|
||
throw new \RuntimeException('原订单不存在');
|
||
}
|
||
$result = $this->payServices->queryRefundStatus('weixin_v3', (string)$refund['order_no'],
|
||
$refundNo, ['order_info' => $order->toArray()]);
|
||
if (($result['status'] ?? '') !== PayServices::REFUND_SUCCESS) {
|
||
return array_merge($refund->toArray(), [
|
||
'query_status' => $result['status'] ?? PayServices::REFUND_UNKNOWN,
|
||
'query_message' => $result['message'] ?? '',
|
||
]);
|
||
}
|
||
$this->markChannelSucceeded($id, $result);
|
||
return $this->process($id);
|
||
}
|
||
|
||
/**
|
||
* 分批恢复未决退款及已完成退款的成功事件。
|
||
* @param int $limit 批次数量
|
||
* @return array
|
||
*/
|
||
public function recover(int $limit = 100): array
|
||
{
|
||
$result = ['processed' => 0, 'completed' => 0, 'manual_review' => 0, 'failed' => 0];
|
||
foreach ($this->refundDao->getRecoverable(time(), $limit) as $refund) {
|
||
try {
|
||
$updated = $this->process((int)$refund['id']);
|
||
$result['processed']++;
|
||
if (($updated['state'] ?? '') === RefundOrder::STATE_COMPLETED) {
|
||
$result['completed']++;
|
||
} elseif (($updated['state'] ?? '') === RefundOrder::STATE_MANUAL_REVIEW) {
|
||
$result['manual_review']++;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$result['failed']++;
|
||
Log::error('[RefundRecovery] 退款单 ' . ($refund['refund_no'] ?? $refund['id']) . ' 恢复失败:' . $e->getMessage());
|
||
}
|
||
}
|
||
$this->app->make(RefundEffectServices::class)->recoverSuccessEvents($limit);
|
||
return $result;
|
||
}
|
||
|
||
protected function claimChannelAction(int $refundOrderId): string
|
||
{
|
||
return $this->transaction(function () use ($refundOrderId): string {
|
||
$locked = $this->refundDao->getByIdForUpdate($refundOrderId);
|
||
if (!$locked) {
|
||
throw new \RuntimeException('退款单不存在');
|
||
}
|
||
$state = (string)$locked['state'];
|
||
if ($state === RefundOrder::STATE_SUBMITTING) {
|
||
if ((int)$locked['update_time'] + 60 > time()) {
|
||
return 'wait';
|
||
}
|
||
$this->incrementQueryAttempt($locked);
|
||
return 'query';
|
||
}
|
||
if (in_array($state, [RefundOrder::STATE_UNKNOWN, RefundOrder::STATE_CHANNEL_PROCESSING], true)) {
|
||
$this->incrementQueryAttempt($locked);
|
||
return 'query';
|
||
}
|
||
if (!in_array($state, [RefundOrder::STATE_PENDING, RefundOrder::STATE_RETRYABLE_FAILED], true)) {
|
||
return 'wait';
|
||
}
|
||
$this->refundDao->update($refundOrderId, [
|
||
'state' => RefundOrder::STATE_SUBMITTING,
|
||
'attempts' => (int)$locked['attempts'] + 1,
|
||
'next_retry_time' => 0,
|
||
'last_error' => '',
|
||
'update_time' => time(),
|
||
]);
|
||
return 'submit';
|
||
});
|
||
}
|
||
|
||
protected function incrementQueryAttempt($refund): void
|
||
{
|
||
$this->refundDao->update((int)$refund['id'], [
|
||
'attempts' => (int)$refund['attempts'] + 1,
|
||
'next_retry_time' => 0,
|
||
'update_time' => time(),
|
||
]);
|
||
}
|
||
|
||
protected function persistChannelResult(int $refundOrderId, array $result): void
|
||
{
|
||
$this->transaction(function () use ($refundOrderId, $result): void {
|
||
$current = $this->refundDao->getByIdForUpdate($refundOrderId);
|
||
if (!$current || in_array($current['state'], [RefundOrder::STATE_CHANNEL_SUCCEEDED, RefundOrder::STATE_APPLYING, RefundOrder::STATE_LOCAL_FAILED, RefundOrder::STATE_COMPLETED], true)) {
|
||
return;
|
||
}
|
||
$status = (string)($result['status'] ?? PayServices::REFUND_UNKNOWN);
|
||
if ($status === PayServices::REFUND_SUCCESS) {
|
||
$this->refundDao->update($refundOrderId, [
|
||
'state' => RefundOrder::STATE_CHANNEL_SUCCEEDED,
|
||
'channel_response' => (array)($result['response'] ?? []),
|
||
'channel_refund_id' => (string)($result['channel_refund_id'] ?? ''),
|
||
'channel_succeeded_at' => time(),
|
||
'next_retry_time' => 0,
|
||
'last_error' => '',
|
||
'update_time' => time(),
|
||
]);
|
||
return;
|
||
}
|
||
$state = $status === PayServices::REFUND_PROCESSING
|
||
? RefundOrder::STATE_CHANNEL_PROCESSING
|
||
: ($status === PayServices::REFUND_FAILED ? RefundOrder::STATE_RETRYABLE_FAILED : RefundOrder::STATE_UNKNOWN);
|
||
$attempts = (int)($current['attempts'] ?? 0);
|
||
$plan = (array)($current['context']['plan'] ?? []);
|
||
if (($plan['allow_auto_recovery'] ?? true) !== true || $attempts >= 8) {
|
||
$state = RefundOrder::STATE_MANUAL_REVIEW;
|
||
}
|
||
$this->refundDao->update($refundOrderId, [
|
||
'state' => $state,
|
||
'channel_response' => (array)($result['response'] ?? []),
|
||
'channel_refund_id' => (string)($result['channel_refund_id'] ?? ''),
|
||
'last_error' => substr((string)($result['message'] ?? ''), 0, 1000),
|
||
'next_retry_time' => $state === RefundOrder::STATE_MANUAL_REVIEW ? 0 : time() + min(3600, 30 * max(1, $attempts)),
|
||
'update_time' => time(),
|
||
]);
|
||
});
|
||
}
|
||
|
||
protected function markChannelSucceeded(int $refundOrderId, array $result): void
|
||
{
|
||
$this->transaction(function () use ($refundOrderId, $result): void {
|
||
$current = $this->refundDao->getByIdForUpdate($refundOrderId);
|
||
if (!$current || in_array($current['state'], [RefundOrder::STATE_APPLYING, RefundOrder::STATE_COMPLETED], true)) {
|
||
return;
|
||
}
|
||
$this->refundDao->update($refundOrderId, [
|
||
'state' => RefundOrder::STATE_CHANNEL_SUCCEEDED,
|
||
'channel_response' => (array)($result['response'] ?? []),
|
||
'channel_refund_id' => (string)($result['channel_refund_id'] ?? ''),
|
||
'channel_succeeded_at' => time(),
|
||
'next_retry_time' => 0,
|
||
'last_error' => '',
|
||
'update_time' => time(),
|
||
]);
|
||
});
|
||
}
|
||
|
||
protected function applyLocalEffects(array $refund): array
|
||
{
|
||
$afterSale = $this->afterSaleDao->get((int)$refund['after_sale_id']);
|
||
$order = $this->centerDao->get((int)$refund['order_center_id']);
|
||
if (!$afterSale || !$order) {
|
||
throw new \RuntimeException('退款关联的售后单或订单不存在');
|
||
}
|
||
$items = $this->itemDao->getByAfterSaleId((int)$refund['after_sale_id']);
|
||
$handler = AfterSaleRuleRegistry::getProvider('product_type', (string)$afterSale['product_type']);
|
||
if (!$handler) {
|
||
throw new \RuntimeException('产品类型未注册售后退款处理器');
|
||
}
|
||
try {
|
||
/** @var RefundEffectServices $effects */
|
||
$effects = $this->app->make(RefundEffectServices::class);
|
||
return $effects->apply($refund, $afterSale->toArray(), $order->toArray(), $items, $handler);
|
||
} catch (\Throwable $e) {
|
||
$this->refundDao->update((int)$refund['id'], [
|
||
'state' => RefundOrder::STATE_LOCAL_FAILED,
|
||
'last_error' => substr($e->getMessage(), 0, 1000),
|
||
'next_retry_time' => time() + 60,
|
||
'update_time' => time(),
|
||
]);
|
||
throw new \RuntimeException('支付渠道退款已成功,本地业务处理中断,系统将自动恢复:' . $e->getMessage(), 0, $e);
|
||
}
|
||
}
|
||
|
||
protected function prepareProductEffects(array $refund): void
|
||
{
|
||
$afterSale = $this->afterSaleDao->get((int)$refund['after_sale_id']);
|
||
$order = $this->centerDao->get((int)$refund['order_center_id']);
|
||
if (!$afterSale || !$order) {
|
||
throw new \RuntimeException('退款关联的售后单或订单不存在');
|
||
}
|
||
$items = $this->itemDao->getByAfterSaleId((int)$refund['after_sale_id']);
|
||
$handler = AfterSaleRuleRegistry::getProvider('product_type', (string)$afterSale['product_type']);
|
||
if (!$handler) {
|
||
throw new \RuntimeException('产品类型未注册售后退款处理器');
|
||
}
|
||
try {
|
||
/** @var RefundEffectServices $effects */
|
||
$effects = $this->app->make(RefundEffectServices::class);
|
||
$effects->prepare($refund, $afterSale->toArray(), $order->toArray(), $items, $handler);
|
||
} catch (\Throwable $e) {
|
||
$attempts = (int)($refund['attempts'] ?? 0) + 1;
|
||
$this->refundDao->update((int)$refund['id'], [
|
||
'state' => $attempts >= 8 ? RefundOrder::STATE_MANUAL_REVIEW : (string)$refund['state'],
|
||
'attempts' => $attempts,
|
||
'last_error' => substr('产品退款准备失败:' . $e->getMessage(), 0, 1000),
|
||
'next_retry_time' => $attempts >= 8 ? 0 : time() + min(3600, 30 * $attempts),
|
||
'update_time' => time(),
|
||
]);
|
||
throw new \RuntimeException('产品退款准备失败,尚未向支付渠道提交:' . $e->getMessage(), 0, $e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验售后退款状态、金额与实物退货收货凭据。
|
||
* @param array $afterSale 售后单
|
||
* @param string $amount 实退金额
|
||
* @return void
|
||
*/
|
||
protected function assertRefundable(array $afterSale, string $amount): void
|
||
{
|
||
$validStatus = [
|
||
'return_refund' => ['receiving'],
|
||
'refund_only' => ['audit_passed', 'receiving'],
|
||
'exchange' => ['receiving'],
|
||
'resend' => ['audit_passed'],
|
||
'repair' => ['repair_complete'],
|
||
];
|
||
$allowed = $validStatus[$afterSale['type']] ?? ['audit_passed'];
|
||
if (!in_array($afterSale['status'], $allowed, true)) {
|
||
throw new \RuntimeException('当前状态不可执行退款');
|
||
}
|
||
if (($afterSale['product_type'] ?? '') === 'normal'
|
||
&& ($afterSale['type'] ?? '') === 'return_refund'
|
||
&& (int)($afterSale['receive_time'] ?? 0) <= 0) {
|
||
throw new \RuntimeException('请先确认收到退货再执行退款');
|
||
}
|
||
if (bccomp($amount, '0.00', 2) < 0) {
|
||
throw new \RuntimeException('退款金额不能为负数');
|
||
}
|
||
// 0 元售后(申请金额与退还积分均为 0)允许实退 0,走本地退款闭环;有可退现金却实退 0 仍拦截
|
||
$hasCash = bccomp($amount, '0.00', 2) > 0;
|
||
$hasIntegral = (int)($afterSale['refund_integral'] ?? 0) > 0;
|
||
if (!$hasCash && !$hasIntegral
|
||
&& bccomp((string)($afterSale['refund_price'] ?? '0.00'), '0.00', 2) > 0) {
|
||
throw new \RuntimeException('退款金额或退款积分必须大于零');
|
||
}
|
||
if (bccomp($amount, (string)($afterSale['refund_price'] ?? '0.00'), 2) > 0) {
|
||
throw new \RuntimeException('实际退款金额不能超过售后申请金额');
|
||
}
|
||
}
|
||
|
||
protected function normalizePlan(array $plan): array
|
||
{
|
||
$plan = array_merge([
|
||
'channel_mode' => 'auto',
|
||
'restore_inventory' => true,
|
||
'allow_auto_recovery' => true,
|
||
], $plan);
|
||
if (!in_array($plan['channel_mode'], ['auto', 'local', 'manual'], true)) {
|
||
throw new \RuntimeException('产品退款方案 channel_mode 无效');
|
||
}
|
||
$plan['restore_inventory'] = (bool)$plan['restore_inventory'];
|
||
$plan['allow_auto_recovery'] = (bool)$plan['allow_auto_recovery'];
|
||
return $plan;
|
||
}
|
||
|
||
/**
|
||
* 兼容迁移前已创建但尚未退款的售后单。
|
||
*/
|
||
protected function materializeLegacyItems(array $afterSale): array
|
||
{
|
||
$snapshot = (array)($afterSale['product_snapshot'] ?? []);
|
||
$sourceItems = $snapshot['items'] ?? $snapshot;
|
||
$rows = [];
|
||
foreach ((array)$sourceItems as $index => $item) {
|
||
$cartId = (int)($item['cart_id'] ?? 0);
|
||
$rows[] = [
|
||
'after_sale_id' => (int)$afterSale['id'],
|
||
'order_item_key' => $cartId > 0 ? 'cart:' . $cartId : 'legacy:' . $index,
|
||
'cart_id' => $cartId,
|
||
'product_id' => (int)($item['product_id'] ?? 0),
|
||
'sku_unique' => (string)($item['unique'] ?? $item['attr_info']['unique'] ?? ''),
|
||
'product_type' => (string)$afterSale['product_type'],
|
||
'activity_type' => (string)($afterSale['activity_type'] ?? ''),
|
||
'refund_num' => (int)($item['refund_num'] ?? $item['cart_num'] ?? 0),
|
||
'refund_amount' => '0.00',
|
||
'refund_integral' => 0,
|
||
'snapshot' => $item,
|
||
'add_time' => time(),
|
||
];
|
||
}
|
||
if (!$rows) {
|
||
throw new \RuntimeException('售后商品明细缺失,无法安全执行退款');
|
||
}
|
||
$this->itemDao->saveAll($rows);
|
||
return $this->itemDao->getByAfterSaleId((int)$afterSale['id']);
|
||
}
|
||
}
|