LikeShop 退换货上门取件二开:第三方快递对接与逆向物流全链路跟踪

一、原生售后退款链路的缺口

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,保证原生售后列表、原生物流展示仍然可用;未启用上门取件的售后单完全走原生自寄流程,不受影响。

0
0
0
0
评论
未登录
暂无评论