CRMEB/backend/app/services/order/AfterSaleServices.php
2026-10-28 10:24:30 +08:00

2877 lines
123 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
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\services\order;
use crmeb\basic\BaseService;
use app\dao\order\AfterSaleDao;
use app\dao\order\AfterSaleItemDao;
use app\dao\order\OrderCenterDao;
use app\model\order\OrderCenter;
use app\services\order\rule\AfterSaleRuleResolver;
use app\services\shipping\ExpressServices;
use crmeb\trade\order\aftersale\AfterSaleHandlerInterface;
use crmeb\trade\order\aftersale\AfterSaleRuleRegistry;
use think\facade\Db;
use think\facade\Log;
/**
* 售后核心服务
* 负责售后单的创建、审核、退款、退货、补发、维修等全流程管理
*
* 职责边界:
* - 状态管理:负责 eb_after_sale 表的状态更新和时间戳记录
* - 路由分发:根据产品类型获取对应的 AfterSaleHandlerInterface 实现
* - 规则校验:通过 AfterSaleRuleResolver 做二维规则校验(产品类型 × 活动类型)
*/
class AfterSaleServices extends BaseService
{
/**
* @var AfterSaleDao
*/
protected $dao;
/**
* @var AfterSaleRuleResolver
*/
protected AfterSaleRuleResolver $ruleResolver;
/**
* @var OrderCenterServices
*/
protected OrderCenterServices $centerServices;
/** @var OrderCenterDao */
protected OrderCenterDao $centerDao;
/**
* 初始化
*/
protected function initialize(): void
{
$this->dao = $this->app->make(AfterSaleDao::class);
$this->ruleResolver = $this->app->make(AfterSaleRuleResolver::class);
$this->centerServices = $this->app->make(OrderCenterServices::class);
$this->centerDao = $this->app->make(OrderCenterDao::class);
}
// ===== 规则校验 =====
/**
* 获取可用售后类型
* v8.1.1 增强:下发商品级 refundable_num,支持按 cart_id 选择售后范围
* - 保留 allowed/types/deadline/max_num 字段(向后兼容)
* - 新增 items 字段:每个 cart_id 的可退数量和商品信息
* @param string $orderNo
* @return array
*/
public function getAvailableTypes(string $orderNo): array
{
$center = $this->centerServices->getOne(['order_no' => $orderNo]);
if (empty($center)) {
return [];
}
if (is_object($center)) {
$center = $center->toArray();
}
// 校验订单支付状态:未付款/已取消/已删除/已退款的订单不允许售后
$eligibility = $this->checkOrderEligibility($center);
if (!$eligibility['eligible']) {
return ['allowed' => false, 'reason' => $eligibility['reason']];
}
// 商品级售后:不再做"整单已有进行中售后"拦截
// 由前端根据 items[].refundable_num 判断商品可选性,由 apply() 在提交时按 cart_id 校验
$sourceOrder = $this->getSourceOrder($center);
$rule = $this->ruleResolver->resolve($center, $sourceOrder);
if (!$rule['allow']) {
return ['allowed' => false, 'reason' => $rule['reject_reason'] ?? ''];
}
// 加载商品级可退数量
$items = $this->loadRefundableItems($orderNo);
// 整单剩余可退数量合计(向后兼容 max_num 字段)
$totalRefundable = 0;
$hasCash = false;
$hasIntegral = false;
foreach ($items as $item) {
$totalRefundable += (int)($item['refundable_num'] ?? 0);
// 现金维度看分摊后的实付金额(全额券/纯积分抵扣单实付为 0 时不可退现金)
if ((float)($item['paid_amount_total'] ?? $item['true_price'] ?? 0) > 0) {
$hasCash = true;
}
if ((int)($item['refundable_integral'] ?? 0) > 0) {
$hasIntegral = true;
}
}
if ($totalRefundable <= 0) {
return ['allowed' => false, 'reason' => '当前订单暂无可退商品'];
}
return [
'allowed' => true,
'types' => $this->filterTypesByDelivery($rule['available_types'] ?? [], $center),
'deadline' => $rule['deadline'] ?? 0,
'max_num' => $totalRefundable,
// 用户实际支付的运费作为可退款金额的一部分下发,前端用于展示退款上限。
'pay_postage' => number_format((float)($center['pay_postage'] ?? 0), 2, '.', ''),
// 退款维度:现金与积分是两个独立维度,可同时存在(积分+余额混合支付订单)
'refund_targets' => ['has_cash' => $hasCash, 'has_integral' => $hasIntegral],
'items' => $items,
];
}
/**
* 按发货状态收敛售后类型
* 未发货订单(delivery_time=0)只能申请仅退款,避免用户选择退货退款/换货等需要先收货的类型。
* @param array $types 规则声明的可用类型
* @param array $center 中台订单
* @return array
*/
protected function filterTypesByDelivery(array $types, array $center): array
{
if ((int)($center['delivery_time'] ?? 0) <= 0) {
return ['refund_only'];
}
return array_values($types);
}
/**
* 加载订单商品级可退数量
* 查询订单的全部 order_items,聚合已申请售后单的 refund_num,计算每个 cart_id 的剩余可退数量
* @param string $orderNo 订单号
* @return array 返回 [{cart_id, product_id, store_name, image, sku, cart_num, refunded_num, refundable_num, true_price}, ...]
*/
protected function loadRefundableItems(string $orderNo): array
{
// 获取订单明细(从中台 extra_info.items 或源订单购物车表加载)
$center = $this->centerServices->getOne(['order_no' => $orderNo]);
if (empty($center)) {
return [];
}
if (is_object($center)) {
$center = $center->toArray();
}
// 积分活动订单:提取每件退还积分参考价(用于售后退款积分计算与前端展示)
$rawExtra = $center['extra_info'] ?? [];
$extra = is_array($rawExtra) ? $rawExtra : (json_decode((string)$rawExtra, true) ?: []);
$integralUnitPrice = (int)($extra['activity_info']['integral_unit_price'] ?? 0);
$orderItems = $this->centerServices->loadOrderItems($center);
if (empty($orderItems)) {
return [];
}
// 查询已申请的有效售后单,按 cart_id 聚合已申请数量
$validAfterSales = $this->dao->getValidByOrderNo($orderNo);
$details = $this->app->make(AfterSaleItemDao::class)
->getQuantitiesByAfterSaleIds(array_column($validAfterSales, 'id'));
$detailsBySale = [];
foreach ($details as $detail) {
$detailsBySale[(int)$detail['after_sale_id']][] = $detail;
}
$refundedNumMap = []; // cart_id => 已申请数量合计
foreach ($validAfterSales as $row) {
$saleDetails = $detailsBySale[(int)($row['id'] ?? 0)] ?? [];
if ($saleDetails) {
foreach ($saleDetails as $detail) {
$cid = (int)$detail['cart_id'];
$refundedNumMap[$cid] = ($refundedNumMap[$cid] ?? 0) + (int)$detail['refund_num'];
}
continue;
}
// 老售后单从服务端保存的快照读取精确数量。
$snapshot = $row['product_snapshot'] ?? [];
if (is_string($snapshot)) {
$snapshot = json_decode($snapshot, true) ?: [];
}
$snapItems = is_array($snapshot) ? ($snapshot['items'] ?? $snapshot) : [];
$hasSnapshot = false;
foreach ($snapItems as $snapItem) {
$cid = (int)($snapItem['cart_id'] ?? 0);
if ($cid > 0) {
$hasSnapshot = true;
$refundedNumMap[$cid] = ($refundedNumMap[$cid] ?? 0)
+ (int)($snapItem['refund_num'] ?? $snapItem['cart_num'] ?? 0);
}
}
if ($hasSnapshot) {
continue;
}
$cartIds = $row['cart_ids'];
if (is_string($cartIds)) {
$cartIds = json_decode($cartIds, true) ?: [];
}
if (!is_array($cartIds) || empty($cartIds)) {
// 兼容旧售后单(无 cart_ids):按 product_snapshot.items 反推
$snapshot = $row['product_snapshot'] ?? null;
if (is_string($snapshot)) {
$snapshot = json_decode($snapshot, true) ?: [];
}
$snapItems = $snapshot['items'] ?? ($snapshot ?? []);
foreach ($snapItems as $snapItem) {
$cid = (int)($snapItem['cart_id'] ?? 0);
if ($cid > 0) {
$refundedNumMap[$cid] = ($refundedNumMap[$cid] ?? 0) + (int)($snapItem['cart_num'] ?? 0);
}
}
continue;
}
// 新格式:cart_ids 数组 + refund_num 平均分摊到每个 cart_id
// 简化处理:refund_num 是整笔售后单的总数量,若 cart_ids 只有一个元素则全归该 cart_id
$refundNum = (int)($row['refund_num'] ?? 0);
if (count($cartIds) === 1) {
$cid = (int)$cartIds[0];
$refundedNumMap[$cid] = ($refundedNumMap[$cid] ?? 0) + $refundNum;
} else {
// 缺少明细和快照的旧多商品售后无法可靠分配,保守占满涉及的商品,禁止重复退货。
foreach ($cartIds as $cid) {
$cid = (int)$cid;
foreach ($orderItems as $orderItem) {
if ((int)($orderItem['cart_id'] ?? 0) === $cid) {
$refundedNumMap[$cid] = (int)($orderItem['cart_num'] ?? 0);
}
}
}
}
}
// 组装返回
$deductionIntegral = $integralUnitPrice > 0 ? [] : $this->allocateDeductionIntegral($center, $orderItems);
$paidAmounts = $this->allocatePaidAmounts($center, $orderItems);
$isActivityOrder = !in_array((string)($center['activity_type'] ?? ''), ['', 'normal'], true);
$result = [];
foreach ($orderItems as $item) {
$cartId = (int)($item['cart_id'] ?? 0);
if ($cartId <= 0) {
continue; // 缺失 cart_id 的明细无法做商品级售后
}
$cartNum = (int)($item['cart_num'] ?? 1);
$refundedNum = $refundedNumMap[$cartId] ?? 0;
$refundableNum = max(0, $cartNum - $refundedNum);
$sku = '';
if (!empty($item['sku'])) {
$sku = (string)$item['sku'];
} elseif (!empty($item['attr_info']['suk'])) {
$sku = (string)$item['attr_info']['suk'];
}
$skuUnique = (string)($item['unique'] ?? $item['attr_info']['unique'] ?? '');
$result[] = [
// cart_id 使用字符串跨端传输,避免 JavaScript 对 BIGINT 购物车 ID 的数字精度丢失。
'cart_id' => (string)$cartId,
'product_id' => (int)($item['product_id'] ?? 0),
'store_name' => (string)($item['store_name'] ?? ''),
'image' => (string)($item['image'] ?? ''),
'sku' => $sku,
'sku_unique' => $skuUnique,
'cart_num' => $cartNum,
'refunded_num' => $refundedNum,
'refundable_num' => $refundableNum,
'true_price' => (string)($item['true_price'] ?? $item['price'] ?? '0.00'),
// 商品信息卡展示规格原价;活动订单展示活动价(true_price 为等级/会员优惠后单价)
'original_price' => $isActivityOrder
? (string)($item['true_price'] ?? $item['price'] ?? '0.00')
: (string)($item['price'] ?? $item['attr_info']['price'] ?? $item['true_price'] ?? '0.00'),
// 分摊后的整行实付现金(商品原价 - 优惠券/积分抵扣/促销等优惠),退款现金上限按此累计
'paid_amount_total' => $paidAmounts[$cartId] ?? '0.00',
// 积分参考价:非积分订单为 0,仅积分活动订单有值
'integral_unit_price' => $integralUnitPrice,
'deduction_integral_total' => $deductionIntegral[$cartId] ?? 0,
'refundable_integral' => $integralUnitPrice > 0
? $integralUnitPrice * $refundableNum
: $this->deductionIntegralForQuantity($deductionIntegral[$cartId] ?? 0, $cartNum, $refundedNum, $refundableNum),
'snapshot' => $item,
];
}
return $result;
}
/**
* 将普通订单抵扣积分按商品金额分摊,累计取整保证尾差不丢失。
* @param array $center 中台订单
* @param array $items 商品快照
* @return array cart_id => 整行抵扣积分
*/
protected function allocateDeductionIntegral(array $center, array $items): array
{
$integral = max(0, (int)($center['use_integral_num'] ?? 0));
if (!$integral) {
return [];
}
$weights = [];
foreach ($items as $item) {
$cid = (int)($item['cart_id'] ?? 0);
$weights[$cid] = bcmul((string)($item['true_price'] ?? $item['price'] ?? '0'), (string)($item['cart_num'] ?? 1), 4);
}
$total = array_reduce($weights, fn ($sum, $weight) => bcadd($sum, $weight, 4), '0');
if (bccomp($total, '0', 4) <= 0) {
foreach ($items as $item) {
$weights[(int)($item['cart_id'] ?? 0)] = (string)($item['cart_num'] ?? 1);
}
$total = (string)array_sum($weights);
}
$allocated = 0;
$cumulative = '0';
$result = [];
foreach ($weights as $cid => $weight) {
$cumulative = bcadd($cumulative, $weight, 4);
$target = bccomp($total, '0', 4) > 0
? (int)bcdiv(bcmul((string)$integral, $cumulative, 4), $total, 0) : 0;
$result[$cid] = $target - $allocated;
$allocated = $target;
}
return $result;
}
/**
* 计算选中数量应退的抵扣积分,逐次退款累计取整。
* @param int $total 整行积分
* @param int $quantity 整行数量
* @param int $refunded 已占用数量
* @param int $selected 本次数量
* @return int
*/
protected function deductionIntegralForQuantity(int $total, int $quantity, int $refunded, int $selected): int
{
if ($quantity <= 0 || $total <= 0) {
return 0;
}
$before = min($quantity, max(0, $refunded));
$after = min($quantity, $before + max(0, $selected));
return (int)bcdiv(bcmul((string)$total, (string)$after, 0), (string)$quantity, 0)
- (int)bcdiv(bcmul((string)$total, (string)$before, 0), (string)$quantity, 0);
}
/**
* 将订单商品实付现金按原价占比分摊到行,累计取整保证合计等于实付总额。
* 商品实付现金 = 实付金额 - 实付运费,优惠券/积分抵扣/促销等优惠已在该口径中扣除,
* 避免退款上限按商品原价累计而超过用户实际支付的现金(积分抵扣部分以退还积分维度返还)。
* @param array $center 中台订单
* @param array $items 商品快照
* @return array cart_id => 整行实付现金(字符串,2 位)
*/
protected function allocatePaidAmounts(array $center, array $items): array
{
$paidGoods = bcsub((string)($center['pay_price'] ?? '0'), (string)($center['pay_postage'] ?? '0'), 2);
if (bccomp($paidGoods, '0', 2) < 0) {
$paidGoods = '0.00';
}
$weights = [];
foreach ($items as $item) {
$cid = (int)($item['cart_id'] ?? 0);
$weights[$cid] = bcmul((string)($item['true_price'] ?? $item['price'] ?? '0'), (string)($item['cart_num'] ?? 1), 4);
}
$total = array_reduce($weights, fn ($sum, $weight) => bcadd($sum, $weight, 4), '0');
if (bccomp($total, '0', 4) <= 0) {
return array_fill_keys(array_map('intval', array_keys($weights)), '0.00');
}
// 实付现金异常大于商品原价合计时按原价封顶
if (bccomp($paidGoods, $total, 4) > 0) {
$paidGoods = $total;
}
$allocated = '0';
$cumulative = '0';
$result = [];
foreach ($weights as $cid => $weight) {
$cumulative = bcadd($cumulative, $weight, 4);
$target = bcdiv(bcmul($paidGoods, $cumulative, 4), $total, 2);
$result[$cid] = bcsub($target, $allocated, 2);
$allocated = $target;
}
return $result;
}
/**
* 计算选中数量应退的实付现金,逐次退款累计取整(与 deductionIntegralForQuantity 同模式)。
* @param string $rowTotal 整行实付现金
* @param int $quantity 整行数量
* @param int $refunded 已占用数量
* @param int $selected 本次数量
* @return string
*/
protected function paidAmountForQuantity(string $rowTotal, int $quantity, int $refunded, int $selected): string
{
if ($quantity <= 0 || bccomp($rowTotal, '0', 2) <= 0) {
return '0.00';
}
$before = min($quantity, max(0, $refunded));
$after = min($quantity, $before + max(0, $selected));
if ($after <= $before) {
return '0.00';
}
// 全程整数分运算,避免中途小数截断造成少退;差值除回 100 转元
$totalCents = (int)bcmul($rowTotal, '100', 0);
$centsAt = fn (int $num): int => intdiv($totalCents * $num, $quantity);
return bcdiv((string)($centsAt($after) - $centsAt($before)), '100', 2);
}
/**
* 按行取退款现金:有分摊实付数据时按数量累计取额,否则回退商品原价(外部直传原始明细的场景)。
* @param array $item 商品行(loadRefundableItems 结构或原始明细)
* @param int $quantity 本次数量
* @return string
*/
protected function rowPaidAmountForQuantity(array $item, int $quantity): string
{
$quantity = max(0, $quantity);
$cartNum = max(1, (int)($item['cart_num'] ?? 0));
if (isset($item['paid_amount_total'])) {
return $this->paidAmountForQuantity(
(string)$item['paid_amount_total'],
$cartNum,
(int)($item['refunded_num'] ?? 0),
min($quantity, $cartNum)
);
}
return bcmul((string)($item['true_price'] ?? $item['price'] ?? '0'), (string)min($quantity, $cartNum), 2);
}
/**
* 获取订单当前可退现金上限
* 按可退明细逐项累加,避免直接用订单实付金额减已退金额导致和实际 SKU 可退数量不一致。
* 商品部分按分摊后的实付金额(原价扣减优惠券/积分抵扣/促销)累计,现金上限不会超过实付总额。
*/
public function getMaxRefundAmount(string $orderNo): string
{
$center = $this->centerServices->getOne(['order_no' => $orderNo]);
if (empty($center)) {
return '0.00';
}
if (is_object($center)) {
$center = $center->toArray();
}
$items = $this->loadRefundableItems($orderNo);
$maxRefundPrice = '0.00';
foreach ($items as $item) {
$refundNum = (int)($item['refundable_num'] ?? 0);
if ($refundNum <= 0) {
continue;
}
$maxRefundPrice = bcadd(
$maxRefundPrice,
$this->rowPaidAmountForQuantity($item, $refundNum),
2
);
}
// 后台整单退款入口也必须把用户实付运费纳入可退金额上限。
return bcadd($maxRefundPrice, $this->calculateRefundPostage($center, $items), 2);
}
/**
* 按商品实付金额占比分摊订单已支付运费。
*
* 商品级售后允许部分商品/部分数量退款,不能每次都直接加整笔运费,否则多次售后会重复退款。
* 权重与现金退款口径一致:优先用分摊后的实付金额,无分摊数据时回退商品原价。
* @param array $center 中台订单
* @param array $allItems 订单全部商品明细,需含 cart_num/true_price
* @param array $selectedItems 本次售后商品明细,含 refund_num;省略时使用各商品 refundable_num
* @return string 本次售后最多可退的运费
*/
protected function calculateRefundPostage(array $center, array $allItems, array $selectedItems = []): string
{
$postage = max(0, (float)($center['pay_postage'] ?? 0));
if ($postage <= 0 || empty($allItems)) {
return '0.00';
}
$itemMap = [];
$totalProductAmount = 0.0;
foreach ($allItems as $item) {
$cartId = (int)($item['cart_id'] ?? 0);
$cartNum = max(0, (int)($item['cart_num'] ?? 0));
if ($cartId > 0) {
$itemMap[$cartId] = $item;
}
// 分母用整行实付金额(未被已退数量扣减),保证比例基准是整单
$totalProductAmount += isset($item['paid_amount_total'])
? (float)$item['paid_amount_total']
: (float)($item['true_price'] ?? $item['price'] ?? 0) * $cartNum;
}
if (empty($selectedItems)) {
$selectedItems = $allItems;
}
$selectedProductAmount = 0.0;
foreach ($selectedItems as $item) {
$cartId = (int)($item['cart_id'] ?? 0);
$detail = $itemMap[$cartId] ?? $item;
$quantity = array_key_exists('refund_num', $item)
? (int)$item['refund_num']
: (int)($item['refundable_num'] ?? $item['cart_num'] ?? 0);
$selectedProductAmount += (float)$this->rowPaidAmountForQuantity($detail, $quantity);
}
if ($selectedProductAmount <= 0) {
return '0.00';
}
if ($totalProductAmount <= 0 || $selectedProductAmount >= $totalProductAmount) {
return number_format($postage, 2, '.', '');
}
$allocated = round($postage * ($selectedProductAmount / $totalProductAmount), 2);
return number_format(min($postage, max(0, $allocated)), 2, '.', '');
}
/**
* 将售后总金额/积分固化到商品行,尾差归最后一行。
* 这里只在申请时计算一次,后续退款重试直接读取持久化结果。
*/
protected function buildAfterSaleItems(
int $afterSaleId,
array $items,
array $itemDetailMap,
string $productType,
string $activityType,
string $refundPrice,
int $refundIntegral
): array {
$selected = [];
$totalWeight = 0;
foreach ($items as $item) {
$cartId = (int)($item['cart_id'] ?? 0);
$detail = $itemDetailMap[$cartId] ?? null;
if (!$detail) {
continue;
}
$num = (int)($item['refund_num'] ?? 0);
if (isset($detail['paid_amount_total'])) {
// 实付口径权重:按本次数量占整行数量的比例取整行实付金额
$cartNum = max(1, (int)($detail['cart_num'] ?? 1));
$weight = max(0, (int)round((float)$detail['paid_amount_total'] * 100 * $num / $cartNum));
} else {
$weight = max(0, (int)round((float)$detail['true_price'] * 100) * $num);
}
$integralWeight = (int)($detail['selected_integral'] ?? 0);
$selected[] = compact('cartId', 'detail', 'num', 'weight', 'integralWeight');
$totalWeight += $weight;
}
$amountCents = max(0, (int)round((float)$refundPrice * 100));
$remainingAmount = $amountCents;
$remainingIntegral = max(0, $refundIntegral);
$totalIntegralWeight = array_sum(array_column($selected, 'integralWeight'));
$cumulativeIntegralWeight = 0;
$allocatedIntegral = 0;
$rows = [];
$lastIndex = count($selected) - 1;
foreach ($selected as $index => $entry) {
if ($index === $lastIndex) {
$lineAmount = $remainingAmount;
$lineIntegral = $remainingIntegral;
} else {
$ratio = $totalWeight > 0 ? $entry['weight'] / $totalWeight : 1 / max(1, count($selected));
$lineAmount = min($remainingAmount, (int)floor($amountCents * $ratio));
$lineIntegral = min($remainingIntegral, (int)floor($refundIntegral * $ratio));
}
if ($totalIntegralWeight > 0) {
// 积分累计分摊,零积分商品不能承接其他行的整数尾差。
$cumulativeIntegralWeight += $entry['integralWeight'];
$integralTarget = (int)bcdiv(bcmul((string)$refundIntegral, (string)$cumulativeIntegralWeight, 0), (string)$totalIntegralWeight, 0);
$lineIntegral = $integralTarget - $allocatedIntegral;
$allocatedIntegral = $integralTarget;
}
$remainingAmount -= $lineAmount;
$remainingIntegral -= $lineIntegral;
$detail = $entry['detail'];
$snapshot = is_array($detail['snapshot'] ?? null) ? $detail['snapshot'] : $detail;
$snapshot['refund_num'] = $entry['num'];
$rows[] = [
'after_sale_id' => $afterSaleId,
'order_item_key' => 'cart:' . $entry['cartId'],
'cart_id' => $entry['cartId'],
'product_id' => (int)($detail['product_id'] ?? 0),
'sku_unique' => (string)($detail['sku_unique'] ?? ''),
'product_type' => $productType,
'activity_type' => $activityType,
'refund_num' => $entry['num'],
'refund_amount' => number_format($lineAmount / 100, 2, '.', ''),
'refund_integral' => $lineIntegral,
'snapshot' => $snapshot,
'add_time' => time(),
];
}
return $rows;
}
/**
* 获取源表订单数据
* 通过产品类型 Handler 的 getOrderExtend 读取扩展表订单,失败时返回空数组
* @param array $center 中台订单数据
* @return array
*/
protected function getSourceOrder(array $center): array
{
$productType = $center['product_type'] ?? '';
if (empty($productType)) {
return [];
}
try {
$registry = app(\crmeb\trade\product\ProductTypeRegistry::class);
$handler = $registry->get($productType);
if ($handler) {
$extend = $handler->getOrderExtend($center['order_no'] ?? '');
return is_array($extend) ? $extend : [];
}
} catch (\Throwable $e) {
// 扩展表读取失败时不约束
}
return [];
}
/**
* 校验订单是否具备售后资格
* 未付款/已取消/已删除的订单不允许申请售后(未付款无款可退)
* @param array $center 中台订单数据
* @return array ['eligible' => bool, 'reason' => string]
*/
protected function checkOrderEligibility(array $center): array
{
$statusCode = $center['status_code'] ?? '';
$payStatus = (int)($center['pay_status'] ?? 0);
// 全额退款订单不可再次售后;部分退款订单仍允许对剩余商品继续申请。
if ($statusCode === OrderCenter::STATUS_REFUNDED && $payStatus !== OrderCenter::PAY_PARTIAL_REFUND) {
return ['eligible' => false, 'reason' => '订单已退款,无法申请售后'];
}
// 未支付订单不可售后(没付款无法退款)
if (!in_array($payStatus, [OrderCenter::PAY_PAID, OrderCenter::PAY_PARTIAL_REFUND], true)) {
return ['eligible' => false, 'reason' => '订单未支付,无法申请售后'];
}
// 已取消/已删除订单不可售后
if (in_array($statusCode, [OrderCenter::STATUS_CANCELED, OrderCenter::STATUS_DELETED], true)) {
return ['eligible' => false, 'reason' => '订单状态异常,无法申请售后'];
}
return ['eligible' => true, 'reason' => ''];
}
// ===== 表单与详情(数据驱动前端) =====
/**
* 售后类型文案映射
* 前端可优先用 $t() 多语言渲染,此文案作为数据层语义补充
*/
protected const TYPE_TEXT_MAP = [
'refund_only' => '仅退款',
'return_refund' => '退货退款',
'exchange' => '换货',
'resend' => '补发',
'repair' => '维修',
];
/**
* 售后状态文案映射
*/
protected const STATUS_TEXT_MAP = [
'pending_audit' => '待审核',
'audit_passed' => '审核通过',
'audit_rejected' => '审核拒绝',
'canceled' => '已取消',
'refunding' => '退款中',
'refunded' => '已退款',
'pending_return' => '待退货',
'receiving' => '待收货',
'repairing' => '维修中',
'repair_complete'=> '维修完成',
'resending' => '补发中',
'completed' => '已完成',
];
/**
* 解析售后单展示状态文案
* 退货退款/换货在商家确认收货后仍沿用 receiving 状态(退款/补发动作依赖该状态),
* 展示层按 receive_time 细分为“待退款”“待补发”,避免确认收货后仍显示“待收货”。
* @param array|object $afterSale 售后单数据
* @return string
*/
protected function resolveStatusText($afterSale): string
{
$data = is_object($afterSale) ? $afterSale->toArray() : (array)$afterSale;
$status = (string)($data['status'] ?? '');
$type = (string)($data['type'] ?? '');
if ($status === 'receiving' && (int)($data['receive_time'] ?? 0) > 0) {
if ($type === 'return_refund') {
return '待退款';
}
if ($type === 'exchange') {
return '待补发';
}
}
return self::STATUS_TEXT_MAP[$status] ?? $status;
}
/**
* 售后状态分组映射
* 用于用户端列表 tab 筛选:processing=处理中,completed=已完成,canceled=已取消
* repair_complete 归入处理中(维修完成待用户确认收货,流程未终结)
*/
protected const STATUS_GROUP_MAP = [
'processing' => ['pending_audit', 'audit_passed', 'refunding', 'pending_return', 'receiving', 'repairing', 'resending', 'repair_complete'],
'completed' => ['refunded', 'completed'],
'canceled' => ['canceled', 'audit_rejected'],
];
/**
* 获取售后申请表单配置
* 按产品类型 Handler 返回动态字段,前端据此渲染申请表单
* @param string $orderNo 订单号
* @param string $type 售后类型 refund_only/return_refund/exchange/resend/repair
* @return array ['fields'=>[...], 'type_text'=>string]
*/
public function getFormConfig(string $orderNo, string $type): array
{
$center = $this->centerServices->getOne(['order_no' => $orderNo]);
$productType = 'normal';
if (!empty($center)) {
if (is_object($center)) {
$center = $center->toArray();
}
$productType = $center['product_type'] ?? 'normal';
}
// 取产品类型售后处理器,调 getFormConfig 获取动态字段
$handler = AfterSaleRuleRegistry::getProvider('product_type', $productType);
$config = $handler ? $handler->getFormConfig($type) : ['fields' => []];
// 退款标的物:现金与积分是两个独立维度,可同时存在(如积分+余额混合支付订单)
// 根据订单实际含有的退款维度追加/移除字段,而非用 refund_target 二选一替换
$targets = $this->calcRefundTargets($orderNo);
$fields = $config['fields'] ?? [];
// 含积分:追加"退还积分"字段(与退款金额字段并存,非替换)
if ($targets['has_integral']) {
$fields = $this->appendRefundIntegralField($fields);
}
// 无现金可退(纯积分或 0 元单):移除"申请退款金额"字段,避免必填 0 金额干扰申请
if (!$targets['has_cash']) {
$fields = $this->removeRefundPriceField($fields);
}
$config['fields'] = $fields;
$config['type_text'] = self::TYPE_TEXT_MAP[$type] ?? $type;
// 下发退款维度,前端据此渲染提示条(不再依赖 refund_target 二选一)
$config['refund_targets'] = $targets;
return $config;
}
/**
* 计算订单退款标的物维度
* 现金与积分是两个独立维度:现金维度看可退商品的 true_price,积分维度看 integral_unit_price。
* 两者可同时为 true(积分+余额混合支付订单),避免用单一 refund_target 二选一导致漏退。
* @param string $orderNo 订单号
* @return array ['has_cash' => bool, 'has_integral' => bool]
*/
protected function calcRefundTargets(string $orderNo): array
{
$items = $this->loadRefundableItems($orderNo);
$hasCash = false;
$hasIntegral = false;
foreach ($items as $item) {
// 无可退数量的商品不参与退款维度判断
if ((int)($item['refundable_num'] ?? 0) <= 0) {
continue;
}
// 现金维度看分摊后的实付金额(原价扣减优惠券/积分抵扣/促销)
if ((float)($item['paid_amount_total'] ?? $item['true_price'] ?? 0) > 0) {
$hasCash = true;
}
if ((int)($item['refundable_integral'] ?? 0) > 0) {
$hasIntegral = true;
}
}
return ['has_cash' => $hasCash, 'has_integral' => $hasIntegral];
}
/**
* 向表单字段追加"退还积分"字段
* 退款金额与退还积分并存(积分+余额混合支付订单),故为追加而非替换
* @param array $fields 表单字段数组
* @return array
*/
protected function appendRefundIntegralField(array $fields): array
{
// 已存在退还积分字段则跳过,避免重复追加
foreach ($fields as $field) {
if (($field['key'] ?? '') === 'refund_integral') {
return $fields;
}
}
$fields[] = [
'key' => 'refund_integral',
'label' => '退还积分',
'type' => 'number',
'required' => true,
'placeholder' => '请输入退还积分',
];
return $fields;
}
/**
* 从表单字段移除"申请退款金额"字段
* 纯积分订单无现金可退,移除退款金额字段避免前端展示无用输入
* @param array $fields 表单字段数组
* @return array
*/
protected function removeRefundPriceField(array $fields): array
{
return array_values(array_filter($fields, function ($field) {
return ($field['key'] ?? '') !== 'refund_price';
}));
}
/**
* 为售后商品快照补充原价
* 旧售后单快照只保存 true_price(等级/会员优惠后单价),详情商品卡需展示规格原价;
* 新快照已带 original_price,只有缺失时才查订单明细回填。
* @param array $snapshot 售后商品快照
* @param string $orderNo 订单号
* @return array
*/
protected function enrichSnapshotOriginalPrice(array $snapshot, string $orderNo): array
{
$items = $snapshot['items'] ?? [];
if (!is_array($items) || empty($items)) {
return $snapshot;
}
$needEnrich = false;
foreach ($items as $item) {
if (!isset($item['original_price']) || $item['original_price'] === '') {
$needEnrich = true;
break;
}
}
if (!$needEnrich || $orderNo === '') {
return $snapshot;
}
$center = $this->centerServices->getOne(['order_no' => $orderNo]);
if (empty($center)) {
return $snapshot;
}
if (is_object($center)) {
$center = $center->toArray();
}
$orderItems = $this->centerServices->loadOrderItems($center);
$itemMap = [];
foreach ($orderItems as $orderItem) {
$itemMap[(string)($orderItem['cart_id'] ?? '')] = $orderItem;
}
$isActivityOrder = !in_array((string)($center['activity_type'] ?? ''), ['', 'normal'], true);
foreach ($items as &$item) {
if (isset($item['original_price']) && $item['original_price'] !== '') {
continue;
}
$orderItem = $itemMap[(string)($item['cart_id'] ?? '')] ?? [];
$item['original_price'] = $isActivityOrder
? (string)($orderItem['true_price'] ?? $item['true_price'] ?? '0.00')
: (string)($orderItem['price'] ?? $orderItem['attr_info']['price'] ?? $item['true_price'] ?? '0.00');
}
unset($item);
$snapshot['items'] = $items;
return $snapshot;
}
/**
* 获取售后详情
* 含售后单信息、时间线、用户可操作按钮,前端详情模式据此渲染
* @param string $afterSaleNo 售后单号
* @return array
*/
public function getDetail(string $afterSaleNo): array
{
$afterSale = $this->dao->getByNo($afterSaleNo);
if (empty($afterSale)) {
return [];
}
$type = $afterSale['type'] ?? '';
$status = $afterSale['status'] ?? '';
// 构造时间线(按售后类型不同节点不同,终态特殊处理)
$timeline = $this->buildTimeline($afterSale);
// 旧售后快照只有 true_price(优惠后),从订单商品明细回填原价供商品卡展示
$productSnapshot = $afterSale['product_snapshot'] ?? [];
if (is_string($productSnapshot)) {
$productSnapshot = json_decode($productSnapshot, true) ?: [];
}
$productSnapshot = $this->enrichSnapshotOriginalPrice(
is_array($productSnapshot) ? $productSnapshot : [],
(string)($afterSale['order_no'] ?? '')
);
// 获取用户可操作按钮(由产品类型 Handler 提供)
$productType = $afterSale['product_type'] ?? 'normal';
$handler = AfterSaleRuleRegistry::getProvider('product_type', $productType);
$actions = $handler ? $handler->getUserActions($afterSale) : [];
return [
'after_sale_no' => $afterSale['after_sale_no'] ?? '',
'order_no' => $afterSale['order_no'] ?? '',
// 产品类型与订单大类:供前端按业态分流跳转订单详情、展示业态标签
'product_type' => $productType,
'order_category' => $afterSale['order_category'] ?? '',
'type' => $type,
'type_text' => self::TYPE_TEXT_MAP[$type] ?? $type,
'status' => $status,
'status_text' => $this->resolveStatusText($afterSale),
'refund_price' => (float)($afterSale['refund_price'] ?? 0),
'actual_refund_price' => (float)($afterSale['actual_refund_price'] ?? 0),
// 退款标的物与退还积分:积分订单售后展示退还积分数
'refund_target' => $afterSale['refund_target'] ?? 'cash',
'refund_integral' => (int)($afterSale['refund_integral'] ?? 0),
'reason' => $afterSale['reason'] ?? '',
'remark' => $afterSale['remark'] ?? '',
'reject_reason' => $afterSale['reject_reason'] ?? '',
'evidence_images' => $afterSale['evidence_images'] ?? [],
'product_snapshot' => $productSnapshot,
// v8.1.1 商品级售后:返回 cart_ids 和 refund_num,供前端展示售后范围
'cart_ids' => $afterSale['cart_ids'] ?? [],
'refund_num' => (int)($afterSale['refund_num'] ?? 0),
'return_delivery' => [
'type' => $afterSale['return_delivery_type'] ?? '',
'no' => $afterSale['return_delivery_no'] ?? '',
'images' => $afterSale['return_delivery_images'] ?? [],
],
'resend_delivery' => [
'type' => $afterSale['resend_delivery_type'] ?? '',
'no' => $afterSale['resend_delivery_no'] ?? '',
],
'timeline' => $timeline,
'actions' => $actions,
// 商家确认收货时间:前端据此区分“待收货”与“待退款/待补发”
'receive_time' => (int)($afterSale['receive_time'] ?? 0),
'add_time' => (int)($afterSale['add_time'] ?? 0),
];
}
/**
* 构造售后时间线
* 按售后类型返回不同节点,done 标记已完成,time 为时间戳
* 终态(审核拒绝/已取消)做特殊处理:截断后续节点并追加终态节点
* @param array $afterSale 售后单数据
* @return array
*/
protected function buildTimeline(array $afterSale): array
{
$type = $afterSale['type'] ?? '';
$status = $afterSale['status'] ?? '';
// 各售后类型的时间线节点定义
$nodeDefs = [
'refund_only' => ['apply', 'audit', 'refund', 'complete'],
'return_refund' => ['apply', 'audit', 'return', 'receive', 'refund', 'complete'],
'exchange' => ['apply', 'audit', 'return', 'receive', 'resend', 'complete'],
'resend' => ['apply', 'audit', 'resend', 'complete'],
'repair' => ['apply', 'audit', 'return', 'receive', 'repair', 'resend', 'complete'],
];
$nodes = $nodeDefs[$type] ?? ['apply', 'audit', 'complete'];
// 节点文案与时间字段映射
$nodeMeta = [
'apply' => ['label' => '申请售后', 'time_field' => 'add_time'],
'audit' => ['label' => '审核通过', 'time_field' => 'audit_time'],
'return' => ['label' => '用户退货', 'time_field' => 'return_time'],
'receive' => ['label' => '商家收货', 'time_field' => 'receive_time'],
'refund' => ['label' => '退款完成', 'time_field' => 'refund_time'],
'resend' => ['label' => '商家补发', 'time_field' => 'resend_time'],
'repair' => ['label' => '维修完成', 'time_field' => 'repair_complete_time'],
'complete' => ['label' => '售后完成', 'time_field' => 'complete_time'],
];
// 审核拒绝:截断到 audit 节点并标记拒绝
if ($status === 'audit_rejected') {
$nodes = ['apply', 'audit'];
$nodeMeta['audit'] = ['label' => '审核拒绝', 'time_field' => 'audit_time'];
}
// 用户取消:只保留 apply 节点,追加取消节点
if ($status === 'canceled') {
$nodes = ['apply', 'cancel'];
$nodeMeta['cancel'] = ['label' => '用户取消', 'time_field' => 'cancel_time'];
}
$timeline = [];
foreach ($nodes as $key) {
$meta = $nodeMeta[$key] ?? null;
if (!$meta) {
continue;
}
$time = (int)($afterSale[$meta['time_field']] ?? 0);
$timeline[] = [
'key' => $key,
'label' => $meta['label'],
'time' => $time,
'done' => $time > 0,
];
}
return $timeline;
}
// ===== 售后生命周期 =====
/**
* 生成售后单号
* @return string
*/
protected function generateNo(): string
{
return 'AS' . date('YmdHis') . str_pad((string)mt_rand(0, 9999), 4, '0', STR_PAD_LEFT);
}
/**
* 获取产品类型对应的售后生命周期处理器
* 从 AfterSaleRuleRegistry 的 product_type 维度获取(规则提供者与生命周期回调合一)
* @param string $productType
* @return AfterSaleHandlerInterface|null
*/
protected function getLifecycleHandler(string $productType): ?AfterSaleHandlerInterface
{
return AfterSaleRuleRegistry::getProvider('product_type', $productType);
}
/**
* 用户提交售后申请
* v8.1.1 增强:支持按 cart_id 选择商品范围和部分数量售后
* @param array $data
* - order_no: string 订单号
* - type: string 售后类型
* - refund_price: float 退款金额
* - reason: string 申请原因
* - evidence_images: array 凭证图片
* - product_snapshot: array 商品快照(可选,若不传则由 items 自动构建)
* - items: array 售后商品列表 [{cart_id, refund_num}],新参数
* @return array
*/
public function apply(array $data): array
{
$result = $this->transaction(function () use ($data): array {
// 在读取可退明细前锁订单,数量校验、积分分配与申请保存使用同一串行视图。
$center = $this->centerDao->getByOrderNoForUpdate((string)($data['order_no'] ?? ''));
if (!$center) {
throw new \RuntimeException('订单不存在');
}
return $this->applyLocked($data, $center->toArray());
});
// 售后单创建成功后派发事件:扩展层标记退款中 + 用户/客服双侧通知(监听器内部自行隔离失败)
$this->fireRefundCreateAfterEvent($result);
$lifecycle = $this->getLifecycleHandler((string)$result['product_type']);
if ($lifecycle) {
$lifecycle->afterApply((string)$result['after_sale_no'], $data);
}
return $result;
}
/**
* 派发 OrderRefundCreateAfter 事件
* 载荷首参对齐各扩展层监听器取数习惯:order_id=订单号,并附带通知所需的价格/商品名
* @param array $afterSale 售后单数据(apply 落库后的完整记录)
* @return void
*/
protected function fireRefundCreateAfterEvent(array $afterSale): void
{
try {
$orderNo = (string)($afterSale['order_no'] ?? '');
$centerOrder = $orderNo !== '' ? $this->centerDao->get(['order_no' => $orderNo]) : null;
/** @var array $centerArr */
$centerArr = $centerOrder ? $centerOrder->toArray() : [];
// 商品名与发货事件同源:从 extra_info.items 提取
$storeName = '';
$extra = $centerArr['extra_info'] ?? [];
if (is_string($extra)) {
$extra = json_decode($extra, true) ?: [];
}
foreach ($extra['items'] ?? [] as $item) {
if (($item['store_name'] ?? '') !== '') {
$storeName .= ($storeName === '' ? '' : '|') . (string)$item['store_name'];
}
}
$eventOrder = array_merge($centerArr, [
'order_id' => $orderNo, // 监听器以 order_id 取订单号
'pay_price' => (string)($centerArr['pay_price'] ?? '0.00'), // 订单实付金额
'refund_price' => (string)($afterSale['refund_price'] ?? '0.00'), // 申请退款金额
'store_name' => $storeName, // 商品名(多商品竖线分隔)
'refund_status' => '申请售后', // 通知展示用售后状态
]);
event('OrderRefundCreateAfter', [$eventOrder, $afterSale]);
} catch (\Throwable $e) {
Log::error('派发 OrderRefundCreateAfter 事件失败:' . $e->getMessage());
}
}
/**
* 在订单锁内校验并保存售后申请。
* @param array $data 申请数据
* @param array $center 已锁定的中台订单
* @return array
*/
protected function applyLocked(array $data, array $center): array
{
$orderNo = $data['order_no'] ?? '';
$type = $data['type'] ?? '';
$uid = intval($data['uid'] ?? 0);
$items = $data['items'] ?? [];
if ((int)$center['uid'] !== $uid) {
throw new \RuntimeException('订单不存在');
}
// 2. 商品级售后校验
// 校验订单支付状态:未付款/已取消/已删除的订单不允许售后
$eligibility = $this->checkOrderEligibility($center);
if (!$eligibility['eligible']) {
throw new \RuntimeException($eligibility['reason']);
}
$productType = $center['product_type'] ?? 'normal';
$sourceOrder = $this->getSourceOrder($center);
$rule = $this->ruleResolver->resolve($center, $sourceOrder);
if (!$rule['allow']) {
throw new \RuntimeException($rule['reject_reason'] ?: '当前订单不支持售后');
}
$availableTypes = $this->filterTypesByDelivery($rule['available_types'] ?? [], $center);
if (!empty($availableTypes) && !in_array($type, $availableTypes, true)) {
throw new \RuntimeException('当前订单不支持该售后类型');
}
// 3. 校验 items 参数(商品级售后范围)
$cartIds = [];
$totalRefundNum = 0;
$maxRefundPrice = '0.00'; // 退款金额上限 = 商品金额 + 按比例分摊的实付运费
$maxRefundIntegral = 0; // 退还积分上限 = Σ(integral_unit_price × refund_num)
$selectedItemsForPostage = [];
$refundableItems = $this->loadRefundableItems($orderNo); // 商品级可退数量
$refundableMap = []; // cart_id => refundable_num
$itemDetailMap = []; // cart_id => order_item detail(构建快照用)
foreach ($refundableItems as $ri) {
$refundableMap[(int)$ri['cart_id']] = $ri;
}
if (empty($items)) {
// 兼容旧前端:未传 items 时,自动选择全部 refundable_num>0 的商品
foreach ($refundableItems as $ri) {
if ($ri['refundable_num'] > 0) {
$items[] = [
'cart_id' => $ri['cart_id'],
'refund_num' => $ri['refundable_num'],
];
}
}
}
if (empty($items)) {
throw new \RuntimeException('请选择要售后的商品');
}
foreach ($items as $idx => $item) {
$cartId = (int)($item['cart_id'] ?? 0);
$refundNum = (int)($item['refund_num'] ?? 0);
if ($cartId <= 0) {
throw new \RuntimeException("第" . ($idx + 1) . "个商品 cart_id 缺失");
}
if (in_array($cartId, $cartIds, true)) {
throw new \RuntimeException('同一商品不能重复选择售后');
}
if ($refundNum <= 0) {
throw new \RuntimeException("请填写商品售后数量");
}
// 校验 cart_id 归属该订单
if (!isset($refundableMap[$cartId])) {
throw new \RuntimeException("商品 cart_id={$cartId} 不属于该订单");
}
$ri = $refundableMap[$cartId];
// 校验剩余可退数量
if ($refundNum > $ri['refundable_num']) {
$storeName = $ri['store_name'] ?: "cart_id={$cartId}";
throw new \RuntimeException("商品「{$storeName}」最多可退 {$ri['refundable_num']} 件");
}
// 校验该 cart_id 是否已有进行中售后(防重复申请)
$active = $this->dao->getActiveByCartId($orderNo, $cartId);
if ($active) {
$storeName = $ri['store_name'] ?: "cart_id={$cartId}";
throw new \RuntimeException("商品「{$storeName}」已有进行中的售后申请");
}
$cartIds[] = $cartId;
$totalRefundNum += $refundNum;
// 现金上限按分摊后的实付金额累计,避免优惠券/积分抵扣/促销等优惠被重复以现金退还
$lineCash = $this->rowPaidAmountForQuantity($ri, $refundNum);
$maxRefundPrice = bcadd($maxRefundPrice, $lineCash, 2);
$selectedItemsForPostage[] = [
'cart_id' => $cartId,
'refund_num' => $refundNum,
'true_price' => $ri['true_price'],
];
$lineIntegral = (int)($ri['integral_unit_price'] ?? 0) > 0
? (int)$ri['integral_unit_price'] * $refundNum
: $this->deductionIntegralForQuantity((int)$ri['deduction_integral_total'], (int)$ri['cart_num'], (int)$ri['refunded_num'], $refundNum);
$maxRefundIntegral += $lineIntegral;
$ri['selected_integral'] = $lineIntegral;
$itemDetailMap[$cartId] = $ri;
}
// 运费按本次售后商品金额占订单商品总额的比例计入退款上限,避免部分售后重复退整笔运费。
$refundPostage = $this->calculateRefundPostage($center, $refundableItems, $selectedItemsForPostage);
$maxRefundPrice = bcadd($maxRefundPrice, $refundPostage, 2);
// 4. 校验退款金额/退还积分(仅退款类售后需要校验,换货/补发/维修不涉及退款)
// 退款金额(现金)与退还积分是两个独立维度,可同时 > 0(积分+余额混合支付订单)
// exchange/resend/repair 为非退款型售后,退款金额与积分强制为 0,避免误填导致资金异常
$isRefundType = in_array($type, ['refund_only', 'return_refund'], true);
$refundPrice = 0.0;
$refundIntegral = 0;
if ($isRefundType) {
$refundPrice = (float)($data['refund_price'] ?? 0);
$refundIntegral = (int)($data['refund_integral'] ?? 0);
// 纯积分订单(无现金可退):退还积分未传时按可退积分自动计算
if ($refundIntegral <= 0 && $maxRefundIntegral > 0
&& ((float)$maxRefundPrice <= 0 || (int)($center['use_integral_num'] ?? 0) > 0)) {
$refundIntegral = $maxRefundIntegral;
}
// 0 元单(无现金、无积分可退):未传金额时按 0 落库,仍走售后闭环(库存/状态)
if ($refundPrice <= 0 && (float)$maxRefundPrice <= 0) {
$refundPrice = 0.0;
}
// 存在可退现金或积分时,至少一项须大于 0;两侧上限均为 0 时允许 0 退款
if ($refundPrice <= 0 && $refundIntegral <= 0
&& ((float)$maxRefundPrice > 0 || $maxRefundIntegral > 0)) {
throw new \RuntimeException('退款金额或退还积分至少一项必须大于 0');
}
if ($refundPrice > (float)$maxRefundPrice) {
throw new \RuntimeException("退款金额不能超过 {$maxRefundPrice} 元");
}
if ($refundIntegral > $maxRefundIntegral) {
throw new \RuntimeException("退还积分不能超过 {$maxRefundIntegral}");
}
if ((int)($center['use_integral_num'] ?? 0) > 0
&& in_array((string)($center['activity_type'] ?? ''), ['', 'normal'], true)) {
// 普通抵扣积分随选中的退货数量完整返还,不能用少退积分制造无法再申请的尾差。
$refundIntegral = $maxRefundIntegral;
}
} else {
// 非退款类售后:强制退款金额/积分为 0,忽略前端误传
$refundPrice = 0.0;
$refundIntegral = 0;
}
$applicationValidator = $this->getLifecycleHandler($productType);
if ($applicationValidator instanceof \crmeb\trade\order\aftersale\AfterSaleApplicationValidatorInterface) {
$applicationValidator->validateApplication($center, array_replace($data, [
'refund_price' => $refundPrice,
'refund_integral' => $refundIntegral,
]), $items);
}
// 5. 构建 product_snapshot(若前端未传,则由 items 自动构建)
$snapshot = $data['product_snapshot'] ?? [];
if (empty($snapshot) || !is_array($snapshot)) {
$snapItems = [];
foreach ($items as $item) {
$cartId = (int)$item['cart_id'];
$refundNum = (int)$item['refund_num'];
if (!isset($itemDetailMap[$cartId])) {
continue;
}
$ri = $itemDetailMap[$cartId];
$snapItems[] = [
'cart_id' => $cartId,
'product_id' => $ri['product_id'],
'store_name' => $ri['store_name'],
'image' => $ri['image'],
'sku' => $ri['sku'],
'cart_num' => $refundNum, // 售后数量(非订单购买数量)
'true_price' => $ri['true_price'],
];
}
$snapshot = [
'product_type' => $productType,
'order_no' => $orderNo,
'order_amount' => $center['pay_price'] ?? '0.00',
'items' => $snapItems,
];
}
// 快照记录本次实际申请可包含的运费,便于售后详情审计和后续兼容处理。
$productRefundAmount = 0.0;
foreach ($selectedItemsForPostage as $selectedItem) {
$productRefundAmount += (float)$selectedItem['true_price'] * (int)$selectedItem['refund_num'];
}
$requestedPostage = max(0, min(
(float)$refundPostage,
(float)$refundPrice - $productRefundAmount
));
$snapshot['refund_postage'] = number_format($requestedPostage, 2, '.', '');
// 6. 创建售后单
$afterSaleNo = $this->generateNo();
$refundPriceString = number_format($refundPrice, 2, '.', '');
$afterSaleData = [
'after_sale_no' => $afterSaleNo,
'order_center_id' => intval($center['id']),
'order_no' => $orderNo,
'uid' => $uid,
'order_category' => $center['order_category'] ?? '',
'product_type' => $productType,
'activity_type' => $center['activity_type'] ?? '',
'type' => $type,
'status' => 'pending_audit',
'before_status' => $center['status_code'] ?? '', // 售后前中台状态,取消/驳回时恢复
'refund_price' => $refundPriceString,
'refund_integral' => $refundIntegral,
// 退款标的物:现金与积分可同时存在,both 表示混合退款
'refund_target' => $refundIntegral > 0 && $refundPrice > 0
? 'both'
: ($refundIntegral > 0 ? 'integral' : 'cash'),
'reason' => $data['reason'] ?? '',
'remark' => trim((string)($data['remark'] ?? '')),
'evidence_images' => !empty($data['evidence_images']) && is_array($data['evidence_images'])
? json_encode($data['evidence_images']) : '',
'product_snapshot'=> !empty($snapshot) && is_array($snapshot)
? json_encode($snapshot) : '',
'cart_ids' => $cartIds, // 模型修改器会自动 json_encode
'refund_num' => $totalRefundNum,
'add_time' => time(),
];
$afterSale = $this->transaction(function () use (
$afterSaleData,
$items,
$itemDetailMap,
$productType,
$center,
$refundPriceString,
$refundIntegral,
$orderNo
) {
$afterSale = $this->dao->save($afterSaleData);
$rows = $this->buildAfterSaleItems(
(int)$afterSale['id'],
$items,
$itemDetailMap,
$productType,
(string)($center['activity_type'] ?? ''),
$refundPriceString,
$refundIntegral
);
if ($rows) {
/** @var AfterSaleItemDao $itemDao */
$itemDao = app()->make(AfterSaleItemDao::class);
$itemDao->saveAll($rows);
}
// 售后单与规范化商品明细必须同时落库,避免退款阶段重新猜测 JSON 快照。
$this->centerServices->syncStatus($orderNo, OrderCenter::STATUS_AFTER_SALE, [
'refund_status' => OrderCenter::REFUND_APPLYING,
]);
return $afterSale;
});
return $afterSale->toArray();
}
/**
* 审核售后
* @param int $afterSaleId
* @param string $result passed/rejected
* @param int $auditUid
* @param string $rejectReason
* @param string $auditRemark
* @return void
*/
public function audit(int $afterSaleId, string $result, int $auditUid = 0, string $rejectReason = '', string $auditRemark = ''): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
if ($afterSale['status'] !== 'pending_audit') {
throw new \RuntimeException('当前状态不可审核');
}
$updateData = [
'audit_uid' => $auditUid,
'audit_remark' => $auditRemark,
'audit_time' => time(),
];
if ($result === 'reject') {
$updateData['status'] = 'audit_rejected';
$updateData['reject_reason'] = $rejectReason;
$this->dao->update($afterSaleId, $updateData);
// 审核拒绝:中台订单状态恢复到售后前状态
$this->restoreOrderStatus($afterSale);
// 审核拒绝后派发事件:扩展层恢复原状态 + 通知用户拒绝原因
$this->fireRefundRefuseEvent($afterSale->toArray(), $updateData);
// 触发生命周期回调
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$lifecycle->afterAudit($afterSale['after_sale_no'], [
'result' => 'reject',
'reject_reason'=> $rejectReason,
'audit_remark' => $auditRemark,
]);
}
return;
}
// 审核通过:按售后类型自动流转到下一状态
// refund_only / resend 保持 audit_passed(等商家手动执行退款/补发)
// return_refund/exchange/repair → pending_return(待用户填退货物流)
$type = $afterSale['type'] ?? '';
switch ($type) {
case 'return_refund':
case 'exchange':
case 'repair':
$updateData['status'] = 'pending_return';
break;
default:
// refund_only / resend 资金或物流类:停留 audit_passed,等商家执行退款或补发
$updateData['status'] = 'audit_passed';
break;
}
$this->dao->update($afterSaleId, $updateData);
// 触发生命周期回调
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$lifecycle->afterAudit($afterSale['after_sale_no'], [
'result' => 'passed',
'audit_remark' => $auditRemark,
]);
}
}
/**
* 派发 OrderRefundRefuse 事件
* 载荷首参对齐扩展层监听器取数习惯:store_order_id=中台订单ID,附带通知所需的价格/商品名/拒绝原因
* @param array $afterSale 售后单数据
* @param array $updateData 审核更新数据(含 reject_reason)
* @return void
*/
protected function fireRefundRefuseEvent(array $afterSale, array $updateData): void
{
try {
$orderNo = (string)($afterSale['order_no'] ?? '');
$centerOrder = $orderNo !== '' ? $this->centerDao->get(['order_no' => $orderNo]) : null;
/** @var array $centerArr */
$centerArr = $centerOrder ? $centerOrder->toArray() : [];
// 商品名与发货事件同源:从 extra_info.items 提取
$storeName = '';
$extra = $centerArr['extra_info'] ?? [];
if (is_string($extra)) {
$extra = json_decode($extra, true) ?: [];
}
foreach ($extra['items'] ?? [] as $item) {
if (($item['store_name'] ?? '') !== '') {
$storeName .= ($storeName === '' ? '' : '|') . (string)$item['store_name'];
}
}
$eventOrder = array_merge($centerArr, [
'order_id' => $orderNo, // 订单号
'store_order_id' => (int)($afterSale['order_center_id'] ?? 0), // 扩展层以此判定产品类型归属
'pay_price' => (string)($centerArr['pay_price'] ?? '0.00'), // 订单实付金额
'refund_price' => (string)($afterSale['refund_price'] ?? '0.00'), // 申请退款金额
'refuse_reason' => (string)($updateData['reject_reason'] ?? ''), // 拒绝原因
'store_name' => $storeName, // 商品名(多商品竖线分隔)
]);
event('OrderRefundRefuse', [$eventOrder]);
} catch (\Throwable $e) {
Log::error('派发 OrderRefundRefuse 事件失败:' . $e->getMessage());
}
}
/**
* 用户填写退货物流
* @param int $afterSaleId
* @param array $deliveryData
* @return void
*/
public function returnGoods(int $afterSaleId, array $deliveryData): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
if ($afterSale['status'] !== 'pending_return') {
throw new \RuntimeException('当前状态不可填写退货物流');
}
$this->dao->update($afterSaleId, [
'status' => 'receiving',
'return_delivery_type' => $deliveryData['delivery_type'] ?? '',
'return_delivery_no' => $deliveryData['delivery_no'] ?? '',
'return_delivery_images'=> !empty($deliveryData['images']) && is_array($deliveryData['images'])
? json_encode($deliveryData['images']) : '',
'return_time' => time(),
]);
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$lifecycle->afterReturn($afterSale['after_sale_no'], $deliveryData);
}
}
/**
* 商家确认收货
* 确认后按售后类型分流:return_refund→执行退款 / exchange→补发 / repair→维修
* @param int $afterSaleId
* @return void
*/
public function receive(int $afterSaleId): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
if ($afterSale['status'] !== 'receiving') {
throw new \RuntimeException('当前状态不可确认收货');
}
$this->dao->update($afterSaleId, [
'receive_time' => time(),
]);
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$lifecycle->afterReceive($afterSale['after_sale_no']);
}
// 按售后类型分流
$type = $afterSale['type'];
if (in_array($type, ['return_refund', 'exchange', 'repair'])) {
if ($type === 'return_refund') {
// 退货退款:继续推进退款
// 调用方需在 receive 后调用 refund()
} elseif ($type === 'exchange') {
// 换货:推进补发
// 调用方需在 receive 后调用 resend()
} elseif ($type === 'repair') {
// 维修:推进维修
$this->updateStatus($afterSaleId, 'repairing', ['repair_time' => time()]);
if ($lifecycle) {
$lifecycle->afterRepair($afterSale['after_sale_no'], 'repairing');
}
}
}
}
/**
* 执行退款
* 创建唯一退款意图并交给可恢复状态机推进。重复请求返回同一退款单。
* @param int $afterSaleId
* @param float $amount 实际退款金额
* @param array $context
* @return array
*/
public function refund(int $afterSaleId, float $amount, array $context = []): array
{
/** @var RefundOrderServices $refundServices */
$refundServices = app()->make(RefundOrderServices::class);
return $refundServices->request($afterSaleId, number_format($amount, 2, '.', ''), $context);
}
/**
* 补发商品
* @param int $afterSaleId
* @param array $deliveryData
* @return void
*/
public function resend(int $afterSaleId, array $deliveryData): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
$type = $afterSale['type'];
$allowedStatus = ['exchange' => ['receiving'], 'repair' => ['repair_complete'], 'resend' => ['audit_passed']];
$allowed = $allowedStatus[$type] ?? ['audit_passed'];
if (!in_array($afterSale['status'], $allowed)) {
throw new \RuntimeException('当前状态不可补发商品');
}
$this->dao->update($afterSaleId, [
'status' => 'resending',
'resend_delivery_type'=> $deliveryData['delivery_type'] ?? '',
'resend_delivery_no' => $deliveryData['delivery_no'] ?? '',
'resend_time' => time(),
]);
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$lifecycle->afterResend($afterSale['after_sale_no'], $deliveryData);
}
}
/**
* 维修完成
* 记录维修结果与可选寄回物流后,直接完结售后并恢复中台订单到申请前状态。
* 兼容已停在 repair_complete、尚未完结的历史单。
*
* @param int $afterSaleId
* @param string $repairRemark
* @param array $deliveryData 可选寄回物流 delivery_type/delivery_no
* @return void
*/
public function repairComplete(int $afterSaleId, string $repairRemark = '', array $deliveryData = []): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
if (!in_array($afterSale['status'], ['repairing', 'repair_complete'], true)) {
throw new \RuntimeException('当前状态不可维修完成');
}
if (($afterSale['type'] ?? '') !== 'repair') {
throw new \RuntimeException('非维修类型售后不可执行维修完成');
}
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
$update = [];
if ($afterSale['status'] === 'repairing') {
$update['status'] = 'repair_complete';
$update['repair_complete_time'] = time();
}
if ($repairRemark !== '') {
$update['repair_remark'] = $repairRemark;
}
$deliveryType = trim((string)($deliveryData['delivery_type'] ?? ''));
$deliveryNo = trim((string)($deliveryData['delivery_no'] ?? ''));
if ($deliveryType !== '' || $deliveryNo !== '') {
$update['resend_delivery_type'] = $deliveryType;
$update['resend_delivery_no'] = $deliveryNo;
$update['resend_time'] = time();
}
if ($update) {
$this->dao->update($afterSaleId, $update);
}
if ($afterSale['status'] === 'repairing' && $lifecycle) {
$lifecycle->afterRepair($afterSale['after_sale_no'], 'repair_complete');
}
if (($deliveryType !== '' || $deliveryNo !== '') && $lifecycle) {
$lifecycle->afterResend($afterSale['after_sale_no'], [
'delivery_type' => $deliveryType,
'delivery_no' => $deliveryNo,
]);
}
// 维修流程终结:完结售后单并恢复订单原状态(不再等待用户二次确认收货)
$this->complete($afterSaleId);
}
/**
* 用户确认收货(补发/维修完成后)
* @param int $afterSaleId
* @return void
*/
public function confirmReceive(int $afterSaleId): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
if ($afterSale['status'] !== 'resending') {
throw new \RuntimeException('当前状态不可确认收货');
}
// 补发收货后售后完结
$this->complete($afterSaleId);
}
/**
* 取消售后
* @param int $afterSaleId
* @return void
*/
public function cancel(int $afterSaleId): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
// 只有待审核状态可取消
if (!in_array($afterSale['status'], ['pending_audit', 'audit_passed'])) {
throw new \RuntimeException('当前状态不可取消售后');
}
$this->dao->update($afterSaleId, [
'status' => 'canceled',
'cancel_time'=> time(),
]);
// 取消售后:中台订单状态恢复到售后前状态
$this->restoreOrderStatus($afterSale);
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$lifecycle->afterCancel($afterSale['after_sale_no']);
}
// 外平台售后单取消推送
event('OutPush', ['refund_cancel_push', ['after_sale_id' => $afterSaleId]]);
}
/**
* 恢复中台订单状态
* 售后取消/驳回时,将中台订单状态恢复到申请售后前的状态码,并清空退款申请标记
* @param array|object $afterSale 售后单数据(需含 order_no/before_status),兼容模型对象与数组
* @return void
*/
protected function restoreOrderStatus($afterSale): void
{
// 兼容模型对象与数组(dao->get 返回模型对象,部分调用处传数组)
if (is_object($afterSale)) {
$afterSale = $afterSale->toArray();
}
if (!is_array($afterSale)) {
return;
}
$orderNo = $afterSale['order_no'] ?? '';
$beforeStatus = $afterSale['before_status'] ?? '';
if (empty($orderNo) || empty($beforeStatus)) {
return;
}
$this->centerServices->syncStatus($orderNo, $beforeStatus, [
'refund_status' => OrderCenter::REFUND_NONE,
]);
}
/**
* 售后完结
* 非退款型售后(换货/补发/维修)流程完成后,中台订单状态恢复到售后前状态;
* 退款型售后(仅退款/退货退款)退款时中台已置 refunded,此处不覆盖
* @param int $afterSaleId
* @return void
*/
protected function complete(int $afterSaleId): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
return;
}
$this->dao->update($afterSaleId, [
'status' => 'completed',
'complete_time'=> time(),
]);
// 非退款型售后完成后恢复中台订单状态(无退款,订单恢复原流转)
if (in_array($afterSale['type'], ['exchange', 'resend', 'repair'])) {
$this->restoreOrderStatus($afterSale);
}
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$lifecycle->afterComplete($afterSale['after_sale_no']);
}
}
/**
* 更新售后状态(内部辅助方法)
* @param int $afterSaleId
* @param string $status
* @param array $extraData
* @return void
*/
protected function updateStatus(int $afterSaleId, string $status, array $extraData = []): void
{
$updateData = array_merge(['status' => $status], $extraData);
$this->dao->update($afterSaleId, $updateData);
}
// ===== 用户端列表 =====
/**
* 获取用户售后列表
* 支持按状态分组筛选(processing/completed/canceled),附加 type_text/status_text 文案
* 仅返回当前用户名下的售后单,自动过滤软删除记录
* @param int $uid 用户ID
* @param int $page 页码(从1开始)
* @param int $limit 每页数量
* @param string $statusGroup 状态分组:空=全部,processing=处理中,completed=已完成,canceled=已取消
* @return array ['list'=>[], 'count'=>int]
*/
public function getListForUser(int $uid, int $page = 1, int $limit = 10, string $statusGroup = ''): array
{
// 基础条件:限定当前用户 + 未删除
$map = [
['uid', '=', $uid],
['is_del', '=', 0],
];
// 状态分组筛选:非法分组按全部处理
if ($statusGroup !== '' && isset(self::STATUS_GROUP_MAP[$statusGroup])) {
$map[] = ['status', 'in', self::STATUS_GROUP_MAP[$statusGroup]];
}
// with('orderCenter') 预载入关联订单,供 foreach 提取 order_info(订单号/状态/标题/主图等)
// 未预载入则 $item['order_center'] 为 null,列表项订单信息与商品兜底图均无法展示
$list = $this->dao->getList($map, $page, $limit, '*', ['orderCenter']);
// 产品类型注册中心:循环外取一次,用于动态获取产品类型中文名(实物/虚拟/卡密等)
$productTypeRegistry = null;
try {
$productTypeRegistry = app()->make(\crmeb\trade\product\ProductTypeRegistry::class);
} catch (\Throwable $e) {
// 注册中心不可用时静默降级,product_type_text 留空
}
// 附加类型/状态文案与状态分组,避免前端硬编码映射表
$specOrders = [];
foreach ($list as &$item) {
$item['type_text'] = self::TYPE_TEXT_MAP[$item['type'] ?? ''] ?? ($item['type'] ?? '');
$item['status_text'] = $this->resolveStatusText($item);
$item['status_group'] = $this->resolveStatusGroup($item['status'] ?? '');
// 产品类型中文名:动态从注册中心获取,新增产品类型注册即生效
$productType = $item['product_type'] ?? '';
$productTypeText = '';
if ($productType && $productTypeRegistry && $productTypeRegistry->has($productType)) {
try {
$productTypeText = $productTypeRegistry->get($productType)->getTypeName();
} catch (\Throwable $e) {
// 类型未注册时静默降级
}
}
$item['product_type_text'] = $productTypeText;
// 提取关联订单基本信息到 order_info,供前端列表展示订单号/订单状态/下单时间/金额/数量
// title/image 作为商品快照缺失时的兜底来源(order_center 表已含订单标题/主图)
// 注意:ThinkPHP with('orderCenter') 预载入后数组 key 为关联方法名 orderCenter(驼峰),
// 不是 order_center(下划线),写错 key 会恒取 null
$orderCenter = $item['orderCenter'] ?? null;
$extraInfo = [];
if (is_array($orderCenter) && $orderCenter) {
$orderStatusCode = $orderCenter['status_code'] ?? '';
$item['order_info'] = [
'order_no' => $orderCenter['order_no'] ?? '',
'status_code' => $orderStatusCode,
'status_text' => OrderCenter::$statusMap[$orderStatusCode] ?? '',
'add_time' => $orderCenter['add_time'] ?? 0,
'pay_price' => $orderCenter['pay_price'] ?? '0.00',
'total_num' => $orderCenter['total_num'] ?? 0,
'title' => $orderCenter['title'] ?? '',
'image' => $orderCenter['image'] ?? '',
];
// 解析 extra_info(JSON),供规格信息组装(床型/票种/预订时间等)
$rawExtra = $orderCenter['extra_info'] ?? '';
if ($rawExtra) {
$extraInfo = is_array($rawExtra) ? $rawExtra : (json_decode($rawExtra, true) ?: []);
}
} else {
$item['order_info'] = null;
}
// 收集规格扩展所需信息(按 order_no),循环外批量触发事件,避免 N+1 查询
$orderNo = $item['order_no'] ?? ($orderCenter['order_no'] ?? '');
if ($orderNo) {
$specOrders[$orderNo] = [
'product_type' => $productType,
'extra_info' => $extraInfo,
'source_type' => is_array($orderCenter) ? ($orderCenter['source_type'] ?? '') : '',
'source_id' => is_array($orderCenter) ? ($orderCenter['source_id'] ?? 0) : 0,
];
}
unset($item['orderCenter']);
}
unset($item);
// 批量加载规格信息:通过事件让产品类型监听器组装(hotel/ticket 等),normal 默认从 extra_info 提取 sku
$specMap = $this->loadListSpecInfo($specOrders);
foreach ($list as &$item) {
$item['spec_info'] = $specMap[$item['order_no'] ?? ''] ?? [];
}
unset($item);
$count = $this->dao->count($map);
return ['list' => $list, 'count' => $count];
}
/**
* 批量加载列表规格信息
* 通过 AfterSaleListSpecExtend 事件让各产品类型监听器自行组装规格(床型/票种/预订时间等),
* normal 默认从 extra_info.items[0].sku 提取规格。架构合规:app 层不查扩展表,由扩展模块监听器自治
* @param array $specOrders 以 order_no 为键的订单信息集合(product_type/extra_info/source_type/source_id)
* @return array 以 order_no 为键的规格信息集合,每个值为 [{label,value},...]
*/
protected function loadListSpecInfo(array $specOrders): array
{
if (empty($specOrders)) {
return [];
}
$specMap = [];
// 触发事件,监听器返回 [order_no => [{label,value},...]]
$results = event('AfterSaleListSpecExtend', ['orders' => $specOrders]);
if (is_array($results)) {
foreach ($results as $ret) {
if (!is_array($ret)) {
continue;
}
foreach ($ret as $orderNo => $specs) {
// 首个监听器返回优先,避免重复组装
if (!isset($specMap[$orderNo]) && is_array($specs)) {
$specMap[$orderNo] = $specs;
}
}
}
}
// normal 默认:从 extra_info.items[0].sku 提取规格(normal 是中台默认产品类型,app 层处理)
foreach ($specOrders as $orderNo => $info) {
if (isset($specMap[$orderNo])) {
continue;
}
$items = $info['extra_info']['items'] ?? [];
$sku = $items[0]['sku'] ?? ($items[0]['attr_info']['suk'] ?? '');
if ($sku !== '' && $sku !== null) {
$specMap[$orderNo] = [['label' => '规格', 'value' => (string)$sku]];
}
}
return $specMap;
}
/**
* 解析售后状态所属分组
* 用于前端列表项着色等展示逻辑,返回 processing/completed/canceled,未知状态返回空
* @param string $status 售后状态码
* @return string
*/
protected function resolveStatusGroup(string $status): string
{
foreach (self::STATUS_GROUP_MAP as $group => $statuses) {
if (in_array($status, $statuses, true)) {
return $group;
}
}
return '';
}
// ===== 后台管理端 =====
/**
* 后台售后列表
* 支持按售后单号/订单号/状态/类型/产品类型筛选,返回带中台订单关联的列表
* @param array $where 筛选条件
* @param int $page 页码
* @param int $limit 每页数量
* @return array
*/
public function getListForAdmin(array $where, int $page, int $limit): array
{
// 关联键走模型搜索器(status/type 等);模糊/区间条件用三元数组
$map = ['is_del' => 0];
if (!empty($where['after_sale_no'])) {
$map[] = ['after_sale_no', 'like', '%' . trim((string)$where['after_sale_no']) . '%'];
}
if (!empty($where['order_no'])) {
$map[] = ['order_no', 'like', '%' . trim((string)$where['order_no']) . '%'];
}
// 状态:单值精确;逗号串/数组走 whereIn(兼容流程分组)
$status = $where['status'] ?? '';
if ($status !== '' && $status !== null) {
if (is_string($status) && str_contains($status, ',')) {
$map['status'] = array_values(array_filter(array_map('trim', explode(',', $status))));
} else {
$map['status'] = $status;
}
}
foreach (['type', 'product_type', 'order_category'] as $field) {
if (!empty($where[$field])) {
$map[$field] = $where[$field];
}
}
if (!empty($where['start_time']) && !empty($where['end_time'])) {
$map[] = ['add_time', 'between', [(int)$where['start_time'], (int)$where['end_time']]];
}
$list = $this->dao->getList($map, $page, $limit);
// 附加文案
foreach ($list as &$item) {
$item['type_text'] = self::TYPE_TEXT_MAP[$item['type'] ?? ''] ?? ($item['type'] ?? '');
$item['status_text'] = $this->resolveStatusText($item);
}
unset($item);
$count = $this->dao->count($map);
return ['list' => $list, 'count' => $count];
}
/**
* 后台售后详情
* 比用户端详情多返回审核信息、管理备注等后台专属字段
* @param int $afterSaleId 售后单主键
* @return array
*/
public function getDetailForAdmin(int $afterSaleId): array
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
return [];
}
// 复用用户端详情构造,再追加后台专属字段
$detail = $this->getDetail($afterSale['after_sale_no'] ?? '');
// 追加后台专属字段(审核/管理/物流完整信息)
$detail['audit_uid'] = (int)($afterSale['audit_uid'] ?? 0);
$detail['audit_remark'] = $afterSale['audit_remark'] ?? '';
$detail['audit_time'] = (int)($afterSale['audit_time'] ?? 0);
$detail['remark'] = $afterSale['remark'] ?? '';
$detail['before_status'] = $afterSale['before_status'] ?? '';
$detail['refund_integral'] = (int)($afterSale['refund_integral'] ?? 0);
$detail['return_postage'] = (float)($afterSale['return_postage'] ?? 0);
$detail['refund_type'] = $afterSale['refund_type'] ?? '';
$detail['refund_trade_no'] = $afterSale['refund_trade_no'] ?? '';
$detail['repair_remark'] = $afterSale['repair_remark'] ?? '';
$detail['cancel_time'] = (int)($afterSale['cancel_time'] ?? 0);
$detail['order_center_id'] = (int)($afterSale['order_center_id'] ?? 0);
$detail['uid'] = (int)($afterSale['uid'] ?? 0);
// 追加中台订单状态文案(便于后台展示售后前订单状态)
$detail['before_status_text'] = OrderCenter::$statusMap[$detail['before_status']] ?? $detail['before_status'];
/** @var \app\dao\order\RefundOrderDao $refundOrderDao */
$refundOrderDao = app()->make(\app\dao\order\RefundOrderDao::class);
$refundOrder = $refundOrderDao->getByAfterSaleId($afterSaleId);
$detail['refund_order'] = $refundOrder ? [
'refund_no' => (string)$refundOrder['refund_no'],
'amount' => (string)$refundOrder['amount'],
'state' => (string)$refundOrder['state'],
'pay_channel' => (string)$refundOrder['pay_channel'],
'channel_refund_id' => (string)$refundOrder['channel_refund_id'],
'attempts' => (int)$refundOrder['attempts'],
'next_retry_time' => (int)$refundOrder['next_retry_time'],
'last_error' => (string)$refundOrder['last_error'],
'channel_succeeded_at' => (int)$refundOrder['channel_succeeded_at'],
'completed_at' => (int)$refundOrder['completed_at'],
] : null;
return $detail;
}
/**
* 后台更新管理备注
* @param int $afterSaleId 售后单主键
* @param string $remark 备注内容
* @return void
*/
public function updateRemark(int $afterSaleId, string $remark): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
$this->dao->update($afterSaleId, ['remark' => $remark]);
}
/**
* 按 ID 查询售后单
* 供后台控制器校验存在性使用
* @param int $afterSaleId
* @return array|null
*/
public function getById(int $afterSaleId): ?array
{
$record = $this->dao->get($afterSaleId);
return $record ? (is_object($record) ? $record->toArray() : $record) : null;
}
/**
* 获取当前用户拥有的售后单(按售后单号)
* 按售后单号查询并校验 uid 归属,防止越权操作/查询他人售后单
* @param string $afterSaleNo 售后单号
* @param int $uid 用户ID
* @return array|null 命中且归属一致返回售后单原始记录,否则 null
*/
public function getOwnedByNo(string $afterSaleNo, int $uid): ?array
{
$record = $this->dao->getByNo($afterSaleNo);
if (empty($record) || (int)($record['uid'] ?? 0) !== $uid) {
return null;
}
return $record;
}
/**
* 查询售后物流进度
* 复用 ExpressServices::query 统一物流查询能力,支持退货物流与补发物流两种类型
* 快递公司名称→编码通过 resolveExpressCode 解析,匹配不到时由驱动按单号自动识别
* @param int $afterSaleId 售后单主键
* @param string $type 物流类型 return=退货物流 resend=补发物流
* @return array {delivery_name, delivery_id, result}
*/
public function queryExpress(int $afterSaleId, string $type = 'return'): array
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
// 按物流类型取对应快递公司名称与运单号
if ($type === 'resend') {
$deliveryName = $afterSale['resend_delivery_type'] ?? '';
$deliveryNo = $afterSale['resend_delivery_no'] ?? '';
} else {
$deliveryName = $afterSale['return_delivery_type'] ?? '';
$deliveryNo = $afterSale['return_delivery_no'] ?? '';
}
$deliveryName = (string)($deliveryName ?? '');
$deliveryNo = (string)($deliveryNo ?? '');
// 没有运单号无法查询物流
if ($deliveryNo === '') {
throw new \RuntimeException('该售后单暂无' . ($type === 'resend' ? '补发' : '退货') . '物流单号');
}
// 通过快递公司名称匹配编码,匹配不到返回 null 由驱动自动识别
$com = $this->resolveExpressCode((string)$deliveryName);
/** @var ExpressServices $expressServices */
$expressServices = $this->app->make(ExpressServices::class);
// 缓存键:售后单号 + 物流类型,避免与订单物流缓存冲突
$cacheName = 'after_sale_express_' . ($afterSale['after_sale_no'] ?? $afterSaleId) . '_' . $type;
// 顺丰等驱动会按收件手机号验单;售后单不存手机号,按快照→中台→源订单回退解析。
$phone = $this->resolveAfterSalePhone($afterSale);
$result = $expressServices->query($cacheName, $deliveryNo, $com, $phone);
return [
'delivery_name' => (string)$deliveryName,
'delivery_id' => (string)$deliveryNo,
'result' => is_array($result) ? $result : [],
];
}
/**
* 解析售后物流查询所需的收件手机号。
*
* 售后表没有手机号字段:新订单将联系人快照保存在 order_center.extra_info,
* 旧普通商品订单则保存在 pt_normal_order.user_phone。读取时不调用完整订单详情,
* 避免在查询物流时再次触发订单详情中的物流扩展。
*
* @param mixed $afterSale 售后单模型或数组
* @return string
*/
protected function resolveAfterSalePhone($afterSale): string
{
$afterSaleData = $this->normalizeOrderData($afterSale);
// 兼容历史数据中直接附带联系人或手机号的售后记录/关联。
$phone = $this->extractOrderPhone($afterSaleData);
if ($phone !== '') {
return $phone;
}
foreach (['orderCenter', 'order_center'] as $relation) {
if (!empty($afterSaleData[$relation])) {
$phone = $this->extractOrderPhone($afterSaleData[$relation]);
if ($phone !== '') {
return $phone;
}
}
}
$center = null;
$orderCenterId = (int)($afterSaleData['order_center_id'] ?? 0);
$orderNo = trim((string)($afterSaleData['order_no'] ?? ''));
if ($orderCenterId > 0) {
try {
$center = $this->centerDao->get($orderCenterId, [
'id', 'order_no', 'product_type', 'source_type', 'source_id', 'extra_info',
]);
} catch (\Throwable $e) {
// 按 ID 读取失败时仍尝试订单号,兼容中台记录缺失或旧表结构。
$this->logPhoneResolutionWarning('[AfterSale] resolve recipient phone by center id failed: ' . get_class($e));
}
}
if (!$center && $orderNo !== '') {
try {
$center = $this->centerDao->getOne(
['order_no' => $orderNo],
'id,order_no,product_type,source_type,source_id,extra_info'
);
} catch (\Throwable $e) {
$this->logPhoneResolutionWarning('[AfterSale] resolve recipient phone by order number failed: ' . get_class($e));
}
}
$centerData = $this->normalizeOrderData($center);
$phone = $this->extractOrderPhone($centerData);
if ($phone !== '') {
return $phone;
}
// 旧订单手机号位于产品类型扩展表,由产品处理器自行读取。
// 保留售后记录中的定位字段(部分历史中台记录只返回 extra_info)。
// 中台字段优先,但不能用空值覆盖售后记录中仍然有效的定位字段。
$sourceContext = $afterSaleData;
if ($orderNo !== '') {
$sourceContext['order_no'] = $orderNo;
}
foreach (['order_no', 'product_type', 'source_type', 'source_id'] as $field) {
$value = $centerData[$field] ?? null;
if ($field === 'order_no') {
$value = trim((string)$value);
}
if ($value !== null && $value !== '') {
$sourceContext[$field] = $value;
}
}
if (!empty($sourceContext['product_type']) && !empty($sourceContext['order_no'])) {
try {
$phone = $this->extractOrderPhone($this->getSourceOrder($sourceContext));
if ($phone !== '') {
return $phone;
}
} catch (\Throwable $e) {
// 旧扩展表不存在或插件未启用时,保留空手机号交由驱动自动处理。
$this->logPhoneResolutionWarning('[AfterSale] resolve recipient phone from source order failed: ' . get_class($e));
}
}
return '';
}
/**
* 记录手机号回退异常;未配置日志通道的裸测试环境直接跳过。
* @param string $message
* @return void
*/
protected function logPhoneResolutionWarning(string $message): void
{
// 裸测试容器可能没有日志通道;正常应用上下文存在通道时记录回退失败原因。
$logConfig = $this->app->make('config')->get('log', []);
$defaultChannel = is_array($logConfig) ? ($logConfig['default'] ?? '') : '';
$channels = is_array($logConfig) ? ($logConfig['channels'] ?? []) : [];
if ($defaultChannel === '' || !is_array($channels) || !isset($channels[$defaultChannel])) {
return;
}
Log::warning($message);
}
/**
* 将模型、JSON 对象或数组统一为数组。
* @param mixed $data
* @return array
*/
protected function normalizeOrderData($data): array
{
// ORM 模型优先使用 toArray;Json 类型对象则使用 value,避免把模型的同名查询方法当成无参转换器。
for ($attempt = 0; $attempt < 3 && is_object($data); $attempt++) {
if (method_exists($data, 'toArray')) {
$data = $data->toArray();
continue;
}
if (method_exists($data, 'value')) {
$data = $data->value();
continue;
}
$data = get_object_vars($data);
}
if (is_string($data)) {
$decoded = json_decode($data, true);
$data = is_array($decoded) ? $decoded : [];
}
return is_array($data) ? $data : [];
}
/**
* 从订单/联系人快照中提取收件手机号。
* 只读取收件人相关字段,避免把店铺或寄件人电话误作为验单手机号。
* @param mixed $data
* @param int $depth 当前快照递归深度
* @return string
*/
protected function extractOrderPhone($data, int $depth = 0): string
{
// 历史快照可能包含循环引用;限制递归深度,避免异常数据耗尽调用栈。
if ($depth >= 8) {
return '';
}
$data = $this->normalizeOrderData($data);
if (empty($data)) {
return '';
}
$extraInfo = $this->normalizeOrderData($data['extra_info'] ?? []);
$contact = $this->normalizeOrderData($data['contact'] ?? []);
$extraContact = $this->normalizeOrderData($extraInfo['contact'] ?? []);
$candidates = [
$data['user_phone'] ?? '',
$data['receiver_phone'] ?? '',
$data['consignee_phone'] ?? '',
$data['contact_phone'] ?? '',
$contact['phone'] ?? ($contact['user_phone'] ?? ''),
$extraContact['phone'] ?? ($extraContact['user_phone'] ?? ''),
$data['phone'] ?? '',
];
foreach ($candidates as $candidate) {
if (is_scalar($candidate)) {
$phone = trim((string)$candidate);
if ($phone !== '') {
return $phone;
}
}
}
// 个别历史售后快照会同时保存联系人信息,作为订单数据的最后一层兼容。
$snapshotPhone = $this->extractOrderPhone($data['product_snapshot'] ?? [], $depth + 1);
if ($snapshotPhone !== '') {
return $snapshotPhone;
}
return '';
}
/**
* 通过快递公司名称解析编码
* 先精确匹配名称,再双向包含匹配(用户填写可能带"速运"等后缀差异)
* 匹配不到返回 null,由物流驱动按单号自动识别所属快递公司
* @param string $name 快递公司名称
* @return string|null
*/
protected function resolveExpressCode(string $name): ?string
{
if ($name === '') {
return null;
}
try {
/** @var ExpressServices $expressServices */
$expressServices = $this->app->make(ExpressServices::class);
$list = $expressServices->expressList();
// 先精确匹配名称
foreach ($list as $item) {
if (($item['name'] ?? '') === $name) {
return $item['code'] ?? null;
}
}
// 再双向包含匹配,兼容"顺丰"与"顺丰速运"等填写差异
foreach ($list as $item) {
$itemName = (string)($item['name'] ?? '');
if ($itemName === '') {
continue;
}
if (mb_strpos($name, $itemName) !== false || mb_strpos($itemName, $name) !== false) {
return $item['code'] ?? null;
}
}
} catch (\Throwable $e) {
// 查询映射失败不阻断流程,交由驱动自动识别
}
return null;
}
// ==================== 旧退款链路承接(拦截判断/角标统计/拼团代申请/kefu·pc 列表) ====================
// 承接自旧退款服务(已删除):发货/收货/核销拦截、用户售后角标、
// 拼团失败系统代申请、客服与 PC 端售后列表/详情的统一出口
/**
* 订单是否存在处理中的售后单(发货/收货/核销前拦截)
* 语义对齐旧退款链路"申请退款中/待退货/待收货"四态:补发/维修等非退款态不拦截
* @param int $orderCenterId 订单中台ID
* @return bool
*/
public function hasActiveByOrderId(int $orderCenterId): bool
{
return Db::name('after_sale')
->where('order_center_id', $orderCenterId)
->whereIn('status', ['pending_audit', 'audit_passed', 'pending_return', 'receiving'])
->where('is_del', 0)
->count() > 0;
}
/**
* 按状态分组统计售后单数(用户售后角标)
* @param int|null $uid 用户ID(null 为全站统计)
* @param string $group refunding=处理中 / rejected=已拒绝 / refunded=已退款
* @return int
*/
public function getCountByStatusGroup(?int $uid, string $group): int
{
$query = Db::name('after_sale')->where('is_del', 0);
if ($uid) {
$query->where('uid', $uid);
}
switch ($group) {
case 'refunding':
// 处理中:四大进行态(对齐旧 refund_type [1,2,4,5] 语义)
$query->whereIn('status', ['pending_audit', 'audit_passed', 'pending_return', 'receiving']);
break;
case 'rejected':
$query->where('status', 'audit_rejected');
break;
case 'refunded':
// 已退款:退款型终态;兼容历史误写入 completed 的退款单
$query->where(function ($q) {
$q->where('status', 'refunded')->whereOr(function ($q2) {
$q2->where('status', 'completed')
->whereIn('type', ['refund_only', 'return_refund']);
});
});
break;
default:
return 0;
}
return (int)$query->count();
}
/**
* 按订单号获取待审核的售后单(客服端处理"仅退款申请中"订单时定位售后单)
* @param string $orderNo 订单号(中台 order_no)
* @return array|null 售后单数据,无待审核单时返回 null
*/
public function getPendingAuditByOrderNo(string $orderNo): ?array
{
$afterSale = Db::name('after_sale')
->where('order_no', $orderNo)
->where('status', 'pending_audit')
->where('is_del', 0)
->order('id', 'desc')
->find();
return $afterSale ?: null;
}
/**
* 系统代建售后申请(拼团超时/人数不足等场景)
* 仅创建申请单(pending_audit)等待后台处理,不自动退款——语义对齐旧链路拼团退款申请
* @param array $order 订单中台数据(需含 order_no/uid)
* @param string $reason 申请原因
* @return array|null 售后单数据(重复申请等异常时返回 null 静默幂等)
*/
public function createSystemApply(array $order, string $reason): ?array
{
try {
return $this->apply([
'order_no' => (string)($order['order_no'] ?? ''),
'type' => 'refund_only',
'uid' => (int)($order['uid'] ?? 0),
// items 留空:apply 内部自动选择全部可退商品(整单售后)
'items' => [],
'reason' => $reason,
]);
} catch (\Throwable $e) {
// 已存在进行中售后单/无可退商品等场景静默跳过,保证拼团批量处理不被单笔中断
return null;
}
}
/**
* 客服/PC 端售后列表(承接旧退款列表契约)
* @param array $where order_id=售后单号模糊 / time=申请时间范围 / refundTypes=状态分组 / real_name=用户·单号模糊 / uid
* @return array list + count + num(七组状态计数对齐旧 tab 结构)
*/
public function getRefundListForKefu(array $where): array
{
[$page, $limit] = $this->getPageValue();
$query = Db::name('after_sale')->where('is_del', 0);
// 售后单号 / 关联订单号 模糊查询
$orderId = (string)($where['order_id'] ?? '');
if ($orderId !== '') {
$query->where(function ($q) use ($orderId) {
$q->whereLike('after_sale_no', '%' . $orderId . '%')->whereOr(function ($q2) use ($orderId) {
$q2->whereIn('order_center_id', function ($sub) use ($orderId) {
$sub->name('order_center')->field('id')->whereLike('order_no', '%' . $orderId . '%');
});
});
});
}
// 用户昵称/手机号/单号模糊(kefu 聊天用户搜索)
$realName = (string)($where['real_name'] ?? '');
if ($realName !== '') {
$query->where(function ($q) use ($realName) {
$q->whereLike('after_sale_no', '%' . $realName . '%')->whereOr('uid', 'in', function ($sub) use ($realName) {
$sub->name('user')->whereLike('nickname|phone', '%' . $realName . '%')->field('uid')->select();
});
});
}
// 申请时间范围
if (!empty($where['time'])) {
$query->whereBetween('add_time', [strtotime($where['time'][0]), strtotime($where['time'][1]) + 86399]);
}
// 用户限定(PC 端/客服用户订单列表)
if (!empty($where['uid'])) {
$query->where('uid', (int)$where['uid']);
}
// 状态分组筛选(旧 refundTypes 数字语义映射)
if (isset($where['refundTypes']) && $where['refundTypes'] !== '' && $where['refundTypes'] !== 0) {
switch ((int)$where['refundTypes']) {
case 1: // 退款处理中
$query->whereIn('status', ['pending_audit', 'audit_passed']);
break;
case 2: // 商品待退货
$query->where('status', 'pending_return');
break;
case 3: // 退货待收货
$query->where('status', 'receiving');
break;
case 4: // 已退款(含历史误标 completed 的退款型)
$query->where(function ($q) {
$q->where('status', 'refunded')->whereOr(function ($q2) {
$q2->where('status', 'completed')
->whereIn('type', ['refund_only', 'return_refund']);
});
});
break;
}
}
$count = (clone $query)->count();
$list = $query->page($page, $limit)->order('id DESC')->select()->toArray();
if ($list) {
// 批量预载订单号/状态(旧结构 store_order_order_id/store_order_status 对齐)
$orderIds = array_unique(array_column($list, 'order_center_id'));
$orderMap = Db::name('order_center')->whereIn('id', $orderIds)->column('order_no,status_code', 'id');
foreach ($list as &$item) {
$item = $this->tidyKefuListItem($item, $orderMap);
}
unset($item);
}
// 七组状态计数(对齐旧 tab 结构,语义映射到新状态机)
$base = Db::name('after_sale')->where('is_del', 0);
$num = [
0 => ['name' => '全部', 'num' => (clone $base)->count()],
1 => ['name' => '仅退款', 'num' => (clone $base)->where('type', 'refund_only')->whereIn('status', ['pending_audit', 'audit_passed'])->count()],
2 => ['name' => '退货退款', 'num' => (clone $base)->where('type', 'return_refund')->whereIn('status', ['pending_audit', 'audit_passed'])->count()],
3 => ['name' => '拒绝退款', 'num' => (clone $base)->where('status', 'audit_rejected')->count()],
4 => ['name' => '商品待退货', 'num' => (clone $base)->where('status', 'pending_return')->count()],
5 => ['name' => '退货待收货', 'num' => (clone $base)->where('status', 'receiving')->count()],
6 => ['name' => '已退款', 'num' => (clone $base)->where(function ($q) {
$q->where('status', 'refunded')->whereOr(function ($q2) {
$q2->where('status', 'completed')->whereIn('type', ['refund_only', 'return_refund']);
});
})->count()],
];
return ['list' => $list, 'count' => $count, 'num' => $num];
}
/**
* 整理客服/PC 端售后列表项(对齐旧退款列表关键字段契约)
* @param array $item 售后单行数据
* @param array $orderMap 订单中台映射 id => [order_no, status_code]
* @return array
*/
protected function tidyKefuListItem(array $item, array $orderMap): array
{
$status = $item['status'] ?? '';
$type = $item['type'] ?? '';
// 旧数字 refund_status:1=处理中 2=已退款 3=已拒绝
// completed 仅对退款型计为已退款;换货/补发/维修的 completed 仍属处理完结而非退款
if ($status === 'refunded'
|| ($status === 'completed' && in_array($type, ['refund_only', 'return_refund'], true))) {
$refundStatus = 2;
} elseif ($status === 'audit_rejected' || $status === 'canceled') {
$refundStatus = 3;
} else {
$refundStatus = 1;
}
$item['refund_status'] = $refundStatus;
$item['refund_status_text'] = $this->resolveStatusText($item);
$item['refund_type_text'] = self::TYPE_TEXT_MAP[$item['type'] ?? ''] ?? ($item['type'] ?? '');
// 旧 _status 结构:-1=申请中 -2=已退款 -3=已拒绝
$typeMap = [1 => [-1, '申请退款中'], 2 => [-2, '已退款'], 3 => [-3, '已拒绝退款']];
[$type, $title] = $typeMap[$refundStatus] ?? [-1, '申请退款中'];
$item['_status'] = ['_type' => $type, '_title' => $title];
// 商品快照 → 旧 cartInfo 结构
$snapshot = $item['product_snapshot'] ?? [];
$snapshot = is_array($snapshot) ? $snapshot : (json_decode((string)$snapshot, true) ?: []);
$items = $this->tidyOutItems($snapshot);
$item['cartInfo'] = $items;
$item['cart_info'] = $items;
$item['_info'] = [];
foreach ($items as $cart) {
$item['_info'][] = ['cart_info' => $cart];
}
// 旧列表兼容键
$item['order_id'] = $item['after_sale_no'] ?? '';
$item['paid'] = 1;
$item['total_num'] = (int)($item['refund_num'] ?? 0);
$item['pay_price'] = $item['refund_price'] ?? '0.00';
$item['refunded_price'] = $item['actual_refund_price'] ?? '0.00';
$item['add_time'] = $item['_add_time'] = !empty($item['add_time']) ? date('Y-m-d H:i:s', (int)$item['add_time']) : '';
$orderInfo = $orderMap[$item['order_center_id'] ?? 0] ?? [];
$item['store_order_order_id'] = $orderInfo['order_no'] ?? '';
$orderStatusMap = [
'pending_payment' => '待支付', 'processing' => '进行中', 'pending_review' => '待评价',
'completed' => '已完成', 'canceled' => '已取消', 'after_sale' => '售后中',
'refunding' => '售后中', 'refunded' => '已退款',
];
$item['store_order_status'] = $orderStatusMap[$orderInfo['status_code'] ?? ''] ?? '';
return $item;
}
/**
* 客服/PC 端售后单详情(按售后单号)
* @param string $uni 售后单号
* @return array
*/
public function getRefundDetailForKefu(string $uni): array
{
$detail = $this->getDetail($uni);
if (empty($detail)) {
throw new \RuntimeException('退款单不存在');
}
$afterSale = $this->dao->getByNo($uni);
$detail = array_merge($afterSale, $detail);
// 旧结构兼容键
$detail['order_id'] = $detail['after_sale_no'] ?? '';
$detail['store_order_id'] = (int)($detail['order_center_id'] ?? 0);
$detail['refund_status_text'] = $this->resolveStatusText($detail);
$detail['cartInfo'] = $detail['cart_info'] = $this->tidyOutItems($detail['product_snapshot'] ?? []);
$detail['refunded_price'] = $detail['actual_refund_price'] ?? '0.00';
$detail['_add_time'] = !empty($detail['add_time']) ? date('Y-m-d H:i:s', (int)$detail['add_time']) : '';
// 关联订单基础信息
$order = Db::name('order_center')->where('id', (int)($detail['order_center_id'] ?? 0))->find();
$detail['store_order_order_id'] = $order['order_no'] ?? '';
$detail['order_center_data'] = $order ?: [];
return $detail;
}
// ==================== 外平台(outapi/out-push)售后能力 ====================
// 承接自旧对外售后服务(已删除):外平台以售后单号(after_sale_no)为键
// 管理本站售后单,推送通知由 OutPushJob 异步执行
/**
* 按售后单号查询
* @param string $afterSaleNo 售后单号
* @return array|null
*/
public function getByNo(string $afterSaleNo): ?array
{
return $this->dao->getByNo($afterSaleNo);
}
/**
* 外平台售后单列表
* @param array $where 查询条件:after_sale_no 售后单号(模糊)、time 时间范围、refund_status 1进行中/2已完成
* @param int $page 页码
* @param int $limit 每页数量
* @return array{list: array, count: int}
*/
public function getListForOut(array $where, int $page = 1, int $limit = 10): array
{
$query = Db::name('after_sale')->where('is_del', 0);
if (!empty($where['after_sale_no'])) {
$query->whereLike('after_sale_no', '%' . $where['after_sale_no'] . '%');
}
if (!empty($where['time'])) {
// 时间范围:[$startTime, $endTime],均为当天零点或具体时间戳
[$start, $end] = is_string($where['time']) ? explode('-', $where['time']) : $where['time'];
$query->whereBetween('add_time', [strtotime($start), strtotime($end) + 86399]);
}
if (($where['refund_status'] ?? 0) == 1) {
// 进行中:未完结且未取消/驳回(含已退款终态)
$query->whereNotIn('status', ['completed', 'refunded', 'canceled', 'audit_rejected']);
} elseif (($where['refund_status'] ?? 0) == 2) {
// 已完结:已完成 + 已退款
$query->whereIn('status', ['completed', 'refunded']);
}
$count = (clone $query)->count();
$list = $query->field('id, after_sale_no, order_no, uid, type, status, refund_price, actual_refund_price, refund_integral, reason, reject_reason, remark, refund_num, add_time, refund_time')
->page($page, $limit)->order('id DESC')->select()->toArray();
foreach ($list as &$item) {
$item['refund_type_name'] = $this->getOutStatusName($item['status']);
$item['type_name'] = $this->getOutTypeName($item['type']);
}
return compact('list', 'count');
}
/**
* 外平台售后单详情
* @param string $afterSaleNo 售后单号
* @return array
*/
public function getOutDetail(string $afterSaleNo): array
{
$info = $this->dao->getByNo($afterSaleNo);
if (empty($info)) {
throw new \RuntimeException('售后单不存在');
}
$snapshot = $info['product_snapshot'] ?? [];
if (is_string($snapshot)) {
$snapshot = json_decode($snapshot, true) ?: [];
}
$info['items'] = $this->tidyOutItems($snapshot);
$info['refund_type_name'] = $this->getOutStatusName($info['status']);
$info['type_name'] = $this->getOutTypeName($info['type']);
unset($info['product_snapshot']);
return $info;
}
/**
* 外平台同意退款(审核通过)
* @param string $afterSaleNo 售后单号
* @return void
*/
public function outAgreeByNo(string $afterSaleNo): void
{
$info = $this->dao->getByNo($afterSaleNo);
if (empty($info)) {
throw new \RuntimeException('售后单不存在');
}
$this->audit((int)$info['id'], 'passed');
}
/**
* 外平台拒绝退款(审核驳回)
* @param string $afterSaleNo 售后单号
* @param string $reason 拒绝原因
* @return void
*/
public function outRefuseByNo(string $afterSaleNo, string $reason): void
{
$info = $this->dao->getByNo($afterSaleNo);
if (empty($info)) {
throw new \RuntimeException('售后单不存在');
}
$this->audit((int)$info['id'], 'rejected', 0, $reason);
}
/**
* 外平台直接执行退款
* 待审核单自动过审后走退款引擎(RefundExecutor 执行真实资金回滚)
* @param string $afterSaleNo 售后单号
* @param float $amount 退款金额
* @return void
*/
public function outRefundByNo(string $afterSaleNo, float $amount): void
{
$info = $this->dao->getByNo($afterSaleNo);
if (empty($info)) {
throw new \RuntimeException('售后单不存在');
}
if ($amount <= 0) {
throw new \RuntimeException('请输入退款金额');
}
if (bccomp((string)$amount, (string)$info['refund_price'], 2) > 0) {
throw new \RuntimeException('退款金额大于申请金额,请修改退款金额');
}
// 待审核状态先自动过审,保持外平台"一键退款"旧语义
if ($info['status'] === 'pending_audit') {
$this->audit((int)$info['id'], 'passed', 0, '', '外平台直接退款自动过审');
}
$this->refund((int)$info['id'], $amount, ['refund_type' => 'original']);
}
/**
* 售后单创建推送(承接自旧对外售后服务的推送能力)
* @param int $afterSaleId 售后单ID
* @param string $pushUrl 推送地址
* @return bool
*/
public function outRefundCreatePush(int $afterSaleId, string $pushUrl): bool
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
$refundInfo = $this->getOutDetail($afterSale['after_sale_no']);
// 附带订单定位块:外平台按中台订单号回查
$refundInfo['order'] = ['id' => (int)$afterSale['order_center_id'], 'order_no' => $afterSale['order_no']];
return out_push($pushUrl, $refundInfo, '售后单');
}
/**
* 售后单取消推送(承接自已删除的旧对外售后服务)
* @param int $afterSaleId 售后单ID
* @param string $pushUrl 推送地址
* @return bool
*/
public function outRefundCancelPush(int $afterSaleId, string $pushUrl): bool
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
$refundInfo = $this->getOutDetail($afterSale['after_sale_no']);
$refundInfo['order'] = ['id' => (int)$afterSale['order_center_id'], 'order_no' => $afterSale['order_no']];
return out_push($pushUrl, $refundInfo, '取消售后单');
}
/**
* 从售后快照构建外平台商品列表(对齐旧契约 items 字段)
* @param array $snapshot product_snapshot
* @return array
*/
protected function tidyOutItems(array $snapshot): array
{
$items = $snapshot['items'] ?? [];
$list = [];
foreach ($items as $item) {
$list[] = [
'store_name' => $item['store_name'] ?? '',
'suk' => $item['sku'] ?? '',
'image' => $item['image'] ?? '',
'price' => sprintf("%.2f", (float)($item['true_price'] ?? 0)),
'cart_num' => (int)($item['cart_num'] ?? 0),
];
}
return $list;
}
/**
* 外平台售后状态中文名
* @param string $status 状态码
* @return string
*/
protected function getOutStatusName(string $status): string
{
$map = [
'pending_audit' => '申请退款中',
'audit_passed' => '审核通过待退款',
'pending_return' => '待退货',
'receiving' => '退货中',
'refunded' => '已退款',
'resending' => '补发中',
'repairing' => '维修中',
'repair_complete'=> '维修完成',
'completed' => '已完成',
'canceled' => '已取消',
'audit_rejected' => '拒绝退款',
];
return $map[$status] ?? $status;
}
/**
* 外平台售后类型中文名
* @param string $type 售后类型
* @return string
*/
protected function getOutTypeName(string $type): string
{
$map = [
'refund_only' => '仅退款',
'return_refund' => '退货退款',
'exchange' => '换货',
'resend' => '补发',
'repair' => '维修',
];
return $map[$type] ?? $type;
}
}