feat(split-order): 完善分账订单管理功能

- 添加订单关联关系,支持查询原始订单信息
- 实现分账订单列表的搜索过滤功能,支持买家、订单号、状态等多维度筛选
- 优化前端页面展示,添加分页、样式美化和交互提示
- 增加重试分账和查询状态的操作确认提示
- 格式化显示买家信息、金额和时间等数据字段
- 支持汇付和微信两种分账通道的状态显示
```
这个提交包含在:
可乐加冰
2026-10-02 15:39:03 +08:00
父节点 9a487d18c3
当前提交 339c8f508c
共有 4 个文件被更改,包括 392 次插入 和 25 次删除

查看文件

@@ -15,4 +15,9 @@ class SplitOrder extends Model
{
return $this->hasMany(SplitOrderReceiver::class, 'split_order_id', 'id');
}
public function order()
{
return $this->belongsTo(\app\admin\model\shopro\order\Order::class, 'order_id', 'id');
}
}

查看文件

@@ -6,11 +6,21 @@ use app\admin\controller\shopro\Base;
use addons\shopro\library\split\Config as SplitConfig;
use addons\shopro\library\split\SplitExecutor;
use addons\shopro\library\split\ProfitSharingService;
use app\admin\model\shopro\order\Order as ShopOrder;
use app\admin\model\shopro\user\User as ShopUser;
class Order extends Base
{
protected $model = null;
protected $statusMap = [
0 => '待分账',
1 => '分账中',
2 => '成功',
3 => '部分成功',
4 => '失败',
];
public function _initialize()
{
parent::_initialize();
@@ -21,14 +31,143 @@ class Order extends Base
{
$this->request->filter(['strip_tags']);
if ($this->request->isAjax()) {
list($where, $sort, $order, $offset, $limit) = $this->custombuildparams();
$total = $this->model->where($where)->count();
$list = $this->model->with('receivers')->where($where)->order($sort, $order)->limit($offset, $limit)->select();
$nobuildfields = ['buyer_keyword', 'order_sn', 'status', 'channel', 'createtime'];
list($where, $sort, $order, $offset, $limit) = $this->custombuildparams(null, $nobuildfields);
$query = $this->buildSearch();
$total = $query->where($where)->count();
$list = $this->buildSearch()
->with([
'receivers',
'order' => function ($q) {
$q->field('id,order_sn,user_id,pay_fee,paytime,status');
},
'order.user' => function ($q) {
$q->field('id,nickname,mobile');
},
])
->where($where)
->order($sort, $order)
->limit($offset, $limit)
->select();
$list = $this->formatSplitOrderList($list);
return $this->success('操作成功', null, ['total' => $total, 'rows' => $list]);
}
$this->assignconfig('splitHuifuOnly', (new SplitConfig())->isHuifuMode());
return $this->view->fetch();
}
protected function buildSearch()
{
$filter = (array) json_decode($this->request->get('filter', ''), true);
$query = $this->model;
if (!empty($filter['order_sn'])) {
$orderIds = ShopOrder::where('order_sn', 'like', '%' . trim($filter['order_sn']) . '%')->column('id');
$query = $query->where('order_id', 'in', $orderIds ?: [0]);
}
if (!empty($filter['buyer_keyword'])) {
$kw = trim($filter['buyer_keyword']);
$userIds = ShopUser::where('nickname|mobile', 'like', '%' . $kw . '%')->column('id');
$orderIds = $userIds
? ShopOrder::where('user_id', 'in', $userIds)->column('id')
: [];
$query = $query->where('order_id', 'in', $orderIds ?: [0]);
}
if (isset($filter['status']) && $filter['status'] !== '') {
$query = $query->where('status', intval($filter['status']));
}
if (!empty($filter['channel'])) {
$query = $query->where('channel', $filter['channel']);
}
if (!empty($filter['createtime'])) {
$parts = explode(' - ', $filter['createtime']);
if (count($parts) === 2) {
$start = strtotime($parts[0]);
$end = strtotime($parts[1] . ' 23:59:59');
if ($start && $end) {
$query = $query->where('createtime', 'between', [$start, $end]);
}
}
}
return $query;
}
protected function formatSplitOrderList($list)
{
$rows = [];
foreach ($list as $item) {
$data = is_array($item) ? $item : $item->toArray();
$order = $data['order'] ?? null;
$user = is_array($order) ? ($order['user'] ?? null) : null;
$status = intval($data['status'] ?? 0);
$channel = $data['channel'] ?? 'wechat';
$buyerNickname = $user['nickname'] ?? '';
$buyerMobile = $user['mobile'] ?? '';
$receivers = $data['receivers'] ?? [];
$lines = [];
foreach ($receivers as $recv) {
$r = is_array($recv) ? $recv : (method_exists($recv, 'toArray') ? $recv->toArray() : []);
$lines[] = sprintf(
'%s ¥%s',
$this->roleTypeLabel($r['role_type'] ?? ''),
number_format(floatval($r['amount'] ?? 0), 2, '.', '')
);
}
$data['buyer_nickname'] = $buyerNickname;
$data['buyer_mobile'] = $buyerMobile;
$data['buyer_display'] = $this->formatBuyerDisplay(
$buyerNickname,
$buyerMobile,
is_array($order) ? intval($order['user_id'] ?? 0) : 0
);
$data['status_text'] = $this->statusMap[$status] ?? '-';
$data['channel_text'] = $channel === 'huifu' ? '汇付' : '微信';
$data['createtime_text'] = !empty($data['createtime'])
? date('Y-m-d H:i:s', $data['createtime'])
: '-';
$data['order_paytime_text'] = (is_array($order) && !empty($order['paytime']))
? date('Y-m-d H:i:s', $order['paytime'])
: '-';
$data['receiver_summary'] = $lines ? implode(';', $lines) : '—';
unset($data['order']);
$rows[] = $data;
}
return $rows;
}
protected function formatBuyerDisplay($nickname, $mobile, $userId)
{
$nickname = trim((string) $nickname);
$mobile = trim((string) $mobile);
if ($nickname !== '') {
return $mobile !== '' ? ($nickname . '(' . $mobile . ')') : $nickname;
}
if ($mobile !== '') {
return $mobile;
}
return $userId ? ('用户#' . $userId) : '—';
}
protected function roleTypeLabel($type)
{
$map = [
'platform' => '平台',
'promoter' => '推广员',
'regional_agent' => '区域公司',
];
return $map[$type] ?? ($type ?: '接收方');
}
public function retry($ids = null)
{
$id = $ids ?: $this->request->post('id');

查看文件

@@ -1,19 +1,154 @@
<link rel="stylesheet" href="__CDN__/assets/addons/shopro/libs/element/element.css">
<div id="splitOrderIndex" v-cloak style="padding:20px;">
<el-table :data="listData" border v-loading="loading">
<el-table-column prop="out_order_no" label="分账单号" min-width="160"></el-table-column>
<el-table-column prop="order_sn" label="订单号" min-width="160"></el-table-column>
<el-table-column prop="total_amount" label="订单金额" width="100"></el-table-column>
<el-table-column prop="split_amount" label="分账金额" width="100"></el-table-column>
<el-table-column label="状态" width="100"><template slot-scope="s">{{ statusText(s.row.status) }}</template></el-table-column>
<el-table-column prop="retry_count" label="重试" width="70"></el-table-column>
<el-table-column label="操作" width="160">
<template slot-scope="s">
<el-button type="text" v-if="s.row.status==4" @click="retry(s.row.id)">重试</el-button>
<el-button type="text" @click="query(s.row.id)">查询</el-button>
</template>
</el-table-column>
</el-table>
<link rel="stylesheet" href="__CDN__/assets/addons/shopro/libs/common.css">
<style>
#splitOrderIndex {
padding: 0 20px 30px;
background: #f5f7fa;
min-height: 100%;
}
.page-card {
background: #fff;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.page-intro {
font-size: 13px;
color: #606266;
line-height: 1.7;
margin: 0;
}
.page-intro strong { color: #303133; font-weight: 600; }
.toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.source-main {
font-size: 13px;
color: #333;
font-weight: 500;
line-height: 1.5;
}
.source-sub {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-top: 2px;
}
.link-text {
color: #7438D5;
cursor: pointer;
}
.link-text:hover { opacity: 0.85; }
.money-text { color: #e1212b; font-weight: 600; }
.status-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
}
.status-tag.s0 { background: #f4f4f5; color: #909399; }
.status-tag.s1 { background: #ecf5ff; color: #409eff; }
.status-tag.s2 { background: #f0f9eb; color: #67c23a; }
.status-tag.s3 { background: #fdf6ec; color: #e6a23c; }
.status-tag.s4 { background: #fef0f0; color: #f56c6c; }
.op-hint {
font-size: 12px;
color: #909399;
margin-left: 4px;
}
</style>
<div id="splitOrderIndex" v-cloak>
<div class="page-card">
<p class="page-intro">
<strong>分账记录</strong>展示向支付通道(微信/汇付)发起的<strong>网关分账</strong>结果,与「分销佣金」「区域差价」钱包入账是不同链路。
仅<strong>已支付且触发分账</strong>的订单会出现记录;列表为空时请先确认分账已开启并完成一笔测试支付。
</p>
</div>
<div class="page-card">
<div class="toolbar-row">
<el-input v-model="filters.buyer_keyword" placeholder="买家昵称/手机号" size="small" style="width:160px;" clearable></el-input>
<el-input v-model="filters.order_sn" placeholder="订单号" size="small" style="width:170px;" clearable></el-input>
<el-select v-model="filters.status" placeholder="分账状态" size="small" style="width:120px;" clearable>
<el-option label="待分账" value="0"></el-option>
<el-option label="分账中" value="1"></el-option>
<el-option label="成功" value="2"></el-option>
<el-option label="部分成功" value="3"></el-option>
<el-option label="失败" value="4"></el-option>
</el-select>
<el-select v-model="filters.channel" placeholder="通道" size="small" style="width:100px;" clearable>
<el-option label="汇付" value="huifu"></el-option>
<el-option label="微信" value="wechat"></el-option>
</el-select>
<el-date-picker v-model="dateRange" type="daterange" size="small" range-separator="至"
start-placeholder="记录开始" end-placeholder="记录结束" value-format="yyyy-MM-dd"></el-date-picker>
<el-button type="primary" size="small" @click="search">搜索</el-button>
<el-button size="small" @click="resetFilters">重置</el-button>
</div>
</div>
<div class="page-card" style="padding-bottom: 8px;">
<el-table :data="listData" border stripe v-loading="loading"
empty-text="暂无分账记录。请确认已开启分账,并完成支付/确认收货后再查看。">
<el-table-column label="记录时间" prop="createtime_text" width="158"></el-table-column>
<el-table-column label="买家" min-width="140">
<template slot-scope="s">
<div class="source-main">{{ s.row.buyer_display || '—' }}</div>
<div class="source-sub" v-if="s.row.buyer_nickname && s.row.buyer_mobile">手机 {{ s.row.buyer_mobile }}</div>
</template>
</el-table-column>
<el-table-column label="订单 / 分账" min-width="220">
<template slot-scope="s">
<div class="source-main">
<span class="link-text" @click="openOrder(s.row.order_id)">{{ s.row.order_sn || '—' }}</span>
<span class="source-sub" style="display:inline;margin-left:6px;">{{ s.row.channel_text }}</span>
</div>
<div class="source-sub">分账单号 {{ s.row.out_order_no || '—' }}</div>
<div class="source-sub" v-if="s.row.order_paytime_text !== '-'">支付 {{ s.row.order_paytime_text }}</div>
</template>
</el-table-column>
<el-table-column label="金额" width="118" align="right">
<template slot-scope="s">
<div>订单 <span class="money-text">¥{{ formatMoney(s.row.total_amount) }}</span></div>
<div class="source-sub">分账 ¥{{ formatMoney(s.row.split_amount) }}</div>
</template>
</el-table-column>
<el-table-column label="接收方明细" min-width="160" show-overflow-tooltip>
<template slot-scope="s">
<span class="source-sub">{{ s.row.receiver_summary || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template slot-scope="s">
<span :class="'status-tag s' + s.row.status">{{ s.row.status_text }}</span>
</template>
</el-table-column>
<el-table-column label="重试" width="64" align="center" prop="retry_count"></el-table-column>
<el-table-column label="操作" width="168" fixed="right">
<template slot-scope="s">
<el-button type="text" v-if="s.row.status == 4" @click="retry(s.row)">重试分账</el-button>
<el-button type="text" @click="query(s.row.id)">同步通道状态</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 14px; text-align: right;">
<el-pagination
background
layout="total, prev, pager, next, sizes"
:current-page.sync="currentPage"
:page-size.sync="limit"
:page-sizes="[10, 20, 50]"
:total="total"
@current-change="getList"
@size-change="onSizeChange">
</el-pagination>
</div>
<p class="op-hint" style="margin-top:10px;">
「重试分账」仅对<strong>失败</strong>记录有效,会向通道重新发起分账;「同步通道状态」只拉取最新结果并更新本页,不会重复扣款。
</p>
</div>
</div>
<script src="__CDN__/assets/addons/shopro/libs/vue.js"></script>
<script src="__CDN__/assets/addons/shopro/libs/element/element.js"></script>

查看文件

@@ -3,20 +3,108 @@ define(['jquery', 'bootstrap', 'backend', 'form'], function ($, undefined, Backe
index: function () {
new Vue({
el: '#splitOrderIndex',
data: { listData: [], loading: false },
mounted() { this.getList(); },
data() {
return {
listData: [],
loading: false,
currentPage: 1,
limit: 20,
total: 0,
dateRange: [],
filters: {
buyer_keyword: '',
order_sn: '',
status: '',
channel: ''
},
splitHuifuOnly: !!(typeof Config !== 'undefined' && Config.splitHuifuOnly)
};
},
mounted() {
this.getList();
},
methods: {
buildFilter() {
var filter = Object.assign({}, this.filters);
if (this.dateRange && this.dateRange.length === 2) {
filter.createtime = this.dateRange[0] + ' - ' + this.dateRange[1];
}
return filter;
},
search() {
this.currentPage = 1;
this.getList();
},
resetFilters() {
this.filters = { buyer_keyword: '', order_sn: '', status: '', channel: '' };
this.dateRange = [];
this.currentPage = 1;
this.getList();
},
onSizeChange() {
this.currentPage = 1;
this.getList();
},
getList() {
this.loading = true;
Fast.api.ajax({ url: 'shopro/split/order/index', type: 'GET', data: { limit: 50, sort: 'id', order: 'desc' } }, (ret, res) => {
this.listData = (res.data && res.data.rows) || [];
Fast.api.ajax({
url: 'shopro/split/order/index',
type: 'GET',
data: {
offset: (this.currentPage - 1) * this.limit,
limit: this.limit,
sort: 'id',
order: 'desc',
filter: JSON.stringify(this.buildFilter()),
op: JSON.stringify({})
}
}, (ret, res) => {
const data = res.data || {};
this.listData = data.rows || [];
this.total = data.total || 0;
this.loading = false;
return false;
}, () => { this.loading = false; });
},
retry(id) { Fast.api.ajax({ url: 'shopro/split/order/retry/ids/' + id, type: 'POST' }, () => this.getList()); },
query(id) { Fast.api.ajax({ url: 'shopro/split/order/query/ids/' + id, type: 'POST' }, () => this.getList()); },
statusText(s) { return { 0: '待分账', 1: '分账中', 2: '成功', 3: '部分成功', 4: '失败' }[s] || '-'; }
formatMoney(v) {
var n = parseFloat(v);
return isNaN(n) ? '0.00' : n.toFixed(2);
},
openOrder(orderId) {
if (!orderId) return;
Fast.api.open('shopro/order/order/detail?id=' + orderId, '订单详情');
},
retry(row) {
var tip = this.splitHuifuOnly
? '将向汇付重新发起该笔分账。若订单资金仍被冻结,请先发货/确认收货或按文档处理 FREEZE。'
: '将向微信/汇付重新发起该笔分账,请确认该订单尚未分账成功。';
Layer.confirm(
'订单 ' + (row.order_sn || '') + '(' + (row.buyer_display || '买家') + ')<br>' + tip,
{ title: '确认重试分账', btn: ['重试', '取消'] },
() => {
Fast.api.ajax({ url: 'shopro/split/order/retry/ids/' + row.id, type: 'POST' }, () => {
this.getList();
});
}
);
},
query(id) {
Fast.api.ajax({ url: 'shopro/split/order/query/ids/' + id, type: 'POST' }, (ret, res) => {
var hint = '已同步通道状态并更新记录';
if (res.data) {
try {
var d = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
var parts = [d.resp_desc, d.resp_code, d.bank_message, d.status].filter(Boolean);
if (parts.length) {
hint += ':' + parts.join(' ');
}
} catch (e) { /* ignore */ }
}
Layer.msg(hint, { time: 4000 });
this.getList();
return false;
});
}
}
});
}