CRMEB Pro 积分订单售后避坑:发货、收货、退积分怎么闭环?

CRMEB Pro 积分订单售后避坑:发货、收货、退积分怎么闭环?

摘要

积分商城真正难的不是把商品放出来,也不是让用户点兑换,而是订单后半段:发货怎么走、确认收货怎么改状态、退款时积分怎么返、库存怎么回、账单怎么避免重复。

CRMEB Pro 当前积分兑换订单已经并入统一订单体系,后台积分订单管理通过 type = 4 过滤统一订单。售后退积分和回库存也不是单独改用户积分,而是走统一退款回退链路。

这篇拆第三段:积分订单从后台列表、订单详情、发货、收货,到售后退积分和回库存怎么闭环。

1. 后台积分订单管理其实查的是统一订单

后台积分订单控制器在:

text 复制代码
app/controller/admin/v1/marketing/integral/StoreIntegralOrder.php

注意它注入的是统一订单服务:

php 复制代码
use app\services\order\StoreOrderServices;

class StoreIntegralOrder extends AuthController
{
    #[Inject]
    protected StoreOrderServices $services;
}

列表和统计都强制加 type = 4

php 复制代码
public function chart()
{
    $where = $this->request->getMore([
        ['status', ''],
        ['real_name', ''],
        ['data', '', '', 'time'],
        ['type', ''],
        ['plat_type', 0],
        ['pay_type', ''],
        ['field_key', ''],
        ['store_id', 0],
        ['supplier_id', 0]
    ]);

    if (!in_array($where['status'], [-1, -2, -3])) {
        $where['pid'] = [0, -1];
    }

    $where['type'] = 4;
    $data = $this->services->orderCount($where);
    return $this->success($data);
}

订单列表同样如此:

php 复制代码
public function lst()
{
    $where = $this->request->getMore([
        ['status', ''],
        ['real_name', ''],
        ['data', '', '', 'time'],
        ['type', ''],
        ['plat_type', 0],
        ['pay_type', ''],
        ['field_key', ''],
        ['store_id', 0],
        ['supplier_id', 0]
    ]);

    if (!in_array($where['status'], [-1, -2, -3])) {
        $where['pid'] = [0, -1];
    }

    $where['type'] = 4;
    return $this->success($this->services->getOrderList($where, ['*']));
}

所以后台"积分订单"不是查旧的独立积分订单表,而是查统一订单表里 type = 4 的订单。

2. 后台 API 和页面入口

后台前端积分订单接口在:

text 复制代码
crmeb_pro_admin/src/api/marketing.js

核心接口:

js 复制代码
export function integralOrderList(data) {
  return request({
    url: 'marketing/integral/order/list',
    method: 'get',
    params: data
  });
}

export function integralGetOrdes(data) {
  return request({
    url: 'marketing/integral/order/chart',
    method: 'get',
    params: data
  });
}

export function getIntegralOrderDataInfo(id) {
  return request({
    url: `marketing/integral/order/info/${id}`,
    method: 'get'
  });
}

发货接口:

js 复制代码
export function integralOrderPutDelivery(data) {
  return request({
    url: `marketing/integral/order/delivery/${data.id}`,
    method: 'put',
    data: data.datas
  });
}

物流和配送信息:

js 复制代码
export function getIntegralOrderDistribution(id) {
  return request({
    url: `marketing/integral/order/distribution/${id}`,
    method: 'get'
  });
}

export function getExpress(id) {
  return request({
    url: `marketing/integral/order/express/${id}`,
    method: 'get'
  });
}

这意味着二开后台积分订单时,优先复用统一订单的发货、物流、备注、状态记录能力,不要单独做一套"积分订单发货"。

3. 订单详情会带用户信息和订单格式化

积分订单详情:

php 复制代码
public function order_info($id)
{
    if (!$id || !($orderInfo = $this->services->get($id, ['*'], ['virtual']))) {
        return $this->fail('订单不存在');
    }

    $services = app()->make(UserServices::class);
    $userInfo = $services->getUserWithTrashedInfo((int)$orderInfo['uid']);

    if ($userInfo) {
        $userInfo = $userInfo->hidden([
            'pwd',
            'add_ip',
            'last_ip',
            'login_type'
        ]);
        $userInfo = $userInfo->toArray();
    } else {
        $userInfo = [];
    }

    $orderInfo = $this->services->tidyOrder($orderInfo->toArray());
    return $this->success(compact('orderInfo', 'userInfo'));
}

这里有两个二开点:

text 复制代码
用户信息会隐藏敏感字段
订单展示由 tidyOrder() 统一格式化

如果新增积分订单字段,比如"兑换来源""积分活动批次""人工审核备注",建议在统一订单扩展字段或订单附加信息里处理,再由 tidyOrder() 输出,不要在 Controller 拼一堆临时字段。

