文件
xionggeshop/addons/shopro_huifu/library/HuifuSplitService.php
可乐加冰 4094ff5cf7 ```
feat(shopro): 完善汇付分账功能的错误处理和调试信息

- 添加主商户号验证逻辑,当platform_huifu_id为空时使用插件主商户
- 改进分账剩余金额显示逻辑,区分主商户留存和平台分账情况
- 更新错误提示信息,明确2200002错误的处理方式
- 新增acct_infos预览功能,便于调试分账配置
- 优化分账服务中的商户号处理,避免主商户重复分账

fix(shopro_huifu): 修复汇付分账配置错误提示和分账逻辑

- 更新22000002错误排查说明,简化表述并突出关键点
- 修复分账服务中构建分账信息的逻辑,确保不包含收款主商户
- 添加日志记录,当跳过分账方为自己时进行警告
- 优化剩余金额处理,当日志平台即收款主商户时剩余款项保留在主商户
- 增强分账验证,当无有效分账接收方时抛出异常
```
2026-10-05 10:33:14 +08:00

421 行
17 KiB
PHP

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

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

<?php
namespace addons\shopro_huifu\library;
use addons\shopro\library\split\SplitCalculator;
use addons\shopro\library\split\Config as SplitConfig;
use addons\shopro\model\Order;
use addons\shopro\model\split\SplitOrder;
use addons\shopro\model\split\SplitOrderReceiver;
use think\Db;
class HuifuSplitService
{
protected $client;
protected $config;
public function __construct()
{
$this->config = HuifuConfig::getInstance();
$this->client = new HuifuClient();
}
public function executeByOrderId($orderId)
{
$order = Order::get($orderId);
if (!$order || $order['pay_type'] !== 'huifu_wechat') {
return false;
}
$done = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->where('status', 2)->find();
if ($done) {
return true;
}
SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->where('status', 'in', [1, 4])->delete();
$calculator = new SplitCalculator();
$items = $calculator->calculate($orderId);
if (empty($items)) {
$rewardCount = \addons\shopro\model\commission\Reward::where('order_id', $orderId)->where('status', '>=', 0)->count();
$regionalCount = \addons\shopro\model\regional_agent\Reward::where('order_id', $orderId)->where('status', '>=', 0)->count();
if ($rewardCount + $regionalCount > 0) {
\think\Log::warning('[shopro_huifu] split skipped order_id=' . $orderId . ' reason=receiver_missing_or_no_huifu_id');
}
$msg = '分账明细为空:请检查分账金额来源、汇付接收方(huifu_id)与佣金/规则是否大于0';
$this->markFailed($orderId, 'HFPS' . date('YmdHis') . $orderId, $order, [], $msg);
return false;
}
$payFee = number_format((float) $order['pay_fee'], 2, '.', '');
$confirmBase = $this->resolveConfirmPayFee($order['order_sn'], $payFee);
if (bccomp($confirmBase, $payFee, 2) !== 0) {
\think\Log::info('[shopro_huifu] split confirm base: order_id=' . $orderId
. ' pay_fee=' . $payFee . ' confirm_base=' . $confirmBase);
}
$allocated = '0.00';
foreach ($items as $item) {
$allocated = bcadd($allocated, number_format((float) $item['amount'], 2, '.', ''), 2);
}
if (bccomp($allocated, $confirmBase, 2) > 0) {
$items = $this->scaleSplitItemsToCap($items, $allocated, $confirmBase);
}
$acctInfos = $this->buildAcctInfos($items, $confirmBase);
$splitAmount = '0.00';
foreach ($items as $item) {
$splitAmount = bcadd($splitAmount, number_format((float) $item['amount'], 2, '.', ''), 2);
}
$outOrderNo = 'HFPS' . date('YmdHis') . $orderId;
$ext = HuifuPayService::getPayExt($orderId);
$blockReason = $this->getDelayFundsBlockReason($order['order_sn']);
if ($blockReason !== null) {
$this->markFailed($orderId, $outOrderNo, $order, $items, $blockReason);
return false;
}
Db::startTrans();
try {
$splitOrder = SplitOrder::create([
'order_id' => $orderId,
'order_sn' => $order['order_sn'],
'channel' => 'huifu',
'transaction_id' => $order['transaction_id'],
'out_order_no' => $outOrderNo,
'total_amount' => $order['pay_fee'],
'split_amount' => $splitAmount,
'status' => 1,
]);
foreach ($items as $item) {
SplitOrderReceiver::create([
'split_order_id' => $splitOrder->id,
'receiver_id' => $item['receiver_id'],
'role_type' => $item['role_type'],
'amount' => $item['amount'],
'description' => $item['description'],
'status' => 0,
]);
}
$confirmResult = $this->confirm($ext, $acctInfos, $outOrderNo);
$splitOrder->status = 2;
$splitOrder->confirm_hf_seq_id = $confirmResult['hf_seq_id'] ?? ($confirmResult['confirm_hf_seq_id'] ?? '');
$splitOrder->hf_req_seq_id = $outOrderNo;
$splitOrder->wx_result = json_encode($confirmResult, JSON_UNESCAPED_UNICODE);
$splitOrder->save();
SplitOrderReceiver::where('split_order_id', $splitOrder->id)->update(['status' => 1]);
HuifuPayService::savePayExt($orderId, $order['order_sn'], [
'confirm_hf_seq_id' => $splitOrder->confirm_hf_seq_id,
'confirm_status' => 'S',
]);
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
$this->markFailed($orderId, $outOrderNo, $order, $items, $e->getMessage());
return false;
}
}
public function retry($splitOrderId)
{
$splitOrder = SplitOrder::where('id', $splitOrderId)->find();
if (!$splitOrder || $splitOrder['channel'] !== 'huifu') {
return false;
}
return $this->executeByOrderId((int) $splitOrder['order_id']);
}
public function retryByOrderId($orderId)
{
$orderId = (int) $orderId;
if ($orderId <= 0) {
return false;
}
return $this->executeByOrderId($orderId);
}
protected function confirm($ext, array $acctInfos, $confirmReqSeqId)
{
SdkLoader::requireRequest('V2TradePaymentDelaytransConfirmRequest.php');
$request = new \BsPaySdk\request\V2TradePaymentDelaytransConfirmRequest();
$request->setReqDate(date('Ymd'));
$request->setReqSeqId($confirmReqSeqId);
$request->setHuifuId($this->config->get('huifu_id'));
$acctSplitBunch = json_encode(['acct_infos' => $acctInfos], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$request->setExtendInfo([
'org_req_date' => $ext['req_date'],
'org_req_seq_id' => $ext['req_seq_id'],
'org_hf_seq_id' => $ext['hf_seq_id'] ?? '',
'acct_split_bunch' => $acctSplitBunch,
]);
$result = $this->client->postRequest($request);
return $this->client->parseResponse($result);
}
protected function buildAcctInfos(array $items, $payFee)
{
$payFee = number_format((float) $payFee, 2, '.', '');
$paymentHuifuId = trim((string) $this->config->get('huifu_id'));
$platformHuifuId = trim((string) ((new SplitConfig())->all()['platform_huifu_id'] ?? $paymentHuifuId));
if ($platformHuifuId === '') {
$platformHuifuId = $paymentHuifuId;
}
$infos = [];
$allocated = '0.00';
foreach ($items as $item) {
$amt = number_format((float) $item['amount'], 2, '.', '');
if (bccomp($amt, '0.00', 2) <= 0) {
continue;
}
$receiverHuifuId = trim((string) ($item['receiver_account'] ?? ''));
if ($receiverHuifuId === '') {
continue;
}
// 汇付要求:分账方勿与收款主商户相同(剩余款留在主商户,勿写入 acct_infos)
if ($paymentHuifuId !== '' && $receiverHuifuId === $paymentHuifuId) {
\think\Log::warning('[shopro_huifu] split skip self receiver in acct_infos huifu_id=' . $receiverHuifuId);
continue;
}
$infos[] = [
'huifu_id' => $receiverHuifuId,
'div_amt' => $amt,
];
$allocated = bcadd($allocated, $amt, 2);
}
$remain = bcsub($payFee, $allocated, 2);
if (bccomp($remain, '0.00', 2) > 0) {
$platformIsCollector = ($platformHuifuId === $paymentHuifuId);
if (!$platformIsCollector) {
$infos[] = [
'huifu_id' => $platformHuifuId,
'div_amt' => $remain,
];
} else {
\think\Log::info('[shopro_huifu] split platform remainder stays on collector: remain=' . $remain
. ' huifu_id=' . $paymentHuifuId);
}
}
$sum = '0.00';
foreach ($infos as $info) {
$sum = bcadd($sum, $info['div_amt'], 2);
}
if (empty($infos)) {
throw new \RuntimeException('分账明细为空:无有效分账接收方(勿将主商户号作为分账方)');
}
if (bccomp($sum, $payFee, 2) > 0) {
throw new \RuntimeException('分账金额之和不能超过待确认金额');
}
return $infos;
}
/**
* 延时分账确认总额须与汇付「待确认/交易金额」一致;内扣手续费时往往小于商城 pay_fee。
*/
public function previewConfirmPayFee($orderSn, $orderPayFee)
{
return $this->resolveConfirmPayFee($orderSn, $orderPayFee);
}
/**
* 微信小程序 + 延时分账:通道侧资金可能处于 FREEZE,需发货/确认收货或等待解冻后才能 delaytrans/confirm。
*
* @return string|null 不可 confirm 时返回说明文案,否则 null
*/
protected function getDelayFundsBlockReason($orderSn)
{
try {
$query = (new HuifuQueryService())->queryByOrderSn($orderSn);
} catch (\Throwable $e) {
return null;
}
$freezeStat = strtoupper(trim((string) ($this->queryFieldRecursive($query, 'fund_freeze_stat') ?? '')));
if ($freezeStat !== 'FREEZE') {
return null;
}
$unfreezeTime = trim((string) ($this->queryFieldRecursive($query, 'unfreeze_time') ?? ''));
$hint = '汇付查单 fund_freeze_stat=FREEZE:延迟交易资金仍被通道冻结,暂不能交易确认分账。'
. '小程序场景通常需:后台发货并同步微信 upload_shipping_info,用户在微信确认收货(或等待自动解冻,约 2–3 天)后再分账;'
. '建议分账时机改为「确认收货」而非「支付成功」。';
if ($unfreezeTime !== '') {
$hint .= ' 预计解冻时间(unfreeze_time)=' . $unfreezeTime;
}
return $hint;
}
protected function resolveConfirmPayFee($orderSn, $orderPayFee)
{
$fallback = number_format((float) $orderPayFee, 2, '.', '');
try {
$query = (new HuifuQueryService())->queryByOrderSn($orderSn);
$delayAcct = strtoupper(trim((string) ($this->queryFieldRecursive($query, 'delay_acct_flag') ?? ''))) === 'Y';
$unconfirm = $this->queryFieldRecursive($query, 'unconfirm_amt');
if ($this->isPositiveMoney($unconfirm)) {
return number_format((float) $unconfirm, 2, '.', '');
}
$settlement = $this->queryFieldRecursive($query, 'settlement_amt');
if ($this->isPositiveMoney($settlement) && bccomp((string) $settlement, $fallback, 2) < 0) {
return number_format((float) $settlement, 2, '.', '');
}
$transAmt = $this->pickTransAmt($query);
$feeAmt = $this->pickFeeAmt($query);
if ($transAmt !== null && $feeAmt !== null) {
$net = bcsub($transAmt, $feeAmt, 2);
if (bccomp($net, '0.00', 2) > 0 && bccomp($net, $fallback, 2) <= 0) {
\think\Log::info('[shopro_huifu] split confirm base from trans-fee: trans=' . $transAmt . ' fee=' . $feeAmt . ' net=' . $net);
return $net;
}
}
if ($transAmt !== null && !$delayAcct && bccomp($transAmt, '0.00', 2) > 0) {
return $transAmt;
}
if ($delayAcct && $transAmt !== null && bccomp($transAmt, $fallback, 2) === 0) {
\think\Log::warning('[shopro_huifu] split confirm base: delay_acct=Y but unconfirm/fee missing, still using pay_fee=' . $fallback
. ' query_keys=' . implode(',', array_slice(array_keys($query), 0, 20)));
}
} catch (\Throwable $e) {
\think\Log::warning('[shopro_huifu] split confirm amount query fallback: ' . $e->getMessage());
}
return $fallback;
}
protected function pickTransAmt(array $query)
{
$raw = $this->queryFieldRecursive($query, 'trans_amt');
if (!$this->isPositiveMoney($raw)) {
return null;
}
return number_format((float) $raw, 2, '.', '');
}
protected function pickFeeAmt(array $query)
{
foreach (['actual_fee_amt', 'receivable_fee_amt', 'fee_amt', 'fee_amount', 'trans_fee', 'combinedpay_fee_amt'] as $key) {
$raw = $this->queryFieldRecursive($query, $key);
if ($this->isPositiveMoney($raw)) {
return number_format((float) $raw, 2, '.', '');
}
}
return null;
}
protected function isPositiveMoney($value)
{
if ($value === null || $value === '') {
return false;
}
if (!is_numeric($value)) {
return false;
}
return bccomp(number_format((float) $value, 2, '.', ''), '0.00', 2) > 0;
}
/**
* @param array|string|mixed $data
*/
protected function queryFieldRecursive($data, $key)
{
if (is_string($data)) {
$trim = trim($data);
if ($trim !== '' && ($trim[0] === '{' || $trim[0] === '[')) {
$decoded = json_decode($trim, true);
if (is_array($decoded)) {
return $this->queryFieldRecursive($decoded, $key);
}
}
return null;
}
if (!is_array($data)) {
return null;
}
if (array_key_exists($key, $data) && $data[$key] !== '' && $data[$key] !== null) {
return $data[$key];
}
foreach ($data as $value) {
$found = $this->queryFieldRecursive($value, $key);
if ($found !== null && $found !== '') {
return $found;
}
}
return null;
}
protected function scaleSplitItemsToCap(array $items, $allocated, $cap)
{
if (empty($items) || bccomp($allocated, '0.00', 2) <= 0) {
return $items;
}
$cap = number_format((float) $cap, 2, '.', '');
$allocated = number_format((float) $allocated, 2, '.', '');
if (bccomp($allocated, $cap, 2) <= 0) {
return $items;
}
$scaled = [];
$sum = '0.00';
$count = count($items);
foreach ($items as $index => $item) {
if ($index === $count - 1) {
$item['amount'] = (float) bcsub($cap, $sum, 2);
} else {
$ratio = bcdiv($item['amount'], $allocated, 8);
$part = bcmul($cap, $ratio, 2);
$item['amount'] = (float) $part;
$sum = bcadd($sum, $part, 2);
}
if ($item['amount'] > 0) {
$scaled[] = $item;
}
}
return $scaled;
}
public function querySplit($hfSeqId)
{
SdkLoader::requireRequest('V2TradeTransSplitQueryRequest.php');
$request = new \BsPaySdk\request\V2TradeTransSplitQueryRequest();
$request->setHuifuId($this->config->get('huifu_id'));
$request->setHfSeqId($hfSeqId);
$request->setOrdType('consume');
$result = $this->client->postRequest($request);
return $this->client->parseResponse($result);
}
protected function markFailed($orderId, $outOrderNo, $order, $items, $message)
{
$splitOrder = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->find();
$splitConfig = new SplitConfig();
if (!$splitOrder) {
SplitOrder::create([
'order_id' => $orderId,
'order_sn' => $order['order_sn'],
'channel' => 'huifu',
'transaction_id' => $order['transaction_id'] ?? '',
'out_order_no' => $outOrderNo,
'total_amount' => $order['pay_fee'],
'split_amount' => array_sum(array_column($items, 'amount')),
'status' => 4,
'wx_result' => $message,
'retry_count' => 1,
'next_retry_time'=> time() + $splitConfig->getRetryInterval(),
]);
} else {
$splitOrder->status = 4;
$splitOrder->wx_result = $message;
$splitOrder->retry_count = $splitOrder['retry_count'] + 1;
$splitOrder->next_retry_time = time() + $splitConfig->getRetryInterval();
$splitOrder->save();
}
}
}