fix(售后维修): 维修完成可填寄回物流并完结恢复订单,退款终态统一 refunded

已退款 Tab 兼容历史 completed;售后状态 Tab 避免空字符串被 EP 序号化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
吴昊天 2026-10-26 15:17:04 +08:00
parent 89a8ea13d8
commit 02c11e3e19
7 changed files with 245 additions and 70 deletions

View File

@ -233,7 +233,7 @@ class AfterSaleController extends AuthController
/**
* 维修完成
* 维修完成后商家寄回商品,进入补发流程
* 可附带寄回物流;完成后售后完结,订单恢复为申请前状态
* @param int $id 售后单主键
* @return mixed
*/
@ -242,12 +242,17 @@ class AfterSaleController extends AuthController
if ($id <= 0) {
return app('json')->fail('参数错误');
}
[$repairRemark] = $this->request->postMore([
[$repairRemark, $deliveryType, $deliveryNo] = $this->request->postMore([
['repair_remark', ''],
['delivery_type', ''],
['delivery_no', ''],
], true);
try {
$this->services->repairComplete($id, $repairRemark);
return app('json')->success('维修完成');
$this->services->repairComplete($id, (string)$repairRemark, [
'delivery_type' => (string)$deliveryType,
'delivery_no' => (string)$deliveryNo,
]);
return app('json')->success('维修完成,订单已恢复原状态');
} catch (\RuntimeException $e) {
return app('json')->fail($e->getMessage());
}

View File

@ -266,9 +266,13 @@ class AfterSaleDao extends BaseDao
$processingStatuses = ['pending_audit', 'audit_passed', 'refunding', 'pending_return', 'receiving', 'resending', 'repairing', 'repair_complete'];
$query->whereIn('status', $processingStatuses);
} elseif ($mode === 'refunded') {
// 已退款完成:退款/退货退款类型且已完结
$query->whereIn('status', ['refunded', 'completed'])
->whereIn('type', ['refund_only', 'return_refund']);
// 已退款:退款落账终态;兼容历史误写入 completed 的退款型售后
$query->where(function ($q) {
$q->where('status', 'refunded')->whereOr(function ($q2) {
$q2->where('status', 'completed')
->whereIn('type', ['refund_only', 'return_refund']);
});
});
}
// all 模式:不追加状态条件,返回所有有售后记录的订单号

View File

@ -1624,30 +1624,59 @@ class AfterSaleServices extends BaseService
/**
* 维修完成
* 记录维修结果与可选寄回物流后,直接完结售后并恢复中台订单到申请前状态。
* 兼容已停在 repair_complete、尚未完结的历史单。
*
* @param int $afterSaleId
* @param string $repairRemark
* @param array $deliveryData 可选寄回物流 delivery_type/delivery_no
* @return void
*/
public function repairComplete(int $afterSaleId, string $repairRemark = ''): void
public function repairComplete(int $afterSaleId, string $repairRemark = '', array $deliveryData = []): void
{
$afterSale = $this->dao->get($afterSaleId);
if (empty($afterSale)) {
throw new \RuntimeException('售后单不存在');
}
if ($afterSale['status'] !== 'repairing') {
if (!in_array($afterSale['status'], ['repairing', 'repair_complete'], true)) {
throw new \RuntimeException('当前状态不可维修完成');
}
$this->dao->update($afterSaleId, [
'status' => 'repair_complete',
'repair_remark' => $repairRemark,
'repair_complete_time'=> time(),
]);
if (($afterSale['type'] ?? '') !== 'repair') {
throw new \RuntimeException('非维修类型售后不可执行维修完成');
}
$lifecycle = $this->getLifecycleHandler($afterSale['product_type']);
if ($lifecycle) {
$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);
}
/**
@ -1945,26 +1974,31 @@ class AfterSaleServices extends BaseService
*/
public function getListForAdmin(array $where, int $page, int $limit): array
{
$map = [];
// 售后单号模糊查询
// 关联键走模型搜索器(status/type 等);模糊/区间条件用三元数组
$map = ['is_del' => 0];
if (!empty($where['after_sale_no'])) {
$map[] = ['after_sale_no', 'like', '%' . $where['after_sale_no'] . '%'];
$map[] = ['after_sale_no', 'like', '%' . trim((string)$where['after_sale_no']) . '%'];
}
// 订单号模糊查询
if (!empty($where['order_no'])) {
$map[] = ['order_no', 'like', '%' . $where['order_no'] . '%'];
$map[] = ['order_no', 'like', '%' . trim((string)$where['order_no']) . '%'];
}
// 精确筛选
foreach (['status', 'type', 'product_type', 'order_category'] as $field) {
if (!empty($where[$field])) {
$map[] = [$field, '=', $where[$field]];
// 状态:单值精确;逗号串/数组走 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']]];
}
$map[] = ['is_del', '=', 0];
$list = $this->dao->getList($map, $page, $limit);
// 附加文案
@ -2383,8 +2417,13 @@ class AfterSaleServices extends BaseService
$query->where('status', 'audit_rejected');
break;
case 'refunded':
// 已退款:refunded 为退款落账瞬时态、completed 为终态,双取兜底历史数据
$query->whereIn('status', ['refunded', 'completed']);
// 已退款:退款型终态;兼容历史误写入 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;
@ -2482,8 +2521,13 @@ class AfterSaleServices extends BaseService
case 3: // 退货待收货
$query->where('status', 'receiving');
break;
case 4: // 已退款
$query->whereIn('status', ['refunded', 'completed']);
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;
}
}
@ -2509,7 +2553,11 @@ class AfterSaleServices extends BaseService
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)->whereIn('status', ['refunded', 'completed'])->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];
}
@ -2523,8 +2571,11 @@ class AfterSaleServices extends BaseService
protected function tidyKefuListItem(array $item, array $orderMap): array
{
$status = $item['status'] ?? '';
$type = $item['type'] ?? '';
// 旧数字 refund_status:1=处理中 2=已退款 3=已拒绝
if (in_array($status, ['refunded', 'completed'])) {
// 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;
@ -2629,11 +2680,11 @@ class AfterSaleServices extends BaseService
$query->whereBetween('add_time', [strtotime($start), strtotime($end) + 86399]);
}
if (($where['refund_status'] ?? 0) == 1) {
// 进行中:未完结且未取消/驳回
$query->whereNotIn('status', ['completed', 'canceled', 'audit_rejected']);
// 进行中:未完结且未取消/驳回(含已退款终态)
$query->whereNotIn('status', ['completed', 'refunded', 'canceled', 'audit_rejected']);
} elseif (($where['refund_status'] ?? 0) == 2) {
// 已完成
$query->where('status', 'completed');
// 已完结:已完成 + 已退款
$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')

View File

@ -181,8 +181,9 @@ class RefundEffectServices extends BaseService implements RefundEffectContextInt
$refundTradeNo = $manualTradeNo !== ''
? $manualTradeNo
: (string)($locked['channel_refund_id'] ?: $locked['refund_no']);
// 退款型售后终态为 refunded(已退款 tab);换货/补发/维修走 complete() 才是 completed
$this->afterSaleDao->update((int)$afterSale['id'], [
'status' => 'completed',
'status' => 'refunded',
'actual_refund_price' => (string)$locked['amount'],
'refund_type' => (string)$locked['refund_method'],
'refund_trade_no' => $refundTradeNo,

View File

@ -14,6 +14,7 @@ use crmeb\trade\order\aftersale\BaseAfterSaleHandler;
use app\dao\order\AfterSaleDao;
use modules\product\normal\dao\NormalOrderDao;
use modules\product\normal\dao\NormalProductDao;
use modules\product\normal\service\OrderServices as NormalOrderServices;
use app\services\order\OrderCenterServices;
/**
@ -271,4 +272,118 @@ class NormalAfterSaleHandler extends BaseAfterSaleHandler
'refund_status' => 0,
]);
}
/**
* 补发/维修寄回后
* 扩展表进入待收货,并同步中台发货信息
* @param string $afterSaleNo
* @param array $deliveryData
* @return void
*/
public function afterResend(string $afterSaleNo, array $deliveryData): void
{
$orderNo = $this->getOrderNo($afterSaleNo);
if ($orderNo === '') {
return;
}
$deliveryName = trim((string)($deliveryData['delivery_type'] ?? $deliveryData['delivery_name'] ?? ''));
$deliveryId = trim((string)($deliveryData['delivery_no'] ?? $deliveryData['delivery_id'] ?? ''));
if ($deliveryName === '' && $deliveryId === '') {
return;
}
/** @var NormalOrderServices $orderServices */
$orderServices = app(NormalOrderServices::class);
$orderServices->updateDeliveryInfo(
$orderNo,
$deliveryName !== '' ? $deliveryName : '快递',
$deliveryId,
'express'
);
}
/**
* 非退款型售后完结后
* 必须清掉扩展表 refund_status=1,否则适配器仍会推导为「售后中」
* 维修/换货/补发若已寄回,确保扩展状态为待收货
* @param string $afterSaleNo
* @return void
*/
public function afterComplete(string $afterSaleNo): void
{
$orderNo = $this->getOrderNo($afterSaleNo);
if ($orderNo === '') {
return;
}
$afterSale = $this->getAfterSaleRecord($afterSaleNo);
$type = (string)($afterSale['type'] ?? '');
$deliveryName = trim((string)($afterSale['resend_delivery_type'] ?? ''));
$deliveryId = trim((string)($afterSale['resend_delivery_no'] ?? ''));
$needPendingReceive = in_array($type, ['repair', 'exchange', 'resend'], true)
&& ($deliveryName !== '' || $deliveryId !== '');
$sourceUpdate = ['refund_status' => 0];
if ($needPendingReceive) {
$sourceUpdate['status'] = NormalOrderAdapter::EXT_STATUS_PENDING_RECEIVE;
$sourceUpdate['delivery_type'] = 'express';
if ($deliveryName !== '') {
$sourceUpdate['delivery_name'] = $deliveryName;
}
if ($deliveryId !== '') {
$sourceUpdate['delivery_id'] = $deliveryId;
}
}
$this->orderDao->update(['order_no' => $orderNo], $sourceUpdate);
$centerOrder = $this->centerServices->getOne(['order_no' => $orderNo], 'status_code,extra_info,delivery_time');
$centerUpdate = ['refund_status' => 0];
if (($centerOrder['status_code'] ?? '') === 'after_sale') {
$centerUpdate['status_code'] = \app\model\order\OrderCenter::STATUS_PROCESSING;
}
if ($needPendingReceive) {
$extraInfo = $centerOrder['extra_info'] ?? [];
if (is_string($extraInfo)) {
$extraInfo = json_decode($extraInfo, true) ?: [];
}
$extraInfo['delivery'] = [
'delivery_type' => 'express',
'delivery_name' => $deliveryName !== '' ? $deliveryName : (string)($extraInfo['delivery']['delivery_name'] ?? ''),
'delivery_id' => $deliveryId !== '' ? $deliveryId : (string)($extraInfo['delivery']['delivery_id'] ?? ''),
'delivery_uid' => (int)($extraInfo['delivery']['delivery_uid'] ?? 0),
];
$extraInfo['business_status'] = [
'code' => NormalOrderAdapter::EXT_STATUS_PENDING_RECEIVE,
'text' => '待收货',
'detail' => '维修/补发商品运输中',
'extend' => new \stdClass(),
];
$centerUpdate['extra_info'] = json_encode($extraInfo, JSON_UNESCAPED_UNICODE);
if (empty($centerOrder['delivery_time'])) {
$centerUpdate['delivery_time'] = time();
}
// restoreOrderStatus 可能已把中台写成 processing;寄回场景显式保持 processing
$centerUpdate['status_code'] = \app\model\order\OrderCenter::STATUS_PROCESSING;
}
$this->centerServices->updateRecord($orderNo, $centerUpdate);
}
/**
* @param string $afterSaleNo
* @return array
*/
protected function getAfterSaleRecord(string $afterSaleNo): array
{
try {
/** @var AfterSaleDao $afterSaleDao */
$afterSaleDao = app(AfterSaleDao::class);
$record = $afterSaleDao->getByNo($afterSaleNo);
if (empty($record)) {
return [];
}
return is_object($record) ? $record->toArray() : (array)$record;
} catch (\Throwable $e) {
return [];
}
}
}

View File

@ -278,9 +278,9 @@
v-auth="['admin-order-afterSale-start_repair']"
>开始维修</el-button
>
<!-- 维修中:维修完成 -->
<!-- 维修中 / 已维修完成未完结:维修完成(完结售后并恢复订单原状态) -->
<el-button
v-if="detail.status === 'repairing'"
v-if="detail.status === 'repairing' || detail.status === 'repair_complete'"
type="success"
v-db-click
@click="openRepairComplete"
@ -399,7 +399,12 @@
</el-dialog>
<!-- 维修完成弹窗 -->
<el-dialog v-model="repairModal" title="维修完成" width="500px" @close="repairForm = { repair_remark: '' }">
<el-dialog
v-model="repairModal"
title="维修完成"
width="500px"
@close="repairForm = { repair_remark: '', delivery_type: '', delivery_no: '' }"
>
<el-form :model="repairForm" label-width="100px">
<el-form-item label="维修备注">
<el-input
@ -409,7 +414,13 @@
placeholder="维修过程记录(选填)"
></el-input>
</el-form-item>
<div class="repair-tip">维修完成后将进入补发流程,请填写补发物流寄回用户。</div>
<el-form-item label="寄回快递">
<el-input v-model="repairForm.delivery_type" placeholder="快递公司(选填)"></el-input>
</el-form-item>
<el-form-item label="寄回单号">
<el-input v-model="repairForm.delivery_no" placeholder="快递单号(选填)"></el-input>
</el-form-item>
<div class="repair-tip">填写寄回物流并确认后,售后完结,订单将变为「待收货」。</div>
</el-form>
<template #footer
><div>
@ -463,7 +474,7 @@ const resendModal = ref(false);
const resendForm = ref({ delivery_type: '', delivery_no: '' });
// 维修完成弹窗
const repairModal = ref(false);
const repairForm = ref({ repair_remark: '' });
const repairForm = ref({ repair_remark: '', delivery_type: '', delivery_no: '' });
// 产品类型文案映射(与列表页保持一致)
const productTypeOptions = [
{ key: 'normal', label: '实物商品' },
@ -566,14 +577,13 @@ const canStartRepair = computed(() => {
* 是否可补发商品
* - 补发类型:audit_passed 状态可补发
* - 换货类型:receiving 状态且已确认收货后可补发
* - 维修类型:repair_complete 状态可补发
* - 维修类型:维修完成时一并处理寄回,不再单独补发
*/
const canResend = computed(() => {
if (!detail.value) return false;
const { status, type } = detail.value;
if (type === 'resend' && status === 'audit_passed') return true;
if (type === 'exchange' && status === 'receiving' && isReceived.value) return true;
if (type === 'repair' && status === 'repair_complete') return true;
return false;
});
@ -732,7 +742,7 @@ function handleStartRepair() {
* 打开维修完成弹窗
*/
function openRepairComplete() {
repairForm.value = { repair_remark: '' };
repairForm.value = { repair_remark: '', delivery_type: '', delivery_no: '' };
repairModal.value = true;
}

View File

@ -3,18 +3,6 @@
<el-card :bordered="false" shadow="never" :body-style="{ padding: 0 }">
<div class="padding-add">
<el-form :model="searchForm" label-width="80px" label-position="right" inline @submit.prevent>
<el-form-item label="售后状态:">
<el-select
v-model="searchForm.status"
clearable
@change="getList(1)"
placeholder="全部"
class="form_content_width"
>
<el-option label="全部" value="" />
<el-option v-for="item in statusOptions" :key="item.key" :value="item.key" :label="item.label" />
</el-select>
</el-form-item>
<el-form-item label="售后类型:">
<el-select
v-model="searchForm.type"
@ -85,7 +73,7 @@
</el-card>
<el-card :bordered="false" shadow="never" class="mt16" :body-style="{ padding: '0 0 20px' }">
<el-tabs v-model="statusTab" @tab-change="handleStatusTabClick">
<el-tab-pane label="全部" name=""></el-tab-pane>
<el-tab-pane label="全部" name="all"></el-tab-pane>
<el-tab-pane v-for="item in statusOptions" :key="item.key" :label="item.label" :name="item.key"></el-tab-pane>
</el-tabs>
<div class="table-content">
@ -224,7 +212,6 @@ const router = useRouter();
const routePre = setting.routePre;
const searchForm = ref({
status: '',
type: '',
product_type: '',
order_category: '',
@ -238,7 +225,8 @@ const list = ref([]);
const total = ref(0);
const page = ref({ page: 1, limit: 15 });
const loading = ref(false);
const statusTab = ref('');
/** 状态 Tab:all=全部,其余为售后状态码(勿用空字符串,EP 会回退为序号名) */
const statusTab = ref('all');
const remarkModal = ref(false);
const remarkForm = ref({ id: 0, remark: '' });
const auditModal = ref(false);
@ -255,6 +243,7 @@ const statusOptions = [
{ key: 'pending_return', label: '待退货' },
{ key: 'receiving', label: '待收货' },
{ key: 'repairing', label: '维修中' },
{ key: 'repair_complete', label: '维修完成' },
{ key: 'resending', label: '补发中' },
{ key: 'completed', label: '已完成' },
];
@ -283,9 +272,8 @@ const categoryOptions = [
function getList(res) {
page.value.page = res === 1 ? 1 : page.value.page;
loading.value = true;
// 状态 Tab 优先于筛选下拉
const status = statusTab.value || searchForm.value.status;
// 单号搜索映射到对应字段
// 状态仅来自 Tab:all 表示不筛选,避免与空字符串/筛选下拉互相抢优先级
const status = statusTab.value === 'all' ? '' : statusTab.value;
const params = {
status,
type: searchForm.value.type,
@ -345,7 +333,6 @@ function onchangeTime(e) {
*/
function handleReset() {
searchForm.value = {
status: '',
type: '',
product_type: '',
order_category: '',
@ -355,14 +342,16 @@ function handleReset() {
end_time: '',
};
timeVal.value = [];
statusTab.value = '';
statusTab.value = 'all';
getList(1);
}
/**
* 状态 Tab 切换
* 状态 Tab 切换(以事件入参为准,避免 v-model 时序导致读到旧值)
* @param {string} name Tab name
*/
function handleStatusTabClick() {
function handleStatusTabClick(name) {
statusTab.value = name || 'all';
getList(1);
}
@ -433,10 +422,10 @@ function saveRemark() {
}
/**
* 终态不可备注(已完成/已取消/审核拒绝)
* 终态不可备注(已退款/已完成/已取消/审核拒绝)
*/
function canRemark(status) {
return !['completed', 'canceled', 'audit_rejected'].includes(status);
return !['refunded', 'completed', 'canceled', 'audit_rejected'].includes(status);
}
/**