feat(split): 添加分账手动指定接收方功能 - 实现SplitManualService服务类,支持手动覆盖分账明细 - 新增SplitManual和SplitManualLog数据模型 - 修改SplitCalculator优先使用手动覆盖数据进行分账计算 - 添加分账记录手动指定接收方的数据库表结构 - 集成手动分账到汇付分账流程中,支持失败重试机制 - 添加商品详情内容更新SQL脚本用于汇付审核 ```
494 行
18 KiB
PHP
494 行
18 KiB
PHP
<?php
|
||
|
||
namespace addons\shopro\library\split;
|
||
|
||
use addons\shopro\model\Order;
|
||
use addons\shopro\model\split\Receiver;
|
||
use addons\shopro\model\split\Rule;
|
||
use addons\shopro\model\commission\Reward as CommissionReward;
|
||
use addons\shopro\model\regional_agent\Reward as RegionalReward;
|
||
use addons\shopro\model\User;
|
||
use addons\shopro\model\UserOauth;
|
||
use think\Db;
|
||
|
||
class SplitCalculator
|
||
{
|
||
protected $config;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->config = new Config();
|
||
}
|
||
|
||
/**
|
||
* 计算订单分账明细(优先手动覆盖)
|
||
* @return array [['role_type','role_id','receiver_id','receiver_account','receiver_type','receiver_name','amount','description']]
|
||
*/
|
||
public function calculate($orderId)
|
||
{
|
||
$manualItems = (new SplitManualService())->getCalculateItems($orderId);
|
||
if (!empty($manualItems)) {
|
||
return $manualItems;
|
||
}
|
||
return $this->calculateAuto($orderId);
|
||
}
|
||
|
||
/**
|
||
* 按佣金/规则自动计算分账明细
|
||
*/
|
||
public function calculateAuto($orderId)
|
||
{
|
||
$order = Order::get($orderId);
|
||
if (!$order || !in_array($order['pay_type'], ['wechat', 'huifu_wechat'])) {
|
||
return [];
|
||
}
|
||
|
||
$payFee = floatval($order['pay_fee']);
|
||
if ($payFee <= 0) {
|
||
return [];
|
||
}
|
||
|
||
$source = $this->config->getAmountSource();
|
||
$items = [];
|
||
|
||
if (in_array($source, ['commission', 'mixed'])) {
|
||
$items = $this->calculateFromCommission($orderId);
|
||
}
|
||
|
||
if (empty($items) && in_array($source, ['rule', 'mixed'])) {
|
||
$items = $this->calculateFromRules($payFee);
|
||
}
|
||
|
||
$allocated = array_sum(array_column($items, 'amount'));
|
||
$maxSplit = $payFee * $this->config->getMaxRate() / 100;
|
||
if ($allocated > $maxSplit && $allocated > 0) {
|
||
$ratio = $maxSplit / $allocated;
|
||
foreach ($items as &$item) {
|
||
$item['amount'] = round($item['amount'] * $ratio, 2);
|
||
}
|
||
unset($item);
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* 诊断为何未走佣金分账(供后台展示)
|
||
* @return array{amount_source:string,final_source:string,issues:array,commission_total:float,rule_total:float}
|
||
*/
|
||
public function diagnose($orderId)
|
||
{
|
||
$order = Order::get($orderId);
|
||
$amountSource = $this->config->getAmountSource();
|
||
$issues = [];
|
||
$commissionTotal = 0.0;
|
||
|
||
if (!$order) {
|
||
return [
|
||
'amount_source' => $amountSource,
|
||
'final_source' => 'none',
|
||
'issues' => ['订单不存在'],
|
||
'commission_total'=> 0,
|
||
'rule_total' => 0,
|
||
];
|
||
}
|
||
|
||
$payFee = floatval($order['pay_fee']);
|
||
$commissionRewards = CommissionReward::where('order_id', $orderId)->where('status', '>=', 0)->select();
|
||
if ($this->isEmptyResult($commissionRewards)) {
|
||
$issues[] = '无分销佣金记录';
|
||
}
|
||
|
||
foreach ($this->toResultList($commissionRewards) as $reward) {
|
||
$userId = intval($reward['agent_id']);
|
||
$amount = floatval($reward['commission']);
|
||
if ($userId <= 0) {
|
||
$issues[] = '佣金记录 agent_id 无效';
|
||
continue;
|
||
}
|
||
if ($amount <= 0) {
|
||
continue;
|
||
}
|
||
$receiver = $this->getReceiver('promoter', $userId);
|
||
if (!$receiver) {
|
||
$issues[] = sprintf(
|
||
'推广员 %s 佣金¥%s:%s',
|
||
$this->formatUserBrief($userId),
|
||
number_format($amount, 2, '.', ''),
|
||
$this->describeHuifuReceiverGap('promoter', $userId)
|
||
);
|
||
continue;
|
||
}
|
||
$commissionTotal += $amount;
|
||
}
|
||
|
||
$regionalRewards = $this->getRegionalRewardsByOrderId($orderId);
|
||
foreach ($this->toResultList($regionalRewards) as $reward) {
|
||
$uid = intval($reward['agent_user_id']);
|
||
$amount = floatval($reward['commission']);
|
||
if ($uid <= 0 || $amount <= 0) {
|
||
continue;
|
||
}
|
||
if (!$this->getReceiver('regional_agent', $uid)) {
|
||
$issues[] = sprintf(
|
||
'区域公司 %s 佣金¥%s:%s',
|
||
$this->formatUserBrief($uid),
|
||
number_format($amount, 2, '.', ''),
|
||
$this->describeHuifuReceiverGap('regional_agent', $uid)
|
||
);
|
||
continue;
|
||
}
|
||
$commissionTotal += $amount;
|
||
}
|
||
|
||
$commissionItems = $this->calculateFromCommission($orderId);
|
||
$ruleItems = $this->calculateFromRules($payFee);
|
||
$ruleTotal = array_sum(array_column($ruleItems, 'amount'));
|
||
|
||
$finalSource = 'none';
|
||
if (!empty($commissionItems) && in_array($amountSource, ['commission', 'mixed'])) {
|
||
$finalSource = 'commission';
|
||
} elseif (!empty($ruleItems) && ($amountSource === 'rule' || ($amountSource === 'mixed' && empty($commissionItems)))) {
|
||
$finalSource = 'rule';
|
||
if ($amountSource === 'mixed' && !empty($issues)) {
|
||
$issues[] = '已回退分账规则(如 5%),与佣金金额不一致';
|
||
}
|
||
} elseif (empty($ruleItems) && empty($commissionItems) && in_array($amountSource, ['rule', 'mixed'])) {
|
||
$issues = array_merge($issues, $this->diagnoseRuleReceivers($payFee));
|
||
}
|
||
|
||
return [
|
||
'amount_source' => $amountSource,
|
||
'final_source' => $finalSource,
|
||
'issues' => $issues,
|
||
'commission_total' => round($commissionTotal, 2),
|
||
'rule_total' => round($ruleTotal, 2),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 分账明细为空时的可读失败说明(写入 wx_result / 后台原因)
|
||
*/
|
||
public function formatEmptySplitFailureMessage($orderId)
|
||
{
|
||
$diag = $this->diagnose($orderId);
|
||
$issues = array_filter($diag['issues'] ?? []);
|
||
if ($issues) {
|
||
return '分账明细为空:' . implode(';', $issues);
|
||
}
|
||
return '分账明细为空:请检查分账金额来源、汇付接收方(huifu_id)与佣金/规则是否大于0';
|
||
}
|
||
|
||
protected function diagnoseRuleReceivers($payFee)
|
||
{
|
||
$issues = [];
|
||
if (!$this->config->isHuifuMode()) {
|
||
return $issues;
|
||
}
|
||
$rules = Rule::where('status', 'normal')->order('priority desc')->select();
|
||
if ($this->isEmptyResult($rules)) {
|
||
$issues[] = '无生效分账规则,且佣金侧未生成有效接收方';
|
||
return $issues;
|
||
}
|
||
foreach ($this->toResultList($rules) as $rule) {
|
||
if (in_array($rule['role_type'], ['platform'], true)) {
|
||
continue;
|
||
}
|
||
$receiver = $this->getReceiver($rule['role_type'], 0, true);
|
||
if ($receiver && trim((string) ($receiver['receiver_account'] ?? '')) !== '') {
|
||
continue;
|
||
}
|
||
$roleLabel = $rule['role_type'] === 'regional_agent' ? '区域公司' : '推广员';
|
||
$ruleVal = $rule['rule_type'] === 'rate'
|
||
? (floatval($rule['rule_value']) . '%')
|
||
: ('¥' . number_format(floatval($rule['rule_value']), 2, '.', ''));
|
||
$issues[] = sprintf(
|
||
'规则兜底「%s %s」无汇付 huifu_id:请在分账接收方配置或完成对应角色进件',
|
||
$roleLabel,
|
||
$ruleVal
|
||
);
|
||
}
|
||
if (!$issues) {
|
||
$issues[] = '规则兜底金额为 0 或超过分账上限,未生成有效明细';
|
||
}
|
||
return $issues;
|
||
}
|
||
|
||
protected function formatUserBrief($userId)
|
||
{
|
||
$userId = intval($userId);
|
||
if ($userId <= 0) {
|
||
return '用户#?';
|
||
}
|
||
$user = User::where('id', $userId)->field('id,nickname,mobile')->find();
|
||
if (!$user) {
|
||
return '用户#' . $userId;
|
||
}
|
||
$nickname = trim((string) ($user['nickname'] ?? ''));
|
||
$mobile = trim((string) ($user['mobile'] ?? ''));
|
||
if ($nickname !== '' && $mobile !== '') {
|
||
return $nickname . ' #' . $userId . ' ' . $this->maskMobile($mobile);
|
||
}
|
||
if ($nickname !== '') {
|
||
return $nickname . ' #' . $userId;
|
||
}
|
||
if ($mobile !== '') {
|
||
return '用户#' . $userId . ' ' . $this->maskMobile($mobile);
|
||
}
|
||
return '用户#' . $userId;
|
||
}
|
||
|
||
protected function maskMobile($mobile)
|
||
{
|
||
$mobile = trim((string) $mobile);
|
||
if (strlen($mobile) >= 11) {
|
||
return substr($mobile, 0, 3) . '****' . substr($mobile, -4);
|
||
}
|
||
return $mobile;
|
||
}
|
||
|
||
protected function describeHuifuReceiverGap($roleType, $userId)
|
||
{
|
||
if (!$this->config->isHuifuMode()) {
|
||
return '未配置分账接收方';
|
||
}
|
||
$userId = intval($userId);
|
||
$receiver = Receiver::where('role_type', $roleType)
|
||
->where('role_id', $userId)
|
||
->where('status', 'normal')
|
||
->where(function ($q) {
|
||
$q->where('channel', 'huifu')->whereOr('channel', null)->whereOr('channel', '');
|
||
})
|
||
->find();
|
||
if ($receiver && trim((string) ($receiver['receiver_account'] ?? '')) !== '') {
|
||
return '接收方已配置,请检查 huifu_id 是否在汇付控台绑定';
|
||
}
|
||
|
||
if (!class_exists('\\addons\\shopro_huifu\\model\\Onboard')) {
|
||
return '未配置汇付分账接收方(huifu_id),请完成进件或后台添加接收方';
|
||
}
|
||
|
||
$roleRefId = $this->resolveOnboardRoleRefId($roleType, $userId);
|
||
$onboard = \addons\shopro_huifu\model\Onboard::findByRole($userId, $roleType, $roleRefId);
|
||
if (!$onboard) {
|
||
return '未汇付进件,请用户在小程序完成进件绑定或后台代提交';
|
||
}
|
||
$status = (string) ($onboard['onboard_status'] ?? '');
|
||
$huifuId = trim((string) ($onboard['huifu_id'] ?? ''));
|
||
if ($status === \addons\shopro_huifu\model\Onboard::STATUS_APPROVED) {
|
||
if ($huifuId === '') {
|
||
return '进件已通过但未回写 huifu_id,请同步进件状态并配置分账接收方';
|
||
}
|
||
return '进件已通过(huifu_id=' . $huifuId . '),但未写入分账接收方 role_id=' . $userId;
|
||
}
|
||
return '汇付进件状态:' . $this->onboardStatusLabel($status) . ',暂不能分账';
|
||
}
|
||
|
||
protected function resolveOnboardRoleRefId($roleType, $userId)
|
||
{
|
||
if ($roleType === 'regional_agent') {
|
||
try {
|
||
$agent = Db::name('shopro_regional_agent')->where('user_id', $userId)->order('id', 'desc')->find();
|
||
if ($agent) {
|
||
return intval($agent['id']);
|
||
}
|
||
} catch (\Exception $e) {
|
||
// ignore
|
||
}
|
||
}
|
||
return $userId;
|
||
}
|
||
|
||
protected function onboardStatusLabel($status)
|
||
{
|
||
$map = [
|
||
'draft' => '草稿待提交',
|
||
'submitting' => '提交中',
|
||
'basic_ok' => '基础进件成功',
|
||
'busi_pending' => '业务开通审核中',
|
||
'approved' => '已通过',
|
||
'rejected' => '已驳回',
|
||
'failed' => '进件失败',
|
||
];
|
||
return $map[$status] ?? ($status !== '' ? $status : '未知');
|
||
}
|
||
|
||
protected function isEmptyResult($result)
|
||
{
|
||
if (is_array($result)) {
|
||
return count($result) === 0;
|
||
}
|
||
if (is_object($result) && method_exists($result, 'isEmpty')) {
|
||
return $result->isEmpty();
|
||
}
|
||
return empty($result);
|
||
}
|
||
|
||
protected function toResultList($result)
|
||
{
|
||
if (is_array($result)) {
|
||
return $result;
|
||
}
|
||
if (is_object($result) && method_exists($result, 'all')) {
|
||
return $result->all();
|
||
}
|
||
return $result ? (array) $result : [];
|
||
}
|
||
|
||
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 calculateFromCommission($orderId)
|
||
{
|
||
$items = [];
|
||
|
||
$commissionRewards = CommissionReward::where('order_id', $orderId)->where('status', '>=', 0)->select();
|
||
foreach ($commissionRewards as $reward) {
|
||
// agent_id 即分销商 user_id(shopro_commission_agent 主键)
|
||
$userId = intval($reward['agent_id']);
|
||
if ($userId <= 0) {
|
||
continue;
|
||
}
|
||
$receiver = $this->getReceiver('promoter', $userId);
|
||
if (!$receiver) {
|
||
continue;
|
||
}
|
||
$amount = floatval($reward['commission']);
|
||
if ($amount <= 0) {
|
||
continue;
|
||
}
|
||
$items[] = $this->buildItem('promoter', $userId, $receiver, $amount, '推广员差价');
|
||
}
|
||
|
||
$regionalRewards = $this->getRegionalRewardsByOrderId($orderId);
|
||
foreach ($regionalRewards as $reward) {
|
||
$receiver = $this->getReceiver('regional_agent', $reward['agent_user_id']);
|
||
if (!$receiver) {
|
||
continue;
|
||
}
|
||
$amount = floatval($reward['commission']);
|
||
if ($amount <= 0) {
|
||
continue;
|
||
}
|
||
$items[] = $this->buildItem('regional_agent', $reward['agent_user_id'], $receiver, $amount, '区域公司差价');
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
protected function calculateFromRules($payFee)
|
||
{
|
||
$items = [];
|
||
$rules = Rule::where('status', 'normal')->order('priority desc')->select();
|
||
foreach ($rules as $rule) {
|
||
if (in_array($rule['role_type'], ['platform'])) {
|
||
continue;
|
||
}
|
||
$receiver = $this->getReceiver($rule['role_type'], 0, true);
|
||
if (!$receiver) {
|
||
continue;
|
||
}
|
||
if ($rule['rule_type'] === 'rate') {
|
||
$amount = round($payFee * floatval($rule['rule_value']) / 100, 2);
|
||
} else {
|
||
$amount = round(floatval($rule['rule_value']), 2);
|
||
}
|
||
if ($amount <= 0) {
|
||
continue;
|
||
}
|
||
$items[] = $this->buildItem($rule['role_type'], 0, $receiver, $amount, '规则分账');
|
||
}
|
||
return $items;
|
||
}
|
||
|
||
protected function buildItem($roleType, $roleId, $receiver, $amount, $desc)
|
||
{
|
||
return [
|
||
'role_type' => $roleType,
|
||
'role_id' => $roleId,
|
||
'receiver_id' => $receiver['id'] ?? 0,
|
||
'receiver_account' => $receiver['receiver_account'],
|
||
'receiver_type' => $receiver['receiver_type'],
|
||
'receiver_name' => $receiver['receiver_name'] ?? '',
|
||
'amount' => round($amount, 2),
|
||
'description' => $desc,
|
||
];
|
||
}
|
||
|
||
protected function getReceiver($roleType, $roleId, $ruleMode = false)
|
||
{
|
||
$channel = $this->config->isHuifuMode() ? 'huifu' : 'wechat';
|
||
$query = Receiver::where('role_type', $roleType)->where('status', 'normal');
|
||
if ($roleId) {
|
||
$query->where('role_id', $roleId);
|
||
}
|
||
$receiver = $query->where(function ($q) use ($channel) {
|
||
$q->where('channel', $channel)->whereOr('channel', null)->whereOr('channel', '');
|
||
})->find();
|
||
|
||
if ($receiver) {
|
||
return $receiver;
|
||
}
|
||
|
||
if ($this->config->isHuifuMode()) {
|
||
if ($roleType === 'platform') {
|
||
$account = $this->config->getPlatformHuifuId();
|
||
if ($account) {
|
||
return [
|
||
'id' => 0,
|
||
'receiver_account' => $account,
|
||
'receiver_type' => 'HUIFU_ID',
|
||
'receiver_name' => $this->config->getPlatformReceiverName(),
|
||
];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (!$ruleMode && in_array($roleType, ['promoter', 'regional_agent'])) {
|
||
$oauth = UserOauth::where('user_id', $roleId)->where('provider', 'Wechat')->where('platform', 'wxMiniProgram')->find();
|
||
if ($oauth && $oauth['openid']) {
|
||
return Receiver::create([
|
||
'role_type' => $roleType,
|
||
'role_id' => $roleId,
|
||
'channel' => 'wechat',
|
||
'receiver_type' => 'PERSONAL_OPENID',
|
||
'receiver_account' => $oauth['openid'],
|
||
'receiver_name' => '用户' . $roleId,
|
||
'relation_type' => 'USER',
|
||
'wx_status' => 0,
|
||
'status' => 'normal',
|
||
'createtime' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
if ($roleType === 'platform') {
|
||
$account = $this->config->getPlatformReceiverAccount();
|
||
if ($account) {
|
||
return [
|
||
'id' => 0,
|
||
'receiver_account' => $account,
|
||
'receiver_type' => 'MERCHANT_ID',
|
||
'receiver_name' => $this->config->getPlatformReceiverName(),
|
||
];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|