LikeShop 预售活动模块二开:定金膨胀、尾款支付与预售库存独立管控

一、原生活动体系拆解与预售场景的四个缺口

1.1 原生营销活动架构

LikeShop 的营销模块采用插件化、模块化的开发思路,所有营销玩法都做到业务轻耦合、代码轻耦合;营销功能统一放在 app/common/model 和 app/shopapi/logic/ 下,营销模块不直接操作订单,而是通过规则校验和价格计算的方式接入主交易链路。

一次下单请求中,营销逻辑的介入点大致为:用户提交订单 → OrderLogic::create() → 优惠券校验与抵扣计算 → 积分抵扣计算 → 分销关系绑定与佣金预计算 → 生成订单并写入营销快照数据;这个扩展点设计为预售活动的接入提供了天然的基础。

原生系统的活动管理采用 ls_activity_area(活动区域表)和 ls_activity_goods(活动区域商品表)的两层架构,活动以区域维度组织商品,但活动类型组织商品,但活动类型相对基础,主要面向常规促销场景。

1.2 预售场景的四个核心缺口

缺口一:无定金与尾款的两段式支付模型。原生的支付链路是一次性完成支付(REATED → PAYING → PAID),不支持“先付定金锁定资格、后付尾款完成交易”的两段模式;定金订单和尾款订单在原生系统中没有对应的数据结构,订单状态机也无法表达“已付定金、待付尾款”这一中间态。

缺口二:库存扣减时机不明确。原生库存扣减发生在订单支付成功后,但预售场景下有两种合理的扣减策略:定金阶段预扣(锁定库存防止超卖)和尾款阶段实扣;原生系统缺乏配置化和减时机控制,也无法在定金阶段对预售库存做独立管控。

缺口三:无尾款支付时间窗口。原生系统不感知“尾款支付开始时间”和“尾款支付结束时间”两个关键节点;预售活动中,用户支付定金后需要等待尾款支付窗口开启才能付款,窗口关闭后订单自动失效;这套时间驱动的自动化逻辑在原生活动体系中完全缺失。

缺口四:无定金膨胀与定金立减的差异化定价。原生活动的价格计算只处理“原价 - 优惠券 - 积分”的线性模型;预售场景需要支持定金膨胀(定金在尾款支付时按倍率抵扣)和定金扣减(从尾款中直接扣除固定金额)两种差异化定价策略,且需要与阶梯优惠、优惠券、积分形成多层叠加;原生价格引擎缺乏这种多层联动的计算能力。

1.3 二开策略定位

遵循 LikeShop 的二开规范,预售模块采用以下策略:核心业务链路保持稳定,所有扩展基于外围能力实现,新建独立的 PresaleService 和预售数据表体系,不修改 OrderLogic、PayLogic 的核心逻辑;预售订单的状态机扩展通过增加新的订单类型和状态枚举实现,而非修改已有的状态流转逻辑。

二、数据库扩展设计

2.1 预售活动配置表

预售活动配置表是预售模块的核心,承载定位规则、尾款时间窗口、库存策略等全部配置:

