mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
2877 lines
123 KiB
PHP
2877 lines
123 KiB
PHP
<?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;
|
||
}
|
||
}
|