feat(split-order): 添加分账订单失败原因详情展示功能 - 新增 fail_reason_full 字段用于存储完整的失败原因信息 - 实现 formatFailReasonFull 方法解析微信分账失败的详细信息 - 优化前端表格展示,增加查看完整失败原因的弹窗功能 - 调整表格列宽度和样式,提升买家信息展示效果 - 修复昵称和角色ID显示逻辑,避免重复显示问题 ```
485 行
18 KiB
PHP
485 行
18 KiB
PHP
<?php
|
||
|
||
namespace app\admin\controller\shopro\split;
|
||
|
||
use app\admin\controller\shopro\Base;
|
||
use addons\shopro\library\split\Config as SplitConfig;
|
||
use addons\shopro\library\split\SplitCalculator;
|
||
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();
|
||
$this->model = new \addons\shopro\model\split\SplitOrder;
|
||
}
|
||
|
||
public function index()
|
||
{
|
||
$this->request->filter(['strip_tags']);
|
||
if ($this->request->isAjax()) {
|
||
$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' => function ($q) {
|
||
$q->with('receiverConfig');
|
||
},
|
||
'order' => function ($q) {
|
||
$q->field('id,order_sn,user_id,pay_fee,paytime,status')
|
||
->with(['user' => function ($uq) {
|
||
$uq->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'] ?? '';
|
||
|
||
$orderId = intval($data['order_id'] ?? 0);
|
||
$receivers = $data['receivers'] ?? [];
|
||
$receiverDetails = $this->buildReceiverDetails($orderId, $receivers);
|
||
$receiverPreview = empty($receivers) && !empty($receiverDetails);
|
||
|
||
$summaryLines = [];
|
||
foreach ($receiverDetails as $detail) {
|
||
$summaryLines[] = sprintf(
|
||
'%s %s ¥%s',
|
||
$detail['role_label'],
|
||
$detail['display_name'],
|
||
number_format(floatval($detail['amount']), 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_details'] = $receiverDetails;
|
||
$data['receiver_preview'] = $receiverPreview;
|
||
$data['receiver_summary'] = $summaryLines ? implode(';', $summaryLines) : '—';
|
||
$data['fail_reason'] = $this->formatFailReason($data['wx_result'] ?? '', $status);
|
||
$data['fail_reason_full'] = $this->formatFailReasonFull($data['wx_result'] ?? '', $status);
|
||
|
||
$diag = (new SplitCalculator())->diagnose($orderId);
|
||
$data['split_source'] = $diag['final_source'];
|
||
$data['split_source_text'] = $this->formatSplitSourceText($diag);
|
||
$data['split_diagnose'] = $diag['issues'];
|
||
$data['commission_total'] = $diag['commission_total'];
|
||
$data['rule_total'] = $diag['rule_total'];
|
||
|
||
unset($data['order'], $data['receivers']);
|
||
$rows[] = $data;
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
protected function buildReceiverDetails($orderId, $receivers)
|
||
{
|
||
$details = [];
|
||
if (!empty($receivers)) {
|
||
$userIds = [];
|
||
foreach ($receivers as $recv) {
|
||
$r = is_array($recv) ? $recv : (method_exists($recv, 'toArray') ? $recv->toArray() : []);
|
||
$cfg = $r['receiver_config'] ?? null;
|
||
if (is_object($cfg) && method_exists($cfg, 'toArray')) {
|
||
$cfg = $cfg->toArray();
|
||
}
|
||
$roleId = intval(is_array($cfg) ? ($cfg['role_id'] ?? 0) : 0);
|
||
if ($roleId > 0) {
|
||
$userIds[] = $roleId;
|
||
}
|
||
}
|
||
$users = $userIds ? $this->loadUsersByIds($userIds) : [];
|
||
|
||
foreach ($receivers as $recv) {
|
||
$r = is_array($recv) ? $recv : (method_exists($recv, 'toArray') ? $recv->toArray() : []);
|
||
$cfg = $r['receiver_config'] ?? null;
|
||
if (is_object($cfg) && method_exists($cfg, 'toArray')) {
|
||
$cfg = $cfg->toArray();
|
||
}
|
||
$cfg = is_array($cfg) ? $cfg : [];
|
||
$roleId = intval($cfg['role_id'] ?? 0);
|
||
$account = trim((string) ($cfg['receiver_account'] ?? ''));
|
||
$recvName = trim((string) ($cfg['receiver_name'] ?? ''));
|
||
$user = $roleId && isset($users[$roleId]) ? $users[$roleId] : null;
|
||
$details[] = [
|
||
'role_label' => $this->roleTypeLabel($r['role_type'] ?? ($cfg['role_type'] ?? '')),
|
||
'display_name' => $this->formatReceiverDisplayName($roleId, $recvName, $user),
|
||
'account' => $account,
|
||
'amount' => floatval($r['amount'] ?? 0),
|
||
'status' => intval($r['status'] ?? 0),
|
||
];
|
||
}
|
||
return $details;
|
||
}
|
||
|
||
if ($orderId <= 0) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
$items = (new SplitCalculator())->calculate($orderId);
|
||
} catch (\Throwable $e) {
|
||
return [];
|
||
}
|
||
if (empty($items)) {
|
||
return [];
|
||
}
|
||
|
||
$roleIds = [];
|
||
foreach ($items as $item) {
|
||
$rid = intval($item['role_id'] ?? 0);
|
||
if ($rid > 0) {
|
||
$roleIds[] = $rid;
|
||
}
|
||
}
|
||
$users = $roleIds ? $this->loadUsersByIds($roleIds) : [];
|
||
|
||
foreach ($items as $item) {
|
||
$roleId = intval($item['role_id'] ?? 0);
|
||
$user = $roleId && isset($users[$roleId]) ? $users[$roleId] : null;
|
||
$details[] = [
|
||
'role_label' => $this->roleTypeLabel($item['role_type'] ?? ''),
|
||
'display_name' => $this->formatReceiverDisplayName(
|
||
$roleId,
|
||
trim((string) ($item['receiver_name'] ?? '')),
|
||
$user
|
||
),
|
||
'account' => trim((string) ($item['receiver_account'] ?? '')),
|
||
'amount' => floatval($item['amount'] ?? 0),
|
||
'status' => -1,
|
||
];
|
||
}
|
||
|
||
return $details;
|
||
}
|
||
|
||
protected function loadUsersByIds(array $userIds)
|
||
{
|
||
$userIds = array_values(array_unique(array_filter(array_map('intval', $userIds))));
|
||
if (!$userIds) {
|
||
return [];
|
||
}
|
||
$rows = ShopUser::where('id', 'in', $userIds)->field('id,nickname,mobile')->select();
|
||
$map = [];
|
||
foreach ($rows as $row) {
|
||
$item = is_array($row) ? $row : $row->toArray();
|
||
$map[intval($item['id'])] = $item;
|
||
}
|
||
return $map;
|
||
}
|
||
|
||
protected function formatReceiverDisplayName($roleId, $receiverName, $user)
|
||
{
|
||
$nickname = is_array($user) ? trim((string) ($user['nickname'] ?? '')) : '';
|
||
$mobile = is_array($user) ? trim((string) ($user['mobile'] ?? '')) : '';
|
||
if ($nickname !== '') {
|
||
$label = $nickname;
|
||
if ($roleId > 0 && (string) $nickname !== (string) $roleId) {
|
||
$label .= ' #' . $roleId;
|
||
}
|
||
if ($mobile !== '') {
|
||
$label .= ' ' . $this->maskMobile($mobile);
|
||
}
|
||
return $label;
|
||
}
|
||
if ($receiverName !== '') {
|
||
return $roleId > 0 ? ($receiverName . '#' . $roleId) : $receiverName;
|
||
}
|
||
return $roleId > 0 ? ('用户#' . $roleId) : '通用接收方';
|
||
}
|
||
|
||
protected function maskMobile($mobile)
|
||
{
|
||
$mobile = trim((string) $mobile);
|
||
if (strlen($mobile) >= 11) {
|
||
return substr($mobile, 0, 3) . '****' . substr($mobile, -4);
|
||
}
|
||
return $mobile;
|
||
}
|
||
|
||
protected function formatSplitSourceText(array $diag)
|
||
{
|
||
$map = [
|
||
'commission' => '按佣金分账',
|
||
'rule' => '按规则分账(兜底)',
|
||
'none' => '未算出明细',
|
||
];
|
||
$text = $map[$diag['final_source'] ?? ''] ?? '-';
|
||
if (($diag['final_source'] ?? '') === 'commission' && ($diag['commission_total'] ?? 0) > 0) {
|
||
$text .= ' ¥' . number_format(floatval($diag['commission_total']), 2, '.', '');
|
||
} elseif (($diag['final_source'] ?? '') === 'rule' && ($diag['rule_total'] ?? 0) > 0) {
|
||
$text .= ' ¥' . number_format(floatval($diag['rule_total']), 2, '.', '');
|
||
}
|
||
return $text;
|
||
}
|
||
|
||
protected function formatFailReason($wxResult, $status)
|
||
{
|
||
if ($status !== 4 || $wxResult === '' || $wxResult === null) {
|
||
return '';
|
||
}
|
||
$raw = is_string($wxResult) ? $wxResult : json_encode($wxResult, JSON_UNESCAPED_UNICODE);
|
||
if ($raw === '') {
|
||
return '';
|
||
}
|
||
if (strpos($raw, 'fund_freeze_stat=FREEZE') !== false) {
|
||
return '微信资金冻结,需发货/确认收货后再分账';
|
||
}
|
||
$decoded = json_decode($raw, true);
|
||
if (is_array($decoded)) {
|
||
$parts = array_filter([
|
||
$decoded['resp_desc'] ?? '',
|
||
$decoded['resp_code'] ?? '',
|
||
$decoded['bank_message'] ?? '',
|
||
]);
|
||
if ($parts) {
|
||
return implode(' ', $parts);
|
||
}
|
||
}
|
||
$full = $this->formatFailReasonFull($wxResult, $status);
|
||
if ($full === '') {
|
||
return '';
|
||
}
|
||
return mb_strlen($full) > 48 ? (mb_substr($full, 0, 48) . '…') : $full;
|
||
}
|
||
|
||
protected function formatFailReasonFull($wxResult, $status)
|
||
{
|
||
if ($status !== 4 || $wxResult === '' || $wxResult === null) {
|
||
return '';
|
||
}
|
||
$raw = is_string($wxResult) ? $wxResult : json_encode($wxResult, JSON_UNESCAPED_UNICODE);
|
||
if ($raw === '') {
|
||
return '';
|
||
}
|
||
if (strpos($raw, 'fund_freeze_stat=FREEZE') !== false) {
|
||
return '微信资金冻结,需发货/确认收货后再分账';
|
||
}
|
||
$decoded = json_decode($raw, true);
|
||
if (is_array($decoded)) {
|
||
$parts = array_filter([
|
||
$decoded['resp_desc'] ?? '',
|
||
isset($decoded['resp_code']) ? ('resp_code=' . $decoded['resp_code']) : '',
|
||
$decoded['bank_message'] ?? '',
|
||
$decoded['trans_stat'] ?? '',
|
||
]);
|
||
if ($parts) {
|
||
return implode(',', $parts);
|
||
}
|
||
}
|
||
return $raw;
|
||
}
|
||
|
||
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');
|
||
$row = $this->model->get($id);
|
||
if (!$row) {
|
||
$this->error('分账记录不存在,可能已被上次重试替换。请刷新列表后,对最新一条失败记录重试');
|
||
}
|
||
$orderId = (int) $row['order_id'];
|
||
$channel = $row['channel'] ?? 'wechat';
|
||
try {
|
||
$result = (new SplitExecutor())->retry($id);
|
||
} catch (\Throwable $e) {
|
||
$this->error('重试失败:' . $e->getMessage());
|
||
}
|
||
if ($result) {
|
||
$this->success('重试成功');
|
||
}
|
||
$hint = $this->formatSplitWxResultHint($orderId, $channel);
|
||
if ($hint === '') {
|
||
$hint = ':请确认已部署最新 HuifuSplitService.php;或执行 php addons/shopro/tests/split_commission_verify.php try-split ' . $row['order_sn'] . ' --execute 查看详情';
|
||
}
|
||
$this->error('重试失败' . $hint);
|
||
}
|
||
|
||
protected function formatSplitWxResultHint($orderId, $channel)
|
||
{
|
||
$latest = $this->model->where('order_id', $orderId)
|
||
->where('channel', $channel)
|
||
->order('id', 'desc')
|
||
->find();
|
||
if (!$latest || empty($latest['wx_result'])) {
|
||
return '';
|
||
}
|
||
$raw = is_string($latest['wx_result']) ? $latest['wx_result'] : json_encode($latest['wx_result'], JSON_UNESCAPED_UNICODE);
|
||
if ($raw !== '' && strpos($raw, 'fund_freeze_stat=FREEZE') !== false) {
|
||
return ':' . mb_substr($raw, 0, 500);
|
||
}
|
||
$decoded = json_decode($raw, true);
|
||
if (is_array($decoded)) {
|
||
$parts = array_filter([
|
||
$decoded['resp_desc'] ?? '',
|
||
$decoded['resp_code'] ?? '',
|
||
$decoded['bank_message'] ?? '',
|
||
]);
|
||
return $parts ? (':' . implode(' ', $parts)) : (':' . mb_substr($raw, 0, 300));
|
||
}
|
||
return ':' . mb_substr($raw, 0, 300);
|
||
}
|
||
|
||
public function query($ids = null)
|
||
{
|
||
$row = $this->model->get($ids);
|
||
if (!$row) {
|
||
$this->error('记录不存在');
|
||
}
|
||
try {
|
||
if (($row['channel'] ?? 'wechat') === 'huifu' && class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService')) {
|
||
$result = $this->queryHuifuChannelStatus($row);
|
||
} elseif ((new SplitConfig())->isHuifuMode()) {
|
||
$this->error('当前为汇付分账,请对 channel=huifu 的分账记录查询,或到汇付控台查看分账结果');
|
||
} else {
|
||
$service = new ProfitSharingService();
|
||
$result = $service->queryOrder([
|
||
'out_order_no' => $row['out_order_no'],
|
||
'transaction_id' => $row['transaction_id'],
|
||
]);
|
||
}
|
||
} catch (\think\exception\HttpResponseException $e) {
|
||
throw $e;
|
||
} catch (\Throwable $e) {
|
||
$this->error('查询失败:' . ($e->getMessage() ?: '未知错误'));
|
||
}
|
||
|
||
$row->wx_result = json_encode($result, JSON_UNESCAPED_UNICODE);
|
||
$row->save();
|
||
$this->success('查询成功', null, $result);
|
||
}
|
||
|
||
/**
|
||
* 汇付:未 confirm 成功时用支付查单;已有 confirm_hf_seq_id 时再查分账结果
|
||
*/
|
||
protected function queryHuifuChannelStatus($row)
|
||
{
|
||
$payload = [];
|
||
if (class_exists('\\addons\\shopro_huifu\\library\\HuifuQueryService')) {
|
||
$payload['pay_query'] = (new \addons\shopro_huifu\library\HuifuQueryService())->queryByOrderSn($row['order_sn']);
|
||
}
|
||
$confirmSeq = trim((string) ($row['confirm_hf_seq_id'] ?? ''));
|
||
if ($confirmSeq !== '') {
|
||
$service = new \addons\shopro_huifu\library\HuifuSplitService();
|
||
$payload['split_query'] = $service->querySplit($confirmSeq);
|
||
}
|
||
if (empty($payload)) {
|
||
throw new \RuntimeException('汇付查询组件不可用');
|
||
}
|
||
return $payload;
|
||
}
|
||
}
|