config = HuifuConfig::getInstance(); $this->client = new HuifuClient(); } public function executeByOrderId($orderId) { $order = Order::get($orderId); if (!$order || $order['pay_type'] !== 'huifu_wechat') { return false; } if ((new SplitCloseService())->isClosed($orderId)) { \think\Log::info('[shopro_huifu] split skipped order_id=' . $orderId . ' reason=closed_offline'); return false; } $retryBlock = SplitWalletSettlement::getHuifuRetryBlockReason($orderId); if ($retryBlock !== null) { throw new \RuntimeException($retryBlock); } $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(); $manualService = new SplitManualService(); $isManual = $manualService->hasOverride($orderId); $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 = $calculator->formatEmptySplitFailureMessage($orderId); $this->markFailed($orderId, 'HFPS' . date('YmdHis') . $orderId, $order, [], $msg); $manualService->finishPendingRetryLog($orderId, false, $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->getDelayTransBlockReason($order['order_sn'], $orderId); if ($blockReason !== null) { $this->markFailed($orderId, $outOrderNo, $order, $items, $blockReason); $manualService->finishPendingRetryLog($orderId, false, $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, 'split_source' => $isManual ? 'manual' : 'auto', ]); 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, $confirmBase); $finalConfirmSeq = $confirmResult['hf_seq_id'] ?? ($confirmResult['confirm_hf_seq_id'] ?? ''); try { $remainderResult = $this->releaseCollectorRemainderIfNeeded($orderId, $order['order_sn'], $confirmBase, $acctInfos); if ($remainderResult !== null) { $confirmResult['collector_remainder_confirm'] = $remainderResult; $seq = $remainderResult['hf_seq_id'] ?? ''; if ($seq !== '') { $finalConfirmSeq = $seq; } } } catch (\Exception $remainderEx) { \think\Log::error('[shopro_huifu] collector remainder confirm failed order_id=' . $orderId . ' msg=' . $remainderEx->getMessage()); $confirmResult['collector_remainder_error'] = $remainderEx->getMessage(); } $splitOrder->status = 2; $splitOrder->confirm_hf_seq_id = $finalConfirmSeq; $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' => $finalConfirmSeq, 'confirm_status' => $this->resolveDelayConfirmStatus($order['order_sn']), ]); Db::commit(); $manualService->finishPendingRetryLog($orderId, true, '', $splitOrder->id); return true; } catch (\Exception $e) { Db::rollback(); $this->markFailed($orderId, $outOrderNo, $order, $items, $e->getMessage()); $manualService->finishPendingRetryLog($orderId, false, $e->getMessage()); return false; } } public function retry($splitOrderId) { $splitOrder = SplitOrder::where('id', $splitOrderId)->find(); if (!$splitOrder || $splitOrder['channel'] !== 'huifu') { return false; } if (intval($splitOrder['status']) === SplitCloseService::STATUS_CLOSED) { throw new \RuntimeException('该订单已标记线下结案,不可重试'); } if ((new SplitCloseService())->isClosed((int) $splitOrder['order_id'])) { throw new \RuntimeException('该订单已标记线下结案,不可重试'); } $orderId = (int) $splitOrder['order_id']; $ok = $this->executeByOrderId($orderId); if ($ok) { $event = (new SplitConfig())->getTiming(); SplitWalletSettlement::finalizeAfterSplitAttempt($orderId, $event, true); } return $ok; } public function retryByOrderId($orderId) { $orderId = (int) $orderId; if ($orderId <= 0) { return false; } $latest = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->order('id', 'desc')->find(); if (!$latest) { return $this->executeByOrderId($orderId); } return $this->retry((int) $latest['id']); } /** * 预览延时分账 confirm 请求体(调试 22000002) */ public function previewDelaytransConfirmExtend($orderId) { $order = Order::get($orderId); if (!$order || $order['pay_type'] !== 'huifu_wechat') { return null; } $calculator = new SplitCalculator(); $items = $calculator->calculate($orderId); if (empty($items)) { return ['error' => '分账明细为空']; } $payFee = number_format((float) $order['pay_fee'], 2, '.', ''); $confirmBase = $this->resolveConfirmPayFee($order['order_sn'], $payFee); $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); $ext = HuifuPayService::getPayExt($orderId); $bunch = json_encode(['acct_infos' => $acctInfos], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return [ 'confirm_base' => $confirmBase, 'payment_huifu_id' => $this->config->get('huifu_id'), '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' => $bunch, ]; } /** * 仅交易确认、不传分账串(用于排查 22000002 是否来自分账接收方配置) */ public function executeConfirmOnlyByOrderId($orderId, $syncPayExt = true) { $order = Order::get($orderId); if (!$order || $order['pay_type'] !== 'huifu_wechat') { return false; } $ext = HuifuPayService::getPayExt($orderId); if (!$ext) { throw new \RuntimeException('无 shopro_huifu_pay_ext'); } $blockReason = $this->getDelayFundsBlockReason($order['order_sn']); if ($blockReason !== null) { throw new \RuntimeException($blockReason); } $outOrderNo = 'HFPC' . date('YmdHis') . $orderId; SdkLoader::requireRequest('V2TradePaymentDelaytransConfirmRequest.php'); $request = new \BsPaySdk\request\V2TradePaymentDelaytransConfirmRequest(); $request->setReqDate(date('Ymd')); $request->setReqSeqId($outOrderNo); $request->setHuifuId($this->config->get('huifu_id')); $extend = [ 'org_req_date' => $ext['req_date'], 'org_req_seq_id' => $ext['req_seq_id'], 'org_hf_seq_id' => $ext['hf_seq_id'] ?? '', ]; \think\Log::write('[shopro_huifu] delaytrans confirm-only extend=' . json_encode($extend, JSON_UNESCAPED_UNICODE), 'info'); $request->setExtendInfo($extend); $result = $this->client->postRequest($request); $data = $this->client->parseResponse($result); if ($syncPayExt) { $this->syncPayExtAfterDelayConfirm($orderId, $order['order_sn'], $data); } return $data; } /** * 主商户=收款户时,分账 confirm 只确认 acct_infos 部分,尾款需再调一次无分账串的 confirm。 * * @return array|null 汇付返回;无需补确认时 null */ protected function releaseCollectorRemainderIfNeeded($orderId, $orderSn, $confirmBase, array $acctInfos) { $paymentHuifuId = trim((string) $this->config->get('huifu_id')); $platformHuifuId = trim((string) ((new SplitConfig())->all()['platform_huifu_id'] ?? $paymentHuifuId)); if ($platformHuifuId === '' || $platformHuifuId !== $paymentHuifuId) { return null; } $confirmBase = number_format((float) $confirmBase, 2, '.', ''); $splitSum = '0.00'; foreach ($acctInfos as $info) { $splitSum = bcadd($splitSum, number_format((float) ($info['div_amt'] ?? 0), 2, '.', ''), 2); } $expectedRemain = bcsub($confirmBase, $splitSum, 2); if (bccomp($expectedRemain, '0.00', 2) <= 0) { return null; } $query = (new HuifuQueryService())->queryByOrderSn($orderSn); $unconfirm = $this->queryFieldRecursive($query, 'unconfirm_amt'); if (!$this->isPositiveMoney($unconfirm)) { return null; } \think\Log::info('[shopro_huifu] delaytrans collector remainder confirm order_id=' . $orderId . ' expected_remain=' . $expectedRemain . ' unconfirm_amt=' . $unconfirm); return $this->executeConfirmOnlyByOrderId($orderId, false); } protected function resolveDelayConfirmStatus($orderSn) { try { $query = (new HuifuQueryService())->queryByOrderSn($orderSn); $unconfirm = $this->queryFieldRecursive($query, 'unconfirm_amt'); if ($this->isPositiveMoney($unconfirm)) { return 'P'; } } catch (\Throwable $e) { \think\Log::warning('[shopro_huifu] resolveDelayConfirmStatus query failed: ' . $e->getMessage()); } return 'S'; } protected function syncPayExtAfterDelayConfirm($orderId, $orderSn, array $confirmData) { $seq = $confirmData['hf_seq_id'] ?? ($confirmData['confirm_hf_seq_id'] ?? ''); HuifuPayService::savePayExt($orderId, $orderSn, [ 'confirm_hf_seq_id' => $seq, 'confirm_status' => $this->resolveDelayConfirmStatus($orderSn), ]); } protected function confirm($ext, array $acctInfos, $confirmReqSeqId, $confirmBase = null) { SdkLoader::requireRequest('V2TradePaymentDelaytransConfirmRequest.php'); $request = new \BsPaySdk\request\V2TradePaymentDelaytransConfirmRequest(); $request->setReqDate(date('Ymd')); $request->setReqSeqId($confirmReqSeqId); $request->setHuifuId($this->config->get('huifu_id')); $bunch = ['acct_infos' => $acctInfos]; $acctSplitBunch = json_encode($bunch, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $extend = [ '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, ]; \think\Log::write('[shopro_huifu] delaytrans confirm extend=' . json_encode($extend, JSON_UNESCAPED_UNICODE), 'info'); $request->setExtendInfo($extend); $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); } /** * 延时分账 confirm 前置拦截(非延迟入账 / 微信 FREEZE) * * @return string|null 不可 confirm 时返回说明文案,否则 null */ protected function getDelayTransBlockReason($orderSn, $orderId) { $nonDelay = $this->getNonDelayAcctBlockReason($orderSn, $orderId); if ($nonDelay !== null) { return $nonDelay; } return $this->getDelayFundsBlockReason($orderSn); } /** * 23000004「交易非延迟入账」:支付时未带 delay_acct_flag=Y,不能调 delaytrans/confirm */ protected function getNonDelayAcctBlockReason($orderSn, $orderId) { $ext = HuifuPayService::getPayExt($orderId); $local = strtoupper(trim((string) ($ext['delay_acct_flag'] ?? ''))); if ($local === 'N') { return '该笔支付为实时入账(delay_acct_flag=N),不支持 delaytrans/confirm(23000004 交易非延迟入账)。' . '请在分账配置开启「延时分账」后重新下单;历史订单无法补打延迟标记,勿反复重试分账。'; } try { $query = (new HuifuQueryService())->queryByOrderSn($orderSn); } catch (\Throwable $e) { return null; } $remote = strtoupper(trim((string) ($this->queryFieldRecursive($query, 'delay_acct_flag') ?? ''))); if ($remote !== '' && $remote !== 'Y') { return '汇付查单 delay_acct_flag=' . $remote . '(非延迟入账),不能延时分账确认(23000004)。' . ' 请核对下单是否 setDelayAcctFlag(Y)、商户 delay_flag 是否开通。'; } return null; } /** * 微信小程序 + 延时分账:通道侧资金可能处于 FREEZE,需发货/确认收货或等待解冻后才能 delaytrans/confirm。 */ 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(); $isManual = (new SplitManualService())->hasOverride($orderId); if (!$splitOrder) { $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, 'split_source' => $isManual ? 'manual' : 'auto', '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(); } SplitOrderHelper::syncReceivers($splitOrder->id, $items); } }