4. 积分订单发货复用统一 delivery()

发货接口:

php 复制代码
public function update_delivery($id)
{
    $data = $this->request->postMore([
        ['type', 1],
        ['delivery_name', ''],
        ['delivery_id', ''],
        ['delivery_code', ''],
        ['express_record_type', 2],
        ['express_temp_id', ""],
        ['to_name', ''],
        ['to_tel', ''],
        ['to_addr', ''],
        ['sh_delivery_name', ''],
        ['sh_delivery_id', ''],
        ['sh_delivery_uid', ''],
        ['fictitious_content', '']
    ]);

    return $this->success('SUCCESS', $this->services->delivery((int)$id, $data));
}

这段没有自己改订单状态,而是调用统一订单服务的 delivery()。好处是:

text 复制代码
物流发货
虚拟发货
配送员送货
电子面单
订单状态记录
发货后通知

这些能力都能复用,不会因为积分商城单独实现而漏掉。

5. 确认收货只允许从待收货进入已完成

确认收货接口:

php 复制代码
public function take_delivery($id)
{
    if (!$id) return $this->fail('缺少参数');

    $order = $this->services->get($id);
    if (!$order) {
        return $this->fail('Data does not exist!');
    }

    if ($order['status'] == 3) {
        return $this->fail('不能重复收货!');
    }

    if ($order['status'] == 2) {
        $data['status'] = 3;
    } else {
        return $this->fail('请先发货或者送货!');
    }

    if (!$this->services->update($id, $data)) {
        return $this->fail('收货失败,请稍候再试!');
    }
}

收货成功后会写状态记录:

php 复制代码
$statusService = app()->make(StoreIntegralOrderStatusServices::class);

$statusService->save([
    'oid' => $order['id'],
    'change_type' => 'take_delivery',
    'change_message' => '已收货',
    'change_time' => time()
]);

虽然这里类名是 StoreIntegralOrderStatusServices,但从业务上看,它记录的是积分订单状态变更。二开时如果加"平台自动收货""用户确认收货""后台强制收货",建议也写入状态记录,方便售后追踪。

6. 移动端兑换记录也按 type=4 查

移动端积分订单接口:

php 复制代码
public function lst(Request $request)
{
    $where['uid'] = $request->uid();
    $where['paid'] = 1;
    $where['is_del'] = 0;
    $where['is_system_del'] = 0;
    $where['type'] = 4;

    $list = $this->services->getOrderApiList($where);
    return app('json')->successful($list);
}

订单详情要求已支付:

php 复制代码
public function detail(Request $request, $uni)
{
    if (!strlen(trim($uni))) {
        return app('json')->fail('参数错误');
    }

    $order = $this->services->getOne([
        'order_id' => $uni,
        'is_del' => 0
    ]);

    if (!$order) {
        return app('json')->fail('订单不存在');
    }

    if (!$order['paid']) {
        return app('json')->fail('订单未支付,无法查看');
    }

    $orderData = $this->services->tidyOrder($order->toArray());
    return app('json')->successful('ok', $orderData);
}

这能避免未支付订单被当成有效兑换记录展示。

7. 售后退积分不是只改用户余额

统一售后服务在:

text 复制代码
app/services/order/StoreOrderRefundServices.php

退积分关键方法是 regressionIntegral()。它会处理三类积分:

text 复制代码
订单赠送积分追回
积分兑换支付积分返还
普通下单积分抵扣返还

积分兑换支付积分返还在这里:

php 复制代码
$pay_integral = $order['pay_integral'];

if ($pay_integral > 0) {
    $integral = bcadd((string)$integral, (string)$pay_integral);

    $res2 = $userBillServices->income(
        'order_integral_refund',
        $order['uid'],
        (int)$pay_integral,
        (int)$integral,
        $order['id']
    );
}

普通积分抵扣返还在这里:

php 复制代码
$use_integral = $order['use_integral'];

if ($use_integral > 0) {
    $integral = bcadd((string)$integral, (string)$use_integral);

    $res2 = $userBillServices->income(
        'pay_product_integral_back',
        $order['uid'],
        (int)$use_integral,
        (int)$integral,
        $order['id']
    );
}

最后统一更新用户积分:

php 复制代码
$res3 = $userServices->update($order['uid'], ['integral' => $integral]);

if (!($res1 && $res2 && $res3 && $res4 && $res5)) {
    throw new ValidateException('回退积分增加失败');
}

二开时千万不要写:

php 复制代码
$user->integral += $order->pay_integral;
$user->save();

这种写法没有账单、没有防重、没有状态记录,后面用户积分对账一定会出问题。

8. 退款回库存会走 case 4

退款时还要回库存。统一售后里会根据订单类型回退不同活动库存:

php 复制代码
switch ($order['type']) {
    case 1://秒杀
        $res5 = $res5 && $seckillServices
            ->incSeckillStock($cart_num, $activity_id, $unique, $store_id);
        break;

    case 2://砍价
        $res5 = $res5 && $bargainServices
            ->incBargainStock($cart_num, $activity_id, $unique, $store_id);
        break;

    case 3://拼团
        $res5 = $res5 && $pinkServices
            ->incCombinationStock($cart_num, $activity_id, $unique, $store_id);
        break;

    case 4://积分
        $res5 = $res5 && $storeIntegralServices
            ->incIntegralStock($cart_num, $activity_id, $unique, $store_id);
        break;
}

积分商城回库存方法:

php 复制代码
public function incIntegralStock(
    int $num,
    int $integralId,
    string $unique,
    int $store_id = 0
) {
    $product_id = $this->dao->value(['id' => $integralId], 'product_id');

    if ($product_id) {
        if ($unique) {
            $skuValueServices = app()->make(StoreProductAttrValueServices::class);

            //增加积分商品的sku库存,减去销量
            $res = false !== $skuValueServices
                ->incProductAttrStock($integralId, $unique, $num, 4);

            //积分商品sku
            $suk = $skuValueServices->value([
                'unique' => $unique,
                'product_id' => $integralId,
                'type' => 4
            ], 'suk');

            //平台商品sku unique
            $productUnique = $skuValueServices->value([
                'suk' => $suk,
                'product_id' => $product_id,
                'type' => 0
            ], 'unique');

            $services = app()->make(StoreProductServices::class);

            //增加普通商品库存
            $res = $res && $services
                ->incProductStock($num, $product_id, (string)$productUnique);
        }

        //增加积分库存
        $res = $res && false !== $this->dao
            ->incStockDecSales(['id' => $integralId, 'type' => 4], $num);
    }

    return $res;
}

这说明积分订单退款要同时回:

text 复制代码
积分商品 SKU 库存
普通商品 SKU 库存
积分商品主表库存
积分商品销量

只回用户积分,不回库存;或者只回普通库存,不回积分活动库存,都会造成后续兑换异常。

9. 单独退积分也有表单和校验

后台订单还有单独退积分能力,表单会校验:

php 复制代码
if ($orderInfo->getData('back_integral') >= $orderInfo->getData('use_integral')) {
    throw new ValidateException('积分已退或者积分为零无法再退');
}

if (!$orderInfo->paid) {
    throw new ValidateException('未支付无法退积分');
}

$f[] = Form::number(
    'use_integral',
    '使用的积分',
    (float)$orderInfo->getData('use_integral')
)->min(0)->disabled(1);

$f[] = Form::number(
    'back_integral',
    '可退积分',
    (float)bcsub(
        $orderInfo->getData('use_integral'),
        $orderInfo->getData('back_integral')
    )
)->min(0)->precision(0)->required('请输入可退积分');

这块更多针对普通订单的积分抵扣回退。积分商城的兑换积分主要看 pay_integral,售后退款时要区分:

text 复制代码
pay_integral:积分商城兑换消耗
use_integral:普通订单积分抵扣
back_integral:已经退回的积分抵扣

二开后台"退积分"按钮时,必须分清这几个字段,不要混用。

10. 二开建议:把积分售后做成可审计链路

积分商城售后建议至少保留这些信息:

text 复制代码
谁操作
操作时间
操作原因
退了多少 pay_integral
退了多少 use_integral
是否回库存
回了哪个 SKU
账单类型是什么
订单状态从什么变成什么

已有实现里,积分回退会写用户账单:

text 复制代码
order_integral_refund       返还积分兑换支付积分
pay_product_integral_back   返还下单抵扣积分
integral_refund             追回订单赠送积分

订单状态会写状态记录:

php 复制代码
$statusService->save([
    'oid' => $order['id'],
    'change_type' => 'take_delivery',
    'change_message' => '已收货',
    'change_time' => time()
]);

如果要扩展"售后审核""积分人工补偿""部分退积分",建议也按这套思路写账单和状态记录,避免只改余额。

注意事项

  1. 后台积分订单管理查的是统一订单,条件是 type = 4
  2. 发货复用 StoreOrderServices::delivery(),不要单独改订单状态。
  3. 确认收货只能从 status = 2status = 3,要防重复收货。
  4. 退积分要写用户账单,不能只更新用户积分余额。
  5. 积分商城兑换积分看 pay_integral,普通积分抵扣看 use_integral
  6. 退款回库存必须走 incIntegralStock(),同时回积分 SKU 和普通 SKU。
  7. 新增售后动作建议写订单状态记录,方便后续对账。

标签建议

CRMEB Pro、积分商城、订单售后、退积分、库存回退、商城二开、源码解析