文件
xionggeshop/addons/shopro/library/split/SplitManualService.php
可乐加冰 f1e2be8dc4 ```
feat(split): 优化手工分账服务日志记录和历史展示功能

- 添加CommissionReward和RegionalReward模型引用,用于获取原有分账数据
- 实现snapshotPlannedAutoItems方法,首次改人时保存原系统计划快照
- 新增itemsEqual方法,比较分账项目是否发生变化,避免重复记录日志
- 重构历史记录展示界面,添加change_summary摘要显示变更内容
- 优化前端格式化显示,统一账户显示格式为"未进件"或实际账户
- 修复重试操作时的日志记录问题,改进备注显示逻辑
```
2026-10-23 00:31:47 +08:00

735 行
26 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace addons\shopro\library\split;
use addons\shopro\model\Order;
use addons\shopro\model\User;
use addons\shopro\model\commission\Reward as CommissionReward;
use addons\shopro\model\regional_agent\Reward as RegionalReward;
use addons\shopro\model\split\Receiver;
use addons\shopro\model\split\SplitManual;
use addons\shopro\model\split\SplitManualLog;
use addons\shopro\model\split\SplitOrder;
use think\Db;
class SplitManualService
{
protected $config;
public function __construct()
{
$this->config = new Config();
}
/**
* 是否存在手动覆盖
*/
public function hasOverride($orderId)
{
return (bool) SplitManual::where('order_id', intval($orderId))->find();
}
/**
* 批量:存在手动覆盖的 order_id
* @param int[] $orderIds
* @return int[]
*/
public function orderIdsWithOverride(array $orderIds)
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if (!$orderIds) {
return [];
}
return array_map('intval', SplitManual::where('order_id', 'in', $orderIds)->column('order_id'));
}
/**
* 批量:存在变更历史的 order_id
* @param int[] $orderIds
* @return int[]
*/
public function orderIdsWithLog(array $orderIds)
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if (!$orderIds) {
return [];
}
return array_map('intval', SplitManualLog::where('order_id', 'in', $orderIds)->group('order_id')->column('order_id'));
}
/**
* 供 SplitCalculator 使用的明细
* @return array
*/
public function getCalculateItems($orderId)
{
$manual = SplitManual::where('order_id', intval($orderId))->find();
if (!$manual) {
return [];
}
$items = $manual['items'] ?? [];
if (!is_array($items) || empty($items)) {
return [];
}
return $this->normalizeCalculateItems($items);
}
/**
* 保存手动覆盖
* @param int $orderId
* @param array $items [{receiver_id, role_type, amount, description?}]
* @param int $adminId
* @param string $adminName
* @param string $remark
* @return SplitManual
*/
public function save($orderId, array $items, $adminId = 0, $adminName = '', $remark = '')
{
$orderId = intval($orderId);
$this->assertEditableSplitOrder($orderId);
$normalized = $this->validateAndNormalizeItems($orderId, $items);
$existing = SplitManual::where('order_id', $orderId)->find();
$beforeItems = $existing ? ($existing['items'] ?? []) : [];
$action = $existing ? 'update' : 'create';
$originalItems = [];
if (!$existing) {
$originalItems = $this->snapshotPlannedAutoItems($orderId);
} else {
$originalItems = $existing['original_items'] ?? [];
}
$storageItems = $this->enrichItemsForStorage($normalized);
$itemsChanged = !$existing || !$this->itemsEqual($beforeItems, $storageItems);
$logBefore = $existing ? $beforeItems : $originalItems;
Db::startTrans();
try {
if ($existing) {
$existing->items = $storageItems;
$existing->admin_id = intval($adminId);
$existing->remark = $remark;
$existing->save();
$manual = $existing;
} else {
$manual = SplitManual::create([
'order_id' => $orderId,
'items' => $storageItems,
'original_items' => $originalItems,
'admin_id' => intval($adminId),
'remark' => $remark,
]);
}
if ($itemsChanged) {
$this->appendLog($orderId, $action, $logBefore, $storageItems, $adminId, $adminName, $remark);
}
Db::commit();
return $manual;
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
/**
* 清除手动覆盖
*/
public function clear($orderId, $adminId = 0, $adminName = '', $remark = '')
{
$orderId = intval($orderId);
$this->assertEditableSplitOrder($orderId);
$existing = SplitManual::where('order_id', $orderId)->find();
if (!$existing) {
return true;
}
$beforeItems = $existing['items'] ?? [];
Db::startTrans();
try {
$existing->delete();
$this->appendLog($orderId, 'clear', $beforeItems, [], $adminId, $adminName, $remark);
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
/**
* 保存并重试前标记 pending 日志
* @return int log id
*/
public function markPendingRetryLog($orderId, $adminId = 0, $adminName = '', $remark = '')
{
$orderId = intval($orderId);
$manual = SplitManual::where('order_id', $orderId)->find();
if (!$manual) {
throw new \RuntimeException('请先保存手动指定接收方');
}
$order = Order::get($orderId);
$orderSn = $order ? (string) $order['order_sn'] : '';
$log = SplitManualLog::create([
'order_id' => $orderId,
'order_sn' => $orderSn,
'action' => 'retry',
'before_items' => [],
'after_items' => $manual['items'] ?? [],
'admin_id' => intval($adminId),
'admin_name' => $adminName,
'remark' => $remark ?: '保存并重试',
'retry_result' => 'pending',
'createtime' => time(),
]);
return intval($log->id);
}
/**
* 重试完成后更新日志
*/
public function finishPendingRetryLog($orderId, $success, $message = '', $splitOrderId = null)
{
$orderId = intval($orderId);
$log = SplitManualLog::where('order_id', $orderId)
->where('action', 'retry')
->where('retry_result', 'pending')
->order('id', 'desc')
->find();
if (!$log) {
return;
}
$log->retry_result = $success ? 'success' : 'fail';
$log->retry_message = $message;
if ($splitOrderId) {
$log->retry_split_order_id = intval($splitOrderId);
} else {
$latest = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->order('id', 'desc')->find();
if ($latest) {
$log->retry_split_order_id = intval($latest['id']);
}
}
$log->save();
}
/**
* 变更历史
*/
public function getHistory($orderId, $offset = 0, $limit = 20)
{
$orderId = intval($orderId);
$query = SplitManualLog::where('order_id', $orderId);
$total = $query->count();
$rows = SplitManualLog::where('order_id', $orderId)
->order('id', 'desc')
->limit($offset, $limit)
->select();
$list = [];
foreach ($rows as $row) {
$data = is_array($row) ? $row : $row->toArray();
$data['action_text'] = $this->actionLabel($data['action'] ?? '');
$data['retry_result_text'] = $this->retryResultLabel($data['retry_result'] ?? '');
$data['createtime_text'] = !empty($data['createtime'])
? date('Y-m-d H:i:s', $data['createtime'])
: '-';
$before = is_array($data['before_items'] ?? null) ? $data['before_items'] : [];
$after = is_array($data['after_items'] ?? null) ? $data['after_items'] : [];
$data['before_summary'] = $this->formatItemsBrief($before);
$data['after_summary'] = $this->formatItemsBrief($after);
$data['change_summary'] = $this->buildChangeSummary($data['action'] ?? '', $before, $after);
$list[] = $data;
}
return ['total' => $total, 'rows' => $list];
}
/**
* 汇付接收方下拉选项
*/
public function listHuifuReceiverOptions($keyword = '')
{
$query = Receiver::where('status', 'normal')
->where(function ($q) {
$q->where('channel', 'huifu')->whereOr('channel', null)->whereOr('channel', '');
})
->where('receiver_account', '<>', '');
$keyword = trim((string) $keyword);
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->where('receiver_name', 'like', $like)
->whereOr('receiver_account', 'like', $like);
});
}
$rows = $query->order('id', 'desc')->limit(200)->select();
$options = [];
foreach ($rows as $row) {
$data = is_array($row) ? $row : $row->toArray();
$account = trim((string) ($data['receiver_account'] ?? ''));
if ($account === '') {
continue;
}
$options[] = [
'id' => intval($data['id']),
'role_type' => $data['role_type'] ?? '',
'role_id' => intval($data['role_id'] ?? 0),
'receiver_account' => $account,
'receiver_name' => trim((string) ($data['receiver_name'] ?? '')),
'receiver_type' => $data['receiver_type'] ?? 'HUIFU_ID',
'label' => $this->formatReceiverOptionLabel($data),
];
}
return $options;
}
/**
* 加载指定失败单的编辑上下文
*/
public function getManualContext($splitOrderId)
{
$splitOrder = SplitOrder::get(intval($splitOrderId));
if (!$splitOrder) {
throw new \RuntimeException('分账记录不存在');
}
$this->assertEditableSplitRow($splitOrder);
$orderId = intval($splitOrder['order_id']);
$order = Order::get($orderId);
$manual = SplitManual::where('order_id', $orderId)->find();
$calculator = new SplitCalculator();
$autoItems = $calculator->calculateAuto($orderId);
$autoItems = $this->enrichItemsForStorage($autoItems);
$currentItems = $manual ? ($manual['items'] ?? []) : $autoItems;
$originalItems = $manual ? ($manual['original_items'] ?? []) : $autoItems;
$confirmBase = null;
if (class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService') && $order) {
try {
$svc = new \addons\shopro_huifu\library\HuifuSplitService();
$confirmBase = $svc->previewConfirmPayFee($order['order_sn'], $order['pay_fee']);
} catch (\Throwable $e) {
$confirmBase = floatval($order['pay_fee'] ?? 0);
}
}
return [
'split_order_id' => intval($splitOrder['id']),
'order_id' => $orderId,
'order_sn' => $splitOrder['order_sn'] ?? ($order['order_sn'] ?? ''),
'pay_fee' => floatval($order['pay_fee'] ?? 0),
'confirm_base' => floatval($confirmBase ?? ($order['pay_fee'] ?? 0)),
'max_rate' => $this->config->getMaxRate(),
'max_split_amount'=> round(floatval($order['pay_fee'] ?? 0) * $this->config->getMaxRate() / 100, 2),
'has_manual' => (bool) $manual,
'current_items' => $currentItems,
'original_items' => $originalItems,
'auto_items' => $autoItems,
'receiver_options'=> $this->listHuifuReceiverOptions(),
];
}
protected function appendLog($orderId, $action, $beforeItems, $afterItems, $adminId, $adminName, $remark)
{
$order = Order::get($orderId);
SplitManualLog::create([
'order_id' => $orderId,
'order_sn' => $order ? (string) $order['order_sn'] : '',
'action' => $action,
'before_items' => is_array($beforeItems) ? $beforeItems : [],
'after_items' => is_array($afterItems) ? $afterItems : [],
'admin_id' => intval($adminId),
'admin_name' => $adminName,
'remark' => $remark,
'createtime' => time(),
]);
}
protected function assertEditableSplitOrder($orderId)
{
$row = SplitOrder::where('order_id', intval($orderId))
->where('channel', 'huifu')
->where('status', 4)
->order('id', 'desc')
->find();
if (!$row) {
throw new \RuntimeException('仅失败状态的汇付分账记录可指定接收方');
}
}
protected function assertEditableSplitRow($splitOrder)
{
$data = is_array($splitOrder) ? $splitOrder : $splitOrder->toArray();
if (($data['channel'] ?? '') !== 'huifu') {
throw new \RuntimeException('仅汇付通道分账记录可指定接收方');
}
if (intval($data['status'] ?? 0) !== 4) {
throw new \RuntimeException('仅失败状态的分账记录可指定接收方');
}
}
protected function validateAndNormalizeItems($orderId, array $items)
{
if (empty($items)) {
throw new \RuntimeException('请至少添加一行分账明细');
}
$order = Order::get($orderId);
if (!$order) {
throw new \RuntimeException('订单不存在');
}
$payFee = floatval($order['pay_fee']);
$maxSplit = round($payFee * $this->config->getMaxRate() / 100, 2);
$total = 0.0;
$normalized = [];
$paymentHuifuId = $this->getPaymentHuifuId();
foreach ($items as $item) {
$receiverId = intval($item['receiver_id'] ?? 0);
$amount = round(floatval($item['amount'] ?? 0), 2);
if ($receiverId <= 0) {
throw new \RuntimeException('请选择有效的分账接收方');
}
if ($amount <= 0) {
throw new \RuntimeException('分账金额须大于 0');
}
$receiver = Receiver::where('id', $receiverId)->where('status', 'normal')->find();
if (!$receiver) {
throw new \RuntimeException('接收方不存在或已禁用');
}
$account = trim((string) ($receiver['receiver_account'] ?? ''));
if ($account === '') {
throw new \RuntimeException('接收方未配置 huifu_id');
}
if ($paymentHuifuId !== '' && $account === $paymentHuifuId) {
throw new \RuntimeException('不得仅分给收款主商户(' . $paymentHuifuId . ')');
}
$total += $amount;
$normalized[] = [
'receiver_id' => $receiverId,
'role_type' => $item['role_type'] ?? ($receiver['role_type'] ?? 'promoter'),
'role_id' => intval($receiver['role_id'] ?? 0),
'receiver_account' => $account,
'receiver_type' => $receiver['receiver_type'] ?? 'HUIFU_ID',
'receiver_name' => trim((string) ($receiver['receiver_name'] ?? '')),
'amount' => $amount,
'description' => trim((string) ($item['description'] ?? '手动指定')),
];
}
if ($total > $maxSplit + 0.001) {
throw new \RuntimeException(sprintf(
'分账合计 ¥%s 超过上限 ¥%s(订单金额 × %s%%)',
number_format($total, 2, '.', ''),
number_format($maxSplit, 2, '.', ''),
$this->config->getMaxRate()
));
}
$confirmBase = $payFee;
if (class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService')) {
try {
$svc = new \addons\shopro_huifu\library\HuifuSplitService();
$confirmBase = floatval($svc->previewConfirmPayFee($order['order_sn'], $payFee));
} catch (\Throwable $e) {
// use pay_fee
}
}
if ($total > $confirmBase + 0.001) {
throw new \RuntimeException(sprintf(
'分账合计 ¥%s 超过汇付确认基数 ¥%s',
number_format($total, 2, '.', ''),
number_format($confirmBase, 2, '.', '')
));
}
return $normalized;
}
protected function normalizeCalculateItems(array $items)
{
$result = [];
foreach ($items as $item) {
$amount = round(floatval($item['amount'] ?? 0), 2);
if ($amount <= 0) {
continue;
}
$account = trim((string) ($item['receiver_account'] ?? ''));
if ($account === '') {
$receiverId = intval($item['receiver_id'] ?? 0);
if ($receiverId > 0) {
$receiver = Receiver::get($receiverId);
if ($receiver) {
$account = trim((string) ($receiver['receiver_account'] ?? ''));
}
}
}
if ($account === '') {
continue;
}
$result[] = [
'role_type' => $item['role_type'] ?? 'promoter',
'role_id' => intval($item['role_id'] ?? 0),
'receiver_id' => intval($item['receiver_id'] ?? 0),
'receiver_account' => $account,
'receiver_type' => $item['receiver_type'] ?? 'HUIFU_ID',
'receiver_name' => trim((string) ($item['receiver_name'] ?? '')),
'amount' => $amount,
'description' => trim((string) ($item['description'] ?? '手动指定')),
];
}
return $result;
}
protected function enrichItemsForStorage(array $items)
{
$result = [];
foreach ($items as $item) {
$receiverId = intval($item['receiver_id'] ?? 0);
$account = trim((string) ($item['receiver_account'] ?? ''));
$name = trim((string) ($item['receiver_name'] ?? ''));
$roleType = $item['role_type'] ?? 'promoter';
$roleId = intval($item['role_id'] ?? 0);
if ($receiverId > 0) {
$receiver = Receiver::get($receiverId);
if ($receiver) {
if ($account === '') {
$account = trim((string) ($receiver['receiver_account'] ?? ''));
}
if ($name === '') {
$name = trim((string) ($receiver['receiver_name'] ?? ''));
}
if (!$roleType) {
$roleType = $receiver['role_type'] ?? 'promoter';
}
if (!$roleId) {
$roleId = intval($receiver['role_id'] ?? 0);
}
}
}
$result[] = [
'receiver_id' => $receiverId,
'role_type' => $roleType,
'role_id' => $roleId,
'receiver_account' => $account,
'receiver_type' => $item['receiver_type'] ?? 'HUIFU_ID',
'receiver_name' => $name,
'amount' => round(floatval($item['amount'] ?? 0), 2),
'description' => trim((string) ($item['description'] ?? '')),
];
}
return $result;
}
protected function getPaymentHuifuId()
{
if (!class_exists('\\addons\\shopro_huifu\\library\\HuifuConfig')) {
return '';
}
try {
return trim((string) \addons\shopro_huifu\library\HuifuConfig::getInstance()->get('huifu_id'));
} catch (\Throwable $e) {
return '';
}
}
protected function formatReceiverOptionLabel(array $data)
{
$name = trim((string) ($data['receiver_name'] ?? ''));
$account = trim((string) ($data['receiver_account'] ?? ''));
$roleType = $data['role_type'] ?? '';
$roleMap = [
'platform' => '平台',
'promoter' => '推广员',
'regional_agent' => '区域公司',
];
$roleLabel = $roleMap[$roleType] ?? $roleType;
$parts = array_filter([$roleLabel, $name ?: ('#' . ($data['role_id'] ?? 0)), $account]);
return implode(' · ', $parts);
}
protected function actionLabel($action)
{
$map = [
'create' => '首次指定',
'update' => '修改',
'clear' => '清除',
'retry' => '保存并重试',
];
return $map[$action] ?? $action;
}
protected function retryResultLabel($result)
{
if ($result === null || $result === '') {
return '';
}
$map = [
'pending' => '重试中',
'success' => '成功',
'fail' => '失败',
];
return $map[$result] ?? $result;
}
/**
* 首次改人时的「原系统计划」快照(含未进件推广员)
*/
protected function snapshotPlannedAutoItems($orderId)
{
$calculator = new SplitCalculator();
$auto = $calculator->calculateAuto($orderId);
if (!empty($auto)) {
return $this->enrichItemsForStorage($auto);
}
$items = [];
$commissionRewards = CommissionReward::where('order_id', $orderId)->where('status', '>=', 0)->select();
foreach ($commissionRewards as $reward) {
$userId = intval($reward['agent_id']);
$amount = round(floatval($reward['commission']), 2);
if ($userId <= 0 || $amount <= 0) {
continue;
}
$items[] = $this->buildPlannedItem('promoter', $userId, $amount, '原推广员佣金');
}
foreach ($this->getRegionalRewardsByOrderId($orderId) as $reward) {
$userId = intval($reward['agent_user_id']);
$amount = round(floatval($reward['commission']), 2);
if ($userId <= 0 || $amount <= 0) {
continue;
}
$items[] = $this->buildPlannedItem('regional_agent', $userId, $amount, '原区域公司佣金');
}
return $items;
}
protected function getRegionalRewardsByOrderId($orderId)
{
try {
return RegionalReward::where('order_id', $orderId)->where('status', '>=', 0)->select();
} catch (\Exception $e) {
return RegionalReward::alias('rr')
->join('shopro_regional_agent_order ro', 'ro.id = rr.regional_order_id')
->where('ro.order_id', $orderId)
->where('rr.status', '>=', 0)
->field('rr.*')
->select();
}
}
protected function buildPlannedItem($roleType, $userId, $amount, $desc)
{
$receiver = Receiver::where('role_type', $roleType)
->where('role_id', $userId)
->where('status', 'normal')
->find();
$user = User::get($userId);
$name = $receiver ? trim((string) ($receiver['receiver_name'] ?? '')) : '';
if ($name === '' && $user) {
$name = trim((string) ($user['nickname'] ?? '')) ?: ('用户#' . $userId);
}
return [
'receiver_id' => $receiver ? intval($receiver['id']) : 0,
'role_type' => $roleType,
'role_id' => $userId,
'receiver_account' => $receiver ? trim((string) ($receiver['receiver_account'] ?? '')) : '',
'receiver_name' => $name,
'amount' => $amount,
'description' => $desc,
];
}
protected function buildChangeSummary($action, array $before, array $after)
{
if ($action === 'retry') {
$afterText = $this->formatItemsBrief($after);
return $afterText ? ('按以下接收方重新发起分账:' . $afterText) : '重新发起分账重试';
}
if ($action === 'clear') {
$beforeText = $this->formatItemsBrief($before);
return $beforeText
? ('清除手动指定,恢复自动计算(原手动指定:' . $beforeText . ')')
: '清除手动指定,恢复自动计算';
}
$beforeText = $this->formatItemsBrief($before);
$afterText = $this->formatItemsBrief($after);
if ($beforeText === '' && $afterText !== '') {
return '指定分账接收方为:' . $afterText;
}
if ($beforeText === $afterText) {
return '分账接收方未变化:' . $afterText;
}
return '分账接收方:' . ($beforeText ?: '(无有效自动接收方)') . ' → ' . $afterText;
}
protected function formatItemsBrief(array $items)
{
if (empty($items)) {
return '';
}
$parts = [];
foreach ($items as $item) {
$parts[] = $this->formatOneItemBrief($item);
}
return implode(';', $parts);
}
protected function formatOneItemBrief(array $item)
{
$roleMap = [
'platform' => '平台',
'promoter' => '推广员',
'regional_agent' => '区域公司',
];
$role = $roleMap[$item['role_type'] ?? ''] ?? ($item['role_type'] ?? '接收方');
$name = trim((string) ($item['receiver_name'] ?? ''));
if ($name === '' && !empty($item['role_id'])) {
$name = '用户#' . intval($item['role_id']);
}
$account = trim((string) ($item['receiver_account'] ?? ''));
$accountPart = $account !== '' ? $account : '未进件';
$amt = number_format(floatval($item['amount'] ?? 0), 2, '.', '');
return $role . ' ' . $name . '(' . $accountPart . ')¥' . $amt;
}
protected function itemsEqual(array $before, array $after)
{
$norm = function (array $items) {
$rows = [];
foreach ($items as $item) {
$rows[] = [
intval($item['receiver_id'] ?? 0),
number_format(floatval($item['amount'] ?? 0), 2, '.', ''),
trim((string) ($item['receiver_account'] ?? '')),
];
}
usort($rows, function ($a, $b) {
return ($a[0] <=> $b[0]) ?: strcmp($a[1], $b[1]);
});
return $rows;
};
return $norm($before) === $norm($after);
}
}