CRMEB/backend/app/services/order/RefundOrderServices.php
吴昊天 137b719187 fix(售后): 修复确认收货状态与退款流水号覆盖
- 退货退款/换货确认收货后按 receive_time 展示待退款/待补发,并切换确认收货、执行退款、补发按钮
- 后台填写的退款流水号写入售后单,退款完成时优先使用,不再被系统退款单号覆盖
- 退款流水号限制 64 个字符
2026-10-23 14:14:59 +08:00

597 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 || ((int)($afterSale['refund_integral'] ?? 0) <= 0 && bccomp($amount, '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']);
}
}