sql
CREATE TABLE `ls_presale_activity` (
 `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
 `activity_name` varchar(100) NOT NULL COMMENT '活动名称',
 `goods_id` int UNSIGNED NOT NULL COMMENT '商品ID',
 `item_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '规格ID,0为多规格商品',
 `presale_price` decimal(10,2) NOT NULL COMMENT '预售价(原价)',
 `deposit_amount` decimal(10,2) NOT NULL COMMENT '定金金额',
 `deposit_expand_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '定金膨胀金额(尾款可抵扣金额,必须≥定金金额)',
 `deposit_type` tinyint NOT NULL DEFAULT 1 COMMENT '定金类型:1-定金膨胀,2-定金立减',
 `deposit_reduce_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '定金立减金额(直接从尾款中扣除)',
 `presale_stock` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '预售专属库存',
 `stock_deduct_type` tinyint NOT NULL DEFAULT 1 COMMENT '库存扣减时机:1-付定金时预扣,2-付尾款时实扣',
 `deposit_start_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '定金支付开始时间',
 `deposit_end_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '定金支付结束时间',
 `balance_start_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款支付开始时间',
 `balance_end_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款支付结束时间',
 `delivery_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '预计发货时间',
 `is_ladder` tinyint NOT NULL DEFAULT 0 COMMENT '是否开启阶梯优惠:0-否,1-是',
 `ladder_config` text COMMENT '阶梯优惠配置,JSON格式:[{"num":50,"discount":0.8},{"num":100,"discount":0.7}]',
 `limit_buy` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '每人限购数量,0为不限',
 `deposit_refundable` tinyint NOT NULL DEFAULT 0 COMMENT '定金是否可退:0-不可退(需付尾款后申请退货),1-可退',
 `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
 `create_time` int UNSIGNED NOT NULL DEFAULT 0,
 `update_time` int UNSIGNED NOT NULL DEFAULT 0,
 PRIMARY KEY (`id`),
 KEY `idx_goods_id` (`goods_id`),
 KEY `idx_status_time` (`status`, `deposit_start_time`, `deposit_end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预售活动配置表';

deposit_type 字段区分两种定金策略:定金膨胀(deposit_expand_amount)和定金立减(deposit_reduce_amount);定金膨胀优惠金额是指定金在支付尾款时可以优惠的金额,必须大于等于定金本身;例如设置定金100、定金膨胀金额200,则尾款支付时自动优惠200;定金立减则是从尾款中直接扣除固定金额,计算更简单直接。

ladder_config 字段支持阶梯优惠,即达到目标人数后进行折扣的功能;例如设置满50件8折、满100件7折,当预售定金时间结束时根据实际下单件数匹配对应的折扣档位。

2.2 预售订单扩展表

在订单表之外新建预售订单扩展表,记录预售专属的定金、尾款、库存等数据:

sql

CREATE TABLE `ls_presale_order` (
 `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
 `activity_id` int UNSIGNED NOT NULL COMMENT '预售活动ID',
 `user_id` int UNSIGNED NOT NULL COMMENT '用户ID',
 `deposit_order_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '定金订单ID(ls_order.id)',
 `deposit_order_sn` varchar(32) NOT NULL DEFAULT '' COMMENT '定金订单号',
 `balance_order_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款订单ID',
 `balance_order_sn` varchar(32) NOT NULL DEFAULT '' COMMENT '尾款订单号',
 `goods_id` int UNSIGNED NOT NULL COMMENT '商品ID',
 `item_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '规格ID',
 `goods_num` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '购买数量',
 `deposit_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '定金金额快照',
 `deposit_expand_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '定金膨胀/立减金额快照',
 `presale_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '预售价快照',
 `balance_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '尾款应付金额',
 `balance_paid_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '尾款实付金额',
`deposit_pay_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '定金支付时间',
`balance_pay_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款支付时间',
`balance_expire_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款支付截止时间',
`delivery_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '预计发货时间',
`stock_locked` tinyint NOT NULL DEFAULT 0 COMMENT '库存是否已锁定:0-未锁定,1-已锁定',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1-待付定金,2-已付定金待付尾款,3-已付尾款,4-尾款超时关闭,5-已退款',
`remind_status` tinyint NOT NULL DEFAULT 0 COMMENT '尾款提醒状态:0-未提醒,1-已提醒',
`create_time` int UNSIGNED NOT NULL DEFAULT 0,
`update_time` int UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status_expire` (`status`, `balance_expire_time`),
UNIQUE KEY `uk_deposit_order` (`deposit_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预售订单扩展表';

status 字段的枚举设计涵盖了预售订单的完整生命周期:1-待付定金(用户已下单但未付定金)、2-已付定金待付尾款(核心中间态)、3-已付尾款(尾款支付完成)、4-尾款超时关闭(尾款支付窗口关闭后未支付)、5-已退款;balance_expire_time 字段是尾款自动关闭定时任务的关键依据。

三、定金膨胀与尾款计算引擎

3.1 定金阶段的核心逻辑

用户点击支付定金时,系统需要完成以下校验和操作:校验用户是否在限购范围内、校验预售库存是否充足、锁定库存(如果配置为定金阶段预扣)、创建定金订单并记录快照。

php
namespace app\common\service;

class PresaleDepositService
{
/**
 * 用户支付定金
 */
public function payDeposit($activityId, $userId, $goodsNum = 1)
{
    $activity = PresaleActivityModel::where('id', $activityId)
        ->where('status', 1)->find();
    if (!$activity) {
        throw new \Exception('预售活动不存在或已结束');
    }

    // 1. 校验定金支付时间窗口
    $now = time();
    if ($now < $activity['deposit_start_time']) {
        throw new \Exception('定金支付尚未开始');
    }
    if ($now > $activity['deposit_end_time']) {
        throw new \Exception('定金支付已结束');
    }

    // 2. 校验限购
    if ($activity['limit_buy'] > 0) {
        $boughtNum = PresaleOrderModel::where('activity_id', $activityId)
            ->where('user_id', $userId)
            ->whereIn('status', [2, 3])
            ->sum('goods_num');
        if ($boughtNum + $goodsNum > $activity['limit_buy']) {
            throw new \Exception("该预售商品每人限购{$activity['limit_buy']}件");
        }
    }

    // 3. 校验预售库存并锁定
    $this->checkAndLockStock($activity, $goodsNum);

    // 4. 创建预售订单记录
    Db::startTrans();
    try {
        $presaleOrderId = PresaleOrderModel::create([
            'activity_id'           => $activityId,
            'user_id'               => $userId,
            'goods_id'              => $activity['goods_id'],
            'item_id'               => $activity['item_id'],
            'goods_num'             => $goodsNum,
            'deposit_amount'        => $activity['deposit_amount'],
            'deposit_expand_amount' => $activity['deposit_type'] == 1
                ? $activity['deposit_expand_amount']
                : $activity['deposit_reduce_amount'],
            'presale_price'         => $activity['presale_price'],
            'balance_expire_time'   => $activity['balance_end_time'],
            'delivery_time'         => $activity['delivery_time'],
            'status'                => 1,
            'create_time'           => $now,
        ])->id;

        // 5. 创建定金订单(走原生订单体系,order_type 标识为预售定金)
        $depositOrderSn = generateSn('PD');
        $depositOrderId = OrderModel::create([
            'order_sn'      => $depositOrderSn,
            'user_id'       => $userId,
            'order_type'    => 6, // 6-预售定金订单
            'order_amount'  => $activity['deposit_amount'],
            'goods_amount'  => $activity['deposit_amount'],
            'order_status'  => 0, // 待付款
            'pay_status'    => 0,
            'create_time'   => $now,
        ])->id;

        PresaleOrderModel::where('id', $presaleOrderId)->update([
            'deposit_order_id' => $depositOrderId,
            'deposit_order_sn' => $depositOrderSn,
        ]);

        Db::commit();
        return ['presale_order_id' => $presaleOrderId, 'order_sn' => $depositOrderSn];
    } catch (\Exception $e) {
        Db::rollback();
        throw $e;
    }
}

/**
 * 校验并锁定预售专属库存
 */
private function checkAndLockStock($activity, $goodsNum)
{
    // 使用行锁防止并发超卖
    $activity = PresaleActivityModel::lock(true)->find($activity['id']);

    $lockedStock = PresaleOrderModel::where('activity_id', $activity['id'])
        ->whereIn('status', [1, 2])
        ->sum('goods_num');
    $remainStock = $activity['presale_stock'] - $lockedStock;

    if ($remainStock < $goodsNum) {
        throw new \Exception('预售库存不足');
    }

    // 如果配置为定金阶段预扣,直接扣减预售专属库存
    if ($activity['stock_deduct_type'] == 1) {
        PresaleActivityModel::where('id', $activity['id'])
            ->dec('presale_stock', $goodsNum)->update();
     }
   }
}

预售库存独立管控的核心设计:在 ls_presale_activity 表中设置独立的 presale_stock 字段,与商品的常规库存(ls_goods_item.stock)物理隔离;预售活动通过独立的库存池运作,常规销售不受预售库占用影响,两者互不干扰;库存扣减时机通过 stock_deduct_type 配置化控制。

3.2 定金支付成功的处理

定金订单支付成功后,通过事件监听器更新预售订单状态:

php
class PresaleDepositPaidListener
{
public function handle($orderId)
{
    $order = OrderModel::find($orderId);
    if (!$order || $order['order_type'] != 6) return;

    // 更新预售订单状态
    PresaleOrderModel::where('deposit_order_id', $orderId)->update([
        'status'         => 2, // 已付定金待付尾款
        'deposit_pay_time' => time(),
    ]);

    // 发送定金支付成功通知(含尾款支付时间提醒)
    $presaleOrder = PresaleOrderModel::where('deposit_order_id', $orderId)->find();
    if ($presaleOrder) {
        $activity = PresaleActivityModel::find($presaleOrder['activity_id']);
        MessageService::send(
            $presaleOrder['user_id'],
            '预售定金支付成功',
            "您已成功支付定金,尾款支付时间为 "
            . date('Y-m-d H:i:s', $activity['balance_start_time'])
            . " 至 " . date('Y-m-d H:i:s', $activity['balance_end_time'])
            . ",请及时支付尾款。"
        );
      }
   }
}

3.3 尾款金额计算引擎

尾款金额的计算是预售模块最核心的逻辑,需要按顺序叠加定金膨胀/立减、阶梯优惠、优惠券和积分;根据 LikeShop 原生的预售计算规则,下单计算规则为:

(售价/会员价x阶梯折扣-定金膨胀金-优惠券-积分抵扣)x 超级会员卡折扣

优惠券门槛计算是按“售价 x 阶梯折扣 - 定金膨胀金”来计算的,而非按会员价计算。

php
class PresaleBalanceService
{
/**
 * 计算尾款金额
 */
public function calcBalanceAmount($presaleOrderId, $couponId = 0, $useIntegral = 0)
{
    $presaleOrder = PresaleOrderModel::find($presaleOrderId);
    if (!$presaleOrder || $presaleOrder['status'] != 2) {
        throw new \Exception('预售订单状态不正确');
    }

    $activity = PresaleActivityModel::find($presaleOrder['activity_id']);
    $presalePrice = $presaleOrder['presale_price'];
    $goodsNum = $presaleOrder['goods_num'];

    // 第一步:计算阶梯折扣后的价格
    $ladderPrice = $presalePrice * $goodsNum;
    if ($activity['is_ladder'] == 1 && !empty($activity['ladder_config'])) {
        $ladderConfig = json_decode($activity['ladder_config'], true);
        $totalNum = $this->getPresaleTotalNum($activity['id']);
        $ladderPrice = $this->applyLadderDiscount($presalePrice, $goodsNum, $ladderConfig, $totalNum);
    }

    // 第二步:扣除定金膨胀/立减金额
    $depositDeduct = $presaleOrder['deposit_expand_amount'];

    // 第三步:计算优惠券抵扣(门槛按 "售价×阶梯折扣 - 定金膨胀金" 计算)
    $couponAmount = 0;
    if ($couponId > 0) {
        $couponThreshold = $ladderPrice - $depositDeduct;
        $couponAmount = $this->calcCouponAmount($couponId, $couponThreshold);
    }

    // 第四步:计算积分抵扣
    // 积分最高抵扣价为:(售价/会员价 - 定金膨胀金额 - 优惠券)× 积分百分比
    $integralAmount = 0;
    if ($useIntegral > 0) {
        $integralBase = $ladderPrice - $depositDeduct - $couponAmount;
        $integralAmount = $this->calcIntegralDeduction(
            $presaleOrder['user_id'], $useIntegral, $integralBase
        );
    }

    // 第五步:最终尾款 = 阶梯价 - 定金膨胀 - 优惠券 - 积分
    $balanceAmount = $ladderPrice - $depositDeduct - $couponAmount - $integralAmount;
    $balanceAmount = max(0.01, round($balanceAmount, 2));

    return [
        'presale_price'      => $presalePrice,
        'ladder_price'       => $ladderPrice,
        'deposit_deduct'     => $depositDeduct,
        'coupon_amount'      => $couponAmount,
        'integral_amount'    => $integralAmount,
        'balance_amount'     => $balanceAmount,
    ];
}

/**
 * 应用阶梯折扣
 */
private function applyLadderDiscount($presalePrice, $goodsNum, $ladderConfig, $totalNum)
{
    $discount = 1.0;
    // 按阶梯人数降序排列,匹配第一个满足条件的档位
    usort($ladderConfig, fn($a, $b) => $b['num'] - $a['num']);
    foreach ($ladderConfig as $ladder) {
        if ($totalNum >= $ladder['num']) {
            $discount = $ladder['discount'];
            break;
        }
    }
    return round($presalePrice * $goodsNum * $discount, 2);
}

private function getPresaleTotalNum($activityId)
{
    // 统计该活动下定金已支付的总件数
    return PresaleOrderModel::where('activity_id', $activityId)
        ->whereIn('status', [2, 3])
        ->sum('goods_num');
   }
 }
四、尾款支付定时任务与自动提醒

4.1 尾款支付时间窗口控制

尾款支付窗口由 balance_start_time 和 balance_end_time 两个字段控制;用户支付定金后,在窗口开启前无法支付尾款,窗口关闭后订单自动失效。

php
/**
* 校验尾款支付是否在时间窗口内
*/
public function checkBalanceTimeWindow($presaleOrderId)
{
$presaleOrder = PresaleOrderModel::find($presaleOrderId);
$activity = PresaleActivityModel::find($presaleOrder['activity_id']);

$now = time();
if ($now < $activity['balance_start_time']) {
    throw new \Exception('尾款支付尚未开始,开始时间为 '
        . date('Y-m-d H:i:s', $activity['balance_start_time']));
}
if ($now > $activity['balance_end_time']) {
    throw new \Exception('尾款支付已结束');
}
return true;
}

4.2 尾款自动提醒定时任务

LikeShop 的定时任务在管理后台配置和开关,设置路径是「系统设置 → 系统维护 → 定时任务」;所有任务共用 php think crontab 命令,由 PHP 层面的调度器统一管理,新增任务时只需要在数据库中添加记录。

尾款自动提醒的定时任务设计为:扫描所有状态为“已付定金待付尾款”且尾款支付窗口即将开启或即将关闭的订单,按提醒策略发送通知:

php
class PresaleBalanceRemindCrontab
{
/**
 * 尾款支付提醒(每10分钟执行一次)
 */
public function remindBalance()
{
    $now = time();

    // 场景一:尾款支付窗口即将开启(提前1小时提醒)
    $upcomingOrders = PresaleOrderModel::alias('po')
        ->join('presale_activity pa', 'po.activity_id = pa.id')
        ->where('po.status', 2)
        ->where('pa.balance_start_time', '>', $now)
        ->where('pa.balance_start_time', '<=', $now + 3600)
        ->where('po.remind_status', 0)
        ->field('po.id, po.user_id, pa.balance_start_time, pa.balance_end_time')
        ->select();

    foreach ($upcomingOrders as $order) {
        MessageService::send(
            $order['user_id'],
            '预售尾款支付提醒',
            '您参与的预售活动尾款支付即将开始,支付时间为 '
            . date('Y-m-d H:i:s', $order['balance_start_time'])
            . ' 至 ' . date('Y-m-d H:i:s', $order['balance_end_time'])
            . ',请及时支付尾款。'
        );
        PresaleOrderModel::where('id', $order['id'])
            ->update(['remind_status' => 1]);
    }

    // 场景二:尾款支付窗口即将关闭(提前24小时和2小时各提醒一次)
    $expiringOrders = PresaleOrderModel::alias('po')
        ->join('presale_activity pa', 'po.activity_id = pa.id')
        ->where('po.status', 2)
        ->where('pa.balance_end_time', '>', $now)
        ->where('pa.balance_end_time', '<=', $now + 86400)
        ->field('po.id, po.user_id, pa.balance_end_time')
        ->select();

    foreach ($expiringOrders as $order) {
        $hoursLeft = round(($order['balance_end_time'] - $now) / 3600);
        MessageService::send(
            $order['user_id'],
            '尾款支付即将截止',
            "您的预售订单尾款支付还剩 {$hoursLeft} 小时截止,"
            . "截止时间为 " . date('Y-m-d H:i:s', $order['balance_end_time'])
            . ",逾期订单将自动取消,定金不予退还。"
        );
      }
    }
} 

4.3 尾款超时自动关闭任务

尾款支付窗口关闭后,未支付尾款的预售订单自动关闭;根据 deposit_refundable 配置决定是否退还定金:

php
class PresaleBalanceExpireCrontab
{
/**
 * 尾款超时自动关闭(每分钟执行)
 */
public function closeExpiredOrders()
{
    $now = time();

    $expiredOrders = PresaleOrderModel::where('status', 2)
        ->where('balance_expire_time', '<=', $now)
        ->where('balance_expire_time', '>', 0)
        ->limit(50)
        ->select();

    foreach ($expiredOrders as $order) {
        Db::startTrans();
        try {
            $order = PresaleOrderModel::lock(true)->find($order['id']);
            if ($order['status'] != 2) {
                Db::rollback();
                continue;
            }

            $activity = PresaleActivityModel::find($order['activity_id']);

            // 关闭预售订单
            PresaleOrderModel::where('id', $order['id'])->update([
                'status'      => 4,
                'update_time' => $now,
            ]);

            // 释放预售库存(如果定金阶段已扣减)
            if ($activity['stock_deduct_type'] == 1) {
                PresaleActivityModel::where('id', $activity['id'])
                    ->inc('presale_stock', $order['goods_num'])->update();
            }

            // 根据配置决定是否退还定金
            if ($activity['deposit_refundable'] == 1 && $order['deposit_pay_time'] > 0) {
                RefundService::refundDeposit(
                    $order['deposit_order_id'],
                    $order['deposit_amount'],
                    '尾款超时未支付,定金退还'
                );
            }

            // 发送通知
            MessageService::send(
                $order['user_id'],
                '预售订单已关闭',
                '您的预售订单尾款支付已超时,订单已自动关闭。'
                . ($activity['deposit_refundable'] ? '定金已退还至原支付账户。' : '')
            );

            Db::commit();
        } catch (\Exception $e) {
            Db::rollback();
            Log::error("预售尾款超时关闭失败 order_id={$order['id']}: " . $e->getMessage());
        }
       }
     }
   }
五、尾款支付与发货时间自定义

5.1 尾款支付流程

尾款支付在预售订单状态为“已付定金待付尾款”时触发,金额计算完成后生成尾款订单:

php
public function payBalance($presaleOrderId, $userId, $couponId = 0, $useIntegral = 0)
{
$presaleOrder = PresaleOrderModel::where('id', $presaleOrderId)
    ->where('user_id', $userId)->find();
if (!$presaleOrder || $presaleOrder['status'] != 2) {
    throw new \Exception('预售订单状态不正确');
}

// 校验尾款支付时间窗口
$this->checkBalanceTimeWindow($presaleOrderId);

// 计算尾款金额
$amountResult = $this->calcBalanceAmount($presaleOrderId, $couponId, $useIntegral);

Db::startTrans();
try {
    // 创建尾款订单
    $balanceOrderSn = generateSn('PB');
    $balanceOrderId = OrderModel::create([
        'order_sn'     => $balanceOrderSn,
        'user_id'      => $userId,
        'order_type'   => 7, // 7-预售尾款订单
        'order_amount' => $amountResult['balance_amount'],
        'goods_amount' => $amountResult['ladder_price'],
        'order_status' => 0,
        'pay_status'   => 0,
        'create_time'  => time(),
    ])->id;

    PresaleOrderModel::where('id', $presaleOrderId)->update([
        'balance_order_id'   => $balanceOrderId,
        'balance_order_sn'   => $balanceOrderSn,
        'balance_amount'     => $amountResult['balance_amount'],
    ]);

    Db::commit();
    return ['balance_order_sn' => $balanceOrderSn, 'amount' => $amountResult];
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
  }
}

尾款订单支付成功后,通过事件监听器更新预售订单状态并触发后续流程(尾款阶段实扣库存、更新商品实时库存、进入发货流程):

php
class PresaleBalancePaidListener
{
public function handle($orderId)
{
    $order = OrderModel::find($orderId);
    if (!$order || $order['order_type'] != 7) return;

    $presaleOrder = PresaleOrderModel::where('balance_order_id', $orderId)->find();
    if (!$presaleOrder) return;

    Db::startTrans();
    try {
        PresaleOrderModel::where('id', $presaleOrder['id'])->update([
            'status'              => 3,
            'balance_pay_time'    => time(),
            'balance_paid_amount' => $order['order_amount'],
        ]);

        $activity = PresaleActivityModel::find($presaleOrder['activity_id']);

        // 如果配置为尾款阶段实扣库存
        if ($activity['stock_deduct_type'] == 2) {
            // 扣减预售专属库存
            PresaleActivityModel::where('id', $activity['id'])
                ->dec('presale_stock', $presaleOrder['goods_num'])->update();
            // 扣减商品实际库存
            GoodsItemModel::where('id', $activity['item_id'])
                ->dec('stock', $presaleOrder['goods_num'])->update();
        }

        // 更新主订单的可发货状态(待发货)
        // 发货时间由 delivery_time 字段控制

        Db::commit();
    } catch (\Exception $e) {
        Db::rollback();
        throw $e;
      }
    }
}

5.2 发货时间自定义

预售商品的发货时间由 ls_presale_activity.delivery_time 字段控制;支持两种配置模式:

方式一:固定时间发货。设置一个具体的发货时间(如“2026年11月15日”),所有预售订单统一在指定时间后进入发货流程。

方式二:尾款支付后N天发货。设置尾款支付后N天发货,发货时间 = 尾款支付时间 + N 天。

在发货逻辑中,定时任务扫描已付尾款且到达发货时间的预售订单,将其推入待发货队列:

php
class PresaleDeliveryCrontab
{
/**
 * 预售发货时间到达后自动进入待发货流程(每30分钟执行)
 */
public function checkDeliveryTime()
{
    $now = time();

    $readyOrders = PresaleOrderModel::where('status', 3)
        ->where('delivery_time', '<=', $now)
        ->where('delivery_time', '>', 0)
        ->limit(100)
        ->select();

    foreach ($readyOrders as $order) {
        // 更新关联的尾款订单状态为待发货
        if ($order['balance_order_id'] > 0) {
            OrderModel::where('id', $order['balance_order_id'])
                ->where('order_status', 1)
                ->update(['order_status' => 2]); // 2-待发货

            // 通知商家发货
            MessageService::sendToShop(
                $order['goods_id'],
                '预售订单待发货',
                "预售订单 {$order['balance_order_sn']} 已到达发货时间,请及时处理。"
            );
         }
     }
  }
}
六、后台配置与开发注意事项

6.1 后台配置清单

在“营销 → 预售活动”中增加活动配置页面:

配置项类型说明
活动名称文本输入
预售商品商品选择器支持选择商品和规格
预售价金额输入
定金金额金额输入
定金类型单选定金膨胀 / 定金立减
定金膨胀金额金额输入定金类型为膨胀时填写,必须≥定金金额
定金立减金额金额输入定金类型为立减时填写
预售专属库存数字输入独立于商品常规库存
库存扣减时机单选付定金时预扣 / 付尾款时实扣
定金支付时间日期时间范围开始时间 / 结束时间
尾款支付时间日期时间范围开始时间 / 结束时间
预计发货时间日期时间选择固定时间或尾款后N天
阶梯优惠开关+配置满N件打X折,支持多档位
限购数量数字输入0为不限
定金是否可退开关尾款超时后是否退还定金

6.2 开发注意事项

定金订单的地址选择时机。参考行业实践,定金订单只是选择规则付款生成,不选择下单地址等参数,当支付尾款的时候才选择正常的下单参数;这意味着预售订单在定金阶段不需要收货地址,尾款支付阶段才收集地址信息。

定金与优惠券的兼容规则。优惠券门槛的计算按“售价 x 阶梯折扣 - 定金膨胀金”进行,不使用会员价计算;这意味着优惠券可用性判断发生在尾款支付阶段,而非定金阶段。

库存隔离的核心原则。预售专属库存(presale_stock)与商品常规库存(ls_goods_item.stock)必须物理隔离;在多商户场景下,库存扣减必须按商户维度隔离,不能出现A商户的库存被B商户的用户扣减的情况;如果采用定金阶段和策略,需要确保预扣操作在独立的事务和行锁保护下完成。

定金不可直接退的约束。用户要退回定金需要付尾款后申请退货,那样定金也就一并退了;或者联系商家私下退款定金(后台操作);二开中应保留这一约束,同时在后台提供商家手动退款定金的入口。

定时任务的依赖关系。尾款提醒、尾款超时关闭、预售发货三个定时任务存在时序依赖:提醒任务在尾款窗口期间执行,超时关闭任务在窗口关闭后执行,发货任务在尾款支付且到达发货时间执行;LikeShop 的定时任务主要处理订单退款扫描、关闭超时未支付订单、佣金自动结算、活动自动开启/结束等场景;预售相关的定时任务作为新增子任务接入,需要注意与已有任务的执行时间错开,避免资源竞争。

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