1.1 原生售后表已有物流字段,但链路是“用户自寄”
LikeShop 的 ls_after_sale 表已经为退货物流预留了字段:express_name(快递公司名称)、invoice_no(快递单号)、express_time(买家退货时间)、confirm_take_time(确认收货时间);子状态也覆盖了“卖家已同意,等待买家发货”(12)、“买家已发货,等待卖家收货”(13)、“卖家已收货,等待卖家处理”(14) 等逆向节点。
但从操作链路看,原生的“退货”是用户自行联系快递、自行寄出、手动回填单号的模式;用户需要离开商城去叫快递,商家需要人工核对单号,物流轨迹完全不进入系统;售后进度对用户和商家双方都是黑盒。
1.2 上门取件二开的三个核心缺口
缺口一:无取件预约能力。用户无法在售后申请流程中直接预约快递员上门,只能先提交申请、等商家同意、再自己去寄;上门时间、快递公司选择、运费承担方式都没有系统化的记录和管控。
缺口二:无逆向物流轨迹跟踪。单号回填后,系统不会订阅物流轨迹,商家不知道包裹是否已揽收、是否在途、是否已签收;售后状态只能靠人工在“买家已发货”和“卖家已收货”之间手动推进,签收这个关键节点无法自动触发退款。
缺口三:退款与物流进度脱节。退货退款的正确顺序是“仓库签收 → 质检确认 → 退款”;原生系统缺乏“签收事件”的自动感知,退款要么依赖人工确认,要么存在“货未到、款先退”的风险。
2.1 扩展策略
遵循 LikeShop 的扩展优先原则,不修改 ls_after_sale 的核心字段和子状态枚举,新增取件预约表和轨迹表,通过事件监听在“商家同意售后”的扩展点触发取件预约,通过物流订阅回调调动售后子状态的自动流转;退款执行仍然恢复用原生 RefundLogic 的退款通道,只是在触发时机上由“签收事件”未驱动,而非人工点击。
2.2 取件预约表
sql
CREATE TABLE `ls_return_pickup` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`after_sale_id` int UNSIGNED NOT NULL COMMENT '售后单ID',
`after_sale_sn` varchar(32) NOT NULL DEFAULT '' COMMENT '售后单号',
`order_id` int UNSIGNED NOT NULL COMMENT '订单ID',
`provider` varchar(20) NOT NULL COMMENT '快递服务商:kuaidi100/sf/kdniao',
`express_company` varchar(50) NOT NULL DEFAULT '' COMMENT '承运快递公司',
`express_no` varchar(64) NOT NULL DEFAULT '' COMMENT '取件运单号',
`pickup_status` tinyint NOT NULL DEFAULT 0 COMMENT '取件状态:0-待预约,1-已预约,2-已揽收,3-已取消',
`pickup_start_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '预约上门开始时间',
`pickup_end_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '预约上门结束时间',
`sender_name` varchar(50) NOT NULL DEFAULT '' COMMENT '寄件人',
`sender_mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '寄件人手机',
`sender_address` varchar(500) NOT NULL DEFAULT '' COMMENT '寄件地址',
`receiver_name` varchar(50) NOT NULL DEFAULT '' COMMENT '收件人(商家仓库)',
`receiver_mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '收件人手机',
`receiver_address` varchar(500) NOT NULL DEFAULT '' COMMENT '收件地址',
`freight_payer` tinyint NOT NULL DEFAULT 1 COMMENT '运费承担:1-商家,2-用户,3-运费险',
`provider_order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '服务商订单号',
`cancel_reason` varchar(255) NOT NULL DEFAULT '',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_after_sale_id` (`after_sale_id`),
KEY `idx_express_no` (`express_no`),
KEY `idx_pickup_status` (`pickup_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货上门取件预约表';
一张售后单对应一条取件记录(uk_after_sale_id 唯一约束),防止重复预约;express_no 是取件成功后由快递服务商返回的运单号,同时回写到 ls_after_sale.invoice_no,保持原生字段的可读性。
2.3 逆向轨迹表
sql
CREATE TABLE `ls_return_track` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`after_sale_id` int UNSIGNED NOT NULL COMMENT '售后单ID',
`express_no` varchar(64) NOT NULL DEFAULT '' COMMENT '运单号',
`track_status` varchar(20) NOT NULL DEFAULT '' COMMENT '标准化状态:PICKED/IN_TRANSIT/OUT_FOR_DELIVERY/SIGNED/EXCEPTION',
`track_desc` varchar(500) NOT NULL DEFAULT '' COMMENT '原始轨迹描述',
`track_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '轨迹发生时间',
`is_terminal` tinyint NOT NULL DEFAULT 0 COMMENT '是否终态:0-否,1-是(签收/退回)',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_after_sale_id` (`after_sale_id`),
KEY `idx_express_no_status` (`express_no`, `track_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货逆向物流轨迹表';
track_status 做标准化映射,把不同快递公司的轨迹描述归一为统一的状态枚举,避免“已签收”“妥投”“代收”等不同措辞导致状态判断混乱。
3.1 触发时机:商家同意售后之后
取件预约在售后子状态从“等待卖家同意”(11)、流转到“卖家已同意,等待买家发货”(12)时触发;通过售后审核的扩展监听器调用取件预约服务,根据商家的运费政策决定运费承担方:商家承担、用户承担、或走运费险。
3.2 快递服务商适配器
不同快递服务商的取件接口差异很大,用适配器模式隔离:
php
interface ReturnPickupInterface
{
public function createPickup(array $params): array;
public function cancelPickup(string $providerOrderNo): bool;
public function subscribeTrack(string $expressNo, string $company): bool;
}
快递100适配器:通过商家寄件 API 提交寄件请求,支持 EMS、德邦、京东、极兔、圆通、申通、中通、韵达等主流快递,下单成功后自动回传物流单号;同时调用订阅接口提交运单号,物流状态变化时通过回调接口推送轨迹,直到运单生命周期结束(一般以“已签收”为准)。
顺丰主桥适配器:调用退货下单接口(OrderReverseService),必填字段包括寄件方详细地址、联系人、手机,以及上门取件开始时间(格式 YYYY-MM-DD HH24:MM:SS);顺丰支持三天内 8:00-22:00 多个时段预约,最快 1 小时上门。
php
class ReturnPickupService
{
public function createPickup($afterSaleId, $pickupTime, $provider = 'kuaidi100')
{
$afterSale = AfterSaleModel::find($afterSaleId);
if (!$afterSale || $afterSale['refund_method'] != 2) {
throw new \Exception('仅退货退款售后支持上门取件');
}
// 幂等:已有取件记录则直接返回
$exists = ReturnPickupModel::where('after_sale_id', $afterSaleId)->find();
if ($exists) return $exists;
$adapter = ReturnPickupFactory::create($provider);
// 寄件地址来自售后申请时用户填写的退货地址
// 收件地址来自商家配置的退货仓库地址
$params = [
'sender_name' => $afterSale['return_contact'],
'sender_mobile' => $afterSale['return_mobile'],
'sender_address' => $afterSale['return_address'],
'receiver_name' => $this->getMerchantReturnName($afterSale['sid']),
'receiver_mobile' => $this->getMerchantReturnMobile($afterSale['sid']),
'receiver_address' => $this->getMerchantReturnAddress($afterSale['sid']),
'pickup_start' => $pickupTime['start'],
'pickup_end' => $pickupTime['end'],
];
$result = $adapter->createPickup($params);
if (!$result['success']) {
throw new \Exception('取件预约失败:' . $result['message']);
}
Db::startTrans();
try {
ReturnPickupModel::create([
'after_sale_id' => $afterSaleId,
'after_sale_sn' => $afterSale['sn'],
'order_id' => $afterSale['order_id'],
'provider' => $provider,
'express_company' => $result['express_company'],
'express_no' => $result['express_no'],
'pickup_status' => 1,
'pickup_start_time'=> strtotime($pickupTime['start']),
'pickup_end_time' => strtotime($pickupTime['end']),
'freight_payer' => $this->resolveFreightPayer($afterSale),
'provider_order_no'=> $result['provider_order_no'],
'create_time' => time(),
]);
// 回写原生单号字段,保持原生售后列表可读
AfterSaleModel::where('id', $afterSaleId)->update([
'express_name' => $result['express_company'],
'invoice_no' => $result['express_no'],
]);
// 订阅轨迹
$adapter->subscribeTrack($result['express_no'], $result['express_company']);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
return $result;
}
}
3.3 取消与重新预约
用户在快递员上门前可以取消取件;取消时调用服务商的取消接口,将 pickup_status 置为 3,允许重新发起预约;已经揽收(pickup_status = 2)之后不允许取消,只能按正常退货流程推进。
4.1 订阅推送驱动状态自动流转
取件成功后立即向快递100订阅该运单,后续轨迹通过回调接口进入系统;回调处理的核心不是“存轨迹”,而是用轨迹状态驱动售后子状态的自动流转:
| 逆向轨迹标准化状态 | 对应售后子状态流转 | 触发动作 |
|---|---|---|
PICKED(已揽收) | 12 → 13(买家已发货,等待卖家收货) | 记录 express_time,通知商家准备收货 |
IN_TRANSIT(在途) | 保持 13 | 更新轨迹展示,不改变状态 |
SIGNED(已签收) | 13 → 14(卖家已收货,等待卖家处理) | 回写 confirm_take_time,触发“待质检”提醒 |
EXCEPTION(异常/退回) | 保持 13,标记异常 | 通知商家和客服介入 |
这条流转规则把“买家手动确认发货”和“商家手动确认收货”两个人工动作替换为物流事件的自动驱动;商家不再需要盯着单号判断包裹到没到,签收事件本身就是“确认收货”的信号。
php
class ReturnTrackCallbackService
{
public function handle($expressNo, $trackData)
{
$pickup = ReturnPickupModel::where('express_no', $expressNo)->find();
if (!$pickup) return;
$afterSale = AfterSaleModel::where('id', $pickup['after_sale_id'])->find();
if (!$afterSale) return;
$standardStatus = $this->mapTrackStatus($trackData['status_desc']);
// 写入轨迹
ReturnTrackModel::create([
'after_sale_id' => $afterSale['id'],
'express_no' => $expressNo,
'track_status' => $standardStatus,
'track_desc' => $trackData['desc'],
'track_time' => strtotime($trackData['time']),
'is_terminal' => in_array($standardStatus, ['SIGNED', 'RETURNED']) ? 1 : 0,
'create_time' => time(),
]);
// 状态联动:只允许合法流转
Db::startTrans();
try {
$afterSale = AfterSaleModel::lock(true)->find($afterSale['id']);
if ($standardStatus == 'PICKED' && $afterSale['sub_status'] == 12) {
AfterSaleModel::where('id', $afterSale['id'])->update([
'sub_status' => 13,
'express_time' => strtotime($trackData['time']),
]);
$this->notifyMerchant($afterSale, '退货包裹已揽收');
}
if ($standardStatus == 'SIGNED' && $afterSale['sub_status'] == 13) {
AfterSaleModel::where('id', $afterSale['id'])->update([
'sub_status' => 14,
'confirm_take_time'=> strtotime($trackData['time']),
]);
// 签收不自动退款,而是进入“待质检”,由质检结果决定退款或驳回
$this->notifyMerchant($afterSale, '退货包裹已签收,请尽快质检');
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
Log::error("逆向物流状态联动失败 after_sale_id={$afterSale['id']}: " . $e->getMessage());
}
}
}
签收不直接退款,而是流转到“卖家已收货,等待卖家处理”(14);这是逆向流程中一个容易被简化掉、但不能简化的节点:仓库需要验货,确认退回商品与售后商品一致、无调包、无额外损坏,才能执行退款;把签收和退款拆开,是对商家资金安全的基本保护。
4.2 退款与物流进度的最终联动
质检通过后,售后状态从 14 流转到 15 (等待退款),随后调用原生退款通道完成退款,状态变为 21(售后成功);退款动作本身仍然走原生 RefundLogic,不重写支付通道,只是在触发条件上增加了“质检通过”这个前置门槛。
为了让退款只发生一次,在售后单上增加幂等标记,退款执行前用条件更新抢占:
php
$affected = Db::name('after_sale')
->where('id', $afterSaleId)
->where('sub_status', 15)
->where('refund_done', 0)
->update(['refund_done' => 1]);
if (!$affected) {
return 'refund_already_done'; // 已退过,幂等返回
}
// 仅抢占成功才调用退款通道
RefundLogic::refund($afterSaleId);
终态动作(退款、换货出库、补发出库)必须自身幂等,不能因为消息重投货用户重复点击而执行两次。
4.3 超时自动流转兜底
物流回调可能丢失或延迟;定时任务扫描卡在 12(等待买家发货)或 13(等待卖家收货)超过阈值的售后单:12 状态超时约定天数且无取件记录,提醒用户或自动关闭;13 状态超过在途合理时长且无签收轨迹,触发异常工单通知客服介入;自动流转不是替代物流事件,而是作为事件缺失时的安全网。
5.1 后台配置清单
| 配置项 | 类型 | 说明 |
|---|---|---|
| 启用上门取件 | 开关 | 退货退款售后是否开放预约取件 |
| 默认快递服务商 | 单选 | 快递100 / 顺丰丰桥 / 快递鸟 |
| 可选快递公司 | 多选 | 允许用户选择的承运商范围 |
| 可预约时段 | 配置 | 如“09:00-12:00, 14:00-18:00” |
| 运费承担方 | 单选 | 商家承担 / 用户承担 / 运费险 |
| 签收后自动流转 | 开关 | 签收轨迹是否自动推进到“待处理” |
| 签收后质检时限 | 数字(小时) | 超时未质检触发提醒 |
5.2 开发注意事项
取件预约的幂等与取消边界。一张售后单只允许一条有效取件记录;用户在快递员上门前取消后可以重新预约,但已揽收后不可取消;服务商侧取消成功后才更新本地状态,避免本地显示已取消而快递员仍在上门。
轨迹标准化的映射维护。不同快递公司对“签收”的表述不同(已签收、妥投、代收、已代签),映射表需要按服务商分别维护;映射不准确会导致签收事件漏判,状态卡在 13 无法自动推进。
签收与退款的解耦。逆向物流的签收只代表包裹到达,不代表商品符合退款条件;SIGNED 事件驱动到“待质检”(14),质检通过后才允许进入退款(15);客服不能在 14 状态直接跳过质检手动退款,状态白名单中应禁止 14 → 21 的跳转。
兼容性。新增表独立于 ls_after_sale,不修改原生子状态枚举;取件成功后回写 express_name 和 invoice_no,保证原生售后列表、原生物流展示仍然可用;未启用上门取件的售后单完全走原生自寄流程,不受影响。
