diff --git a/addons/shopro/library/split/SplitWalletSettlement.php b/addons/shopro/library/split/SplitWalletSettlement.php index f26e0ce..fd98b0b 100644 --- a/addons/shopro/library/split/SplitWalletSettlement.php +++ b/addons/shopro/library/split/SplitWalletSettlement.php @@ -22,20 +22,52 @@ class SplitWalletSettlement { public static function shouldDeferCommissionWallet($order, $event) { - if (!self::shouldDeferForGatewaySplit($order, $event)) { + $commissionEvent = (new CommissionConfig())->getCommissionEvent(); + if ($commissionEvent !== $event && $event !== 'admin') { return false; } - $commissionEvent = (new CommissionConfig())->getCommissionEvent(); - return $commissionEvent === $event; + return self::shouldDeferWalletUntilGatewaySplitResolves($order); } public static function shouldDeferRegionalWallet($order, $event) { - if (!self::shouldDeferForGatewaySplit($order, $event)) { + $regionalEvent = (new RegionalConfig())->getCommissionEvent(); + if ($regionalEvent !== $event && $event !== 'admin') { return false; } - $regionalEvent = (new RegionalConfig())->getCommissionEvent(); - return $regionalEvent === $event; + return self::shouldDeferWalletUntilGatewaySplitResolves($order); + } + + /** + * 分销/区域结算事件(如 payed)早于 split_timing(如 confirm)时,仍应等分账结束再入钱包 + */ + protected static function shouldDeferWalletUntilGatewaySplitResolves($order) + { + if (is_numeric($order)) { + $order = Order::get($order); + } + if (!$order) { + return false; + } + + $config = new Config(); + if (!$config->isEnable() || !$config->shouldSkipWallet()) { + return false; + } + if (self::isSplitSuccessful($order['id'])) { + return false; + } + + if (class_exists('\\addons\\shopro_huifu\\library\\Bridge') + && \addons\shopro_huifu\library\Bridge::shouldHandleSplit($order)) { + return true; + } + + if (in_array($config->getMode(), ['wechat', 'both'], true) && $order['pay_type'] === 'wechat') { + return true; + } + + return false; } /** diff --git a/addons/shopro_huifu/command/SyncSplitReceivers.php b/addons/shopro_huifu/command/SyncSplitReceivers.php new file mode 100644 index 0000000..23fbac9 --- /dev/null +++ b/addons/shopro_huifu/command/SyncSplitReceivers.php @@ -0,0 +1,84 @@ +setName('shopro_huifu:sync-split-receivers') + ->addOption('dry-run', null, Option::VALUE_NONE, '仅预览将提交的接收方与参数,不调汇付') + ->addOption('init-only', null, Option::VALUE_NONE, '仅提交主商户分账开关(rule_origin=01),不带接收方列表') + ->addOption('huifu-id', null, Option::VALUE_OPTIONAL, '主商户汇付号,默认 platform_huifu_id 或插件 huifu_id') + ->setDescription('Submit v2/merchant/split/config and bind approved onboard huifu_ids as split receivers'); + } + + protected function execute(Input $input, Output $output) + { + if (!Bridge::isEnabled()) { + $output->writeln('[FAIL] 汇付插件未启用'); + return 1; + } + + $service = new HuifuMerchantSplitService(); + $main = trim((string) $input->getOption('huifu-id')); + if ($main === '') { + $main = $service->resolveMainHuifuId(); + } + $output->writeln('主商户 huifu_id: ' . $main); + $output->writeln('apply_ratio(最大分账比例%): ' . $service->resolveApplyRatio()); + + $dryRun = (bool) $input->getOption('dry-run'); + $initOnly = (bool) $input->getOption('init-only'); + $options = [ + 'dry_run' => $dryRun, + 'main_huifu_id' => $main, + ]; + + if ($initOnly) { + $output->writeln('模式: init-only(不带 acct_split_bunch_list)'); + $result = $service->submitSplitConfig([], $options); + } else { + $output->writeln('模式: 全量(远端已绑 + 本地已进件 approved)'); + $result = $service->syncAllApprovedReceivers($options); + } + + if (!empty($result['dry_run'])) { + $output->writeln('[DRY-RUN] 将提交接收方 ' . count($result['receivers'] ?? []) . ' 个'); + $output->writeln(json_encode($result['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + return 0; + } + + $output->writeln('resp: ' . ($result['message'] ?? '')); + if (isset($result['resp_code'])) { + $output->writeln('resp_code: ' . $result['resp_code']); + } + if (isset($result['submitted'])) { + $output->writeln('提交接收方数量: ' . $result['submitted'] + . '(远端已有 ' . ($result['remote'] ?? 0) . ',本地进件 ' . ($result['local'] ?? 0) . ')'); + } + if (!empty($result['receivers'])) { + foreach ($result['receivers'] as $hid) { + $output->writeln(' - ' . $hid); + } + } + + if (empty($result['ok'])) { + $output->writeln('[FAIL] 提交未成功,请查看日志 [shopro_huifu] split/config'); + return 1; + } + + $output->writeln(''); + $output->writeln('下一步: php think shopro_huifu:query-split-config --huifu-id=' . $main); + $output->writeln('(配置若走异步审核,需等待生效后再测 delaytrans/confirm)'); + return 0; + } +} diff --git a/addons/shopro_huifu/library/HuifuClient.php b/addons/shopro_huifu/library/HuifuClient.php index 536d712..8c07a0e 100644 --- a/addons/shopro_huifu/library/HuifuClient.php +++ b/addons/shopro_huifu/library/HuifuClient.php @@ -587,6 +587,36 @@ class HuifuClient return $this->safeApiResult($this->postRequest($request)); } + /** + * 商户分账配置提交 v2/merchant/split/config + * + * @param array $extend rule_origin、div_flag、apply_ratio、acct_split_bunch_list 等 + */ + public function submitMerchantSplitConfig($huifuId, array $extend) + { + $huifuId = trim((string) $huifuId); + if ($huifuId === '') { + return ['ok' => false, 'message' => '缺少 huifu_id', 'data' => null]; + } + + SdkLoader::requireRequest('V2MerchantSplitConfigRequest.php'); + $request = new \BsPaySdk\request\V2MerchantSplitConfigRequest(); + $request->setHuifuId($huifuId); + $request->setReqDate(date('Ymd')); + $request->setReqSeqId('SPLCFG' . date('YmdHis') . mt_rand(1000, 9999)); + + $productId = trim((string) ($extend['product_id'] ?? $this->config->get('product_id'))); + if ($productId !== '' && !isset($extend['product_id'])) { + $extend['product_id'] = $productId; + } + $request->setExtendInfo($extend); + + \think\Log::write('[shopro_huifu] split/config request huifu_id=' . $huifuId + . ' extend=' . json_encode($extend, JSON_UNESCAPED_UNICODE), 'info'); + + return $this->safeApiResult($this->postRequest($request)); + } + /** diff --git a/addons/shopro_huifu/library/Installer.php b/addons/shopro_huifu/library/Installer.php index 3ffbfc6..a626594 100644 --- a/addons/shopro_huifu/library/Installer.php +++ b/addons/shopro_huifu/library/Installer.php @@ -27,6 +27,7 @@ class Installer self::importSqlFile($addonDir . 'upgrade_huifu_split.sql', $output, true); self::importSqlFile($addonDir . 'upgrade_huifu_onboard.sql', $output, true); self::importSqlFile($addonDir . 'upgrade_huifu_onboard_account.sql', $output, true); + self::importSqlFile($addonDir . 'upgrade_huifu_split_bind.sql', $output, true); self::upgradePayTypeColumn($output); self::log($output, '注册汇付/分账菜单 ...'); @@ -170,6 +171,7 @@ class Installer self::importSqlFile($addonDir . 'upgrade_huifu_split.sql', $output, true); self::importSqlFile($addonDir . 'upgrade_huifu_onboard.sql', $output, true); self::importSqlFile($addonDir . 'upgrade_huifu_onboard_account.sql', $output, true); + self::importSqlFile($addonDir . 'upgrade_huifu_split_bind.sql', $output, true); self::upgradePayTypeColumn($output); } diff --git a/addons/shopro_huifu/library/onboard/HuifuOnboardReceiverSync.php b/addons/shopro_huifu/library/onboard/HuifuOnboardReceiverSync.php index 39efd87..98c262c 100644 --- a/addons/shopro_huifu/library/onboard/HuifuOnboardReceiverSync.php +++ b/addons/shopro_huifu/library/onboard/HuifuOnboardReceiverSync.php @@ -42,10 +42,11 @@ class HuifuOnboardReceiverSync $existing->status = 'normal'; $existing->updatetime = $now; $existing->save(); + self::bindHuifuSplitReceiver($row); return $existing; } - return Receiver::create([ + $created = Receiver::create([ 'role_type' => $row['role_type'], 'role_id' => $roleId, 'channel' => 'huifu', @@ -58,6 +59,23 @@ class HuifuOnboardReceiverSync 'createtime' => $now, 'updatetime' => $now, ]); + self::bindHuifuSplitReceiver($row); + return $created; + } + + /** + * 进件通过后向汇付主商户提交 split/config,绑定子户为分账接收方 + */ + protected static function bindHuifuSplitReceiver(Onboard $row) + { + if (!class_exists('\\addons\\shopro_huifu\\library\\split\\HuifuMerchantSplitService')) { + return; + } + try { + (new \addons\shopro_huifu\library\split\HuifuMerchantSplitService())->bindOnboardReceiver($row); + } catch (\Throwable $e) { + \think\Log::error('[shopro_huifu] onboard split bind: ' . $e->getMessage()); + } } public static function bindOnboardId($roleType, $roleRefId, $onboardId) diff --git a/addons/shopro_huifu/library/sdk/extracted/huifurepo-bspay-php-sdk-45b895b/BsPaySdk/request/V2MerchantSplitConfigRequest.php b/addons/shopro_huifu/library/sdk/extracted/huifurepo-bspay-php-sdk-45b895b/BsPaySdk/request/V2MerchantSplitConfigRequest.php new file mode 100644 index 0000000..5a6af8b --- /dev/null +++ b/addons/shopro_huifu/library/sdk/extracted/huifurepo-bspay-php-sdk-45b895b/BsPaySdk/request/V2MerchantSplitConfigRequest.php @@ -0,0 +1,50 @@ +reqSeqId; + } + + public function setReqSeqId($reqSeqId) + { + $this->reqSeqId = $reqSeqId; + } + + public function getReqDate() + { + return $this->reqDate; + } + + public function setReqDate($reqDate) + { + $this->reqDate = $reqDate; + } + + public function getHuifuId() + { + return $this->huifuId; + } + + public function setHuifuId($huifuId) + { + $this->huifuId = $huifuId; + } +} diff --git a/addons/shopro_huifu/library/split/HuifuMerchantSplitService.php b/addons/shopro_huifu/library/split/HuifuMerchantSplitService.php new file mode 100644 index 0000000..c222e91 --- /dev/null +++ b/addons/shopro_huifu/library/split/HuifuMerchantSplitService.php @@ -0,0 +1,351 @@ +client = $client ?: new HuifuClient(); + $this->config = HuifuConfig::getInstance(); + } + + public function isEnabled() + { + return Bridge::isEnabled(); + } + + public function resolveMainHuifuId() + { + try { + $platform = trim((string) Db::name('shopro_split_config')->where('name', 'platform_huifu_id')->value('value')); + if ($platform !== '') { + return $platform; + } + } catch (\Exception $e) { + // ignore + } + return trim((string) $this->config->get('huifu_id')); + } + + public function resolveApplyRatio() + { + $rate = 30.0; + try { + $rate = floatval(Db::name('shopro_split_config')->where('name', 'split_max_rate')->value('value')); + } catch (\Exception $e) { + // ignore + } + if ($rate <= 0 || $rate > 100) { + $rate = 30.0; + } + return number_format($rate, 2, '.', ''); + } + + /** + * @return string[] + */ + public function collectApprovedReceiverHuifuIds() + { + $main = $this->resolveMainHuifuId(); + $rows = Onboard::where('onboard_status', Onboard::STATUS_APPROVED) + ->where('huifu_id', '<>', '') + ->whereNotNull('huifu_id') + ->field('huifu_id') + ->select(); + $ids = []; + foreach ($rows as $row) { + $hid = trim((string) $row['huifu_id']); + if ($hid === '' || $hid === $main) { + continue; + } + $ids[$hid] = $hid; + } + return array_values($ids); + } + + /** + * @return string[] + */ + public function fetchRemoteReceiverHuifuIds($mainHuifuId = null) + { + $mainHuifuId = $mainHuifuId ?: $this->resolveMainHuifuId(); + $result = $this->client->queryMerchantSplitConfig($mainHuifuId); + if (!$result['ok']) { + return []; + } + $items = HuifuClient::parseSplitBillResult($result['data']['split_bill_result'] ?? null); + $ids = []; + foreach ($items as $item) { + $receivers = $this->parseAcctSplitBunchList($item['acct_split_bunch_list'] ?? null); + foreach ($receivers as $r) { + $hid = trim((string) ($r['huifu_id'] ?? '')); + if ($hid !== '') { + $ids[$hid] = $hid; + } + } + } + return array_values($ids); + } + + /** + * @param string[] $receiverHuifuIds + * @return array{ok:bool,message:string,data:?array,dry_run?:bool,receivers?:string[]} + */ + public function submitSplitConfig(array $receiverHuifuIds, array $options = []) + { + $dryRun = !empty($options['dry_run']); + $mainHuifuId = trim((string) ($options['main_huifu_id'] ?? $this->resolveMainHuifuId())); + if ($mainHuifuId === '') { + throw new \InvalidArgumentException('缺少主商户 huifu_id'); + } + + $applyRatio = (string) ($options['apply_ratio'] ?? $this->resolveApplyRatio()); + $ruleOrigin = trim((string) ($options['rule_origin'] ?? '01')); + if (!in_array($ruleOrigin, ['01', '02'], true)) { + $ruleOrigin = '01'; + } + + $receiverHuifuIds = $this->normalizeReceiverIds($receiverHuifuIds, $mainHuifuId); + + $extend = [ + 'product_id' => trim((string) $this->config->get('product_id')), + 'rule_origin' => $ruleOrigin, + 'repeal_flag' => (string) ($options['repeal_flag'] ?? 'Y'), + 'refund_flag' => (string) ($options['refund_flag'] ?? 'Y'), + 'div_flag' => (string) ($options['div_flag'] ?? 'Y'), + 'apply_ratio' => $applyRatio, + 'start_type' => (string) ($options['start_type'] ?? '0'), + ]; + + $asyncUrl = trim((string) ($options['async_return_url'] ?? $this->config->get('split_config_async_url', ''))); + if ($asyncUrl !== '') { + $extend['async_return_url'] = $asyncUrl; + } + + if ($receiverHuifuIds !== []) { + $extend['acct_split_bunch_list'] = $this->encodeAcctSplitBunchList($receiverHuifuIds, $applyRatio); + } elseif ($ruleOrigin === '02') { + throw new \InvalidArgumentException('rule_origin=02 时必须提供分账接收方列表'); + } + + if ($dryRun) { + return [ + 'ok' => true, + 'message' => 'dry-run', + 'dry_run' => true, + 'receivers' => $receiverHuifuIds, + 'data' => ['extend' => $extend, 'huifu_id' => $mainHuifuId], + ]; + } + + $result = $this->client->submitMerchantSplitConfig($mainHuifuId, $extend); + Log::write('[shopro_huifu] split/config submit main=' . $mainHuifuId + . ' receivers=' . count($receiverHuifuIds) + . ' ok=' . ($result['ok'] ? '1' : '0') + . ' msg=' . ($result['message'] ?? ''), 'info'); + + return $result; + } + + /** + * 全量同步:合并远端 + 全部已进件子户,一次提交 split/config + * + * @return array{ok:bool,message:string,submitted:int,receivers:string[],remote:int,local:int} + */ + public function syncAllApprovedReceivers(array $options = []) + { + if (!$this->isEnabled()) { + throw new \RuntimeException('汇付插件未启用'); + } + + $main = $this->resolveMainHuifuId(); + $local = $this->collectApprovedReceiverHuifuIds(); + $remote = $this->fetchRemoteReceiverHuifuIds($main); + $merged = $this->normalizeReceiverIds(array_merge($remote, $local), $main); + + $submit = $this->submitSplitConfig($merged, $options); + $ok = !empty($submit['ok']) && empty($submit['dry_run']); + $respCode = is_array($submit['data'] ?? null) ? (string) ($submit['data']['resp_code'] ?? '') : ''; + $businessOk = $respCode === '' || $respCode === '00000000'; + + if ($ok && $businessOk && empty($options['dry_run'])) { + $this->markOnboardBindByHuifuIds($merged, 'ok', $submit['message'] ?? 'submitted'); + } elseif (!$ok && empty($options['dry_run'])) { + $this->markOnboardBindByHuifuIds($local, 'fail', $submit['message'] ?? 'submit failed'); + } + + return [ + 'ok' => !empty($submit['ok']) && ($businessOk || !empty($submit['dry_run'])), + 'message' => (string) ($submit['message'] ?? ''), + 'submitted' => count($merged), + 'receivers' => $merged, + 'remote' => count($remote), + 'local' => count($local), + 'resp_code' => $respCode, + 'data' => $submit['data'] ?? null, + ]; + } + + /** + * 单条进件通过后:合并远端列表 + 该子户,提交 split/config + */ + public function bindOnboardReceiver(Onboard $row, array $options = []) + { + if (!$this->isEnabled()) { + return ['ok' => false, 'message' => '汇付未启用', 'skipped' => true]; + } + if ($row['onboard_status'] !== Onboard::STATUS_APPROVED) { + return ['ok' => false, 'message' => '进件未通过', 'skipped' => true]; + } + $hid = trim((string) ($row['huifu_id'] ?? '')); + if ($hid === '') { + return ['ok' => false, 'message' => '无 huifu_id', 'skipped' => true]; + } + + $main = $this->resolveMainHuifuId(); + if ($hid === $main) { + $this->updateOnboardBindFields($row, 'skip', '与主商户相同,不作为分账接收方'); + return ['ok' => true, 'message' => 'skip main merchant', 'skipped' => true]; + } + + if (!$this->shouldAutoBindOnboard()) { + return ['ok' => false, 'message' => 'split_auto_bind_receiver=0', 'skipped' => true]; + } + + $remote = $this->fetchRemoteReceiverHuifuIds($main); + $local = $this->collectApprovedReceiverHuifuIds(); + $merged = $this->normalizeReceiverIds(array_merge($remote, $local, [$hid]), $main); + + try { + $submit = $this->submitSplitConfig($merged, $options); + } catch (\Throwable $e) { + $this->updateOnboardBindFields($row, 'fail', $e->getMessage()); + Log::error('[shopro_huifu] bind onboard split fail id=' . $row['id'] . ' ' . $e->getMessage()); + return ['ok' => false, 'message' => $e->getMessage()]; + } + + $respCode = is_array($submit['data'] ?? null) ? (string) ($submit['data']['resp_code'] ?? '') : ''; + $businessOk = !empty($submit['ok']) && ($respCode === '' || $respCode === '00000000'); + + if ($businessOk) { + $this->updateOnboardBindFields($row, 'ok', $submit['message'] ?? 'ok'); + } else { + $this->updateOnboardBindFields($row, 'fail', $submit['message'] ?? 'resp_code=' . $respCode); + } + + return [ + 'ok' => $businessOk, + 'message' => (string) ($submit['message'] ?? ''), + 'receivers' => $merged, + 'resp_code' => $respCode, + ]; + } + + public function shouldAutoBindOnboard() + { + $v = $this->config->get('split_auto_bind_receiver', '1'); + return (string) $v !== '0'; + } + + /** + * @param string[] $huifuIds + */ + protected function encodeAcctSplitBunchList(array $huifuIds, $feeRate) + { + $list = []; + foreach ($huifuIds as $hid) { + $list[] = [ + 'huifu_id' => $hid, + 'fee_rate' => $feeRate, + ]; + } + return json_encode($list, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * @param string[] $ids + * @return string[] + */ + protected function normalizeReceiverIds(array $ids, $mainHuifuId) + { + $mainHuifuId = trim((string) $mainHuifuId); + $out = []; + foreach ($ids as $id) { + $hid = trim((string) $id); + if ($hid === '' || $hid === $mainHuifuId) { + continue; + } + $out[$hid] = $hid; + } + ksort($out); + return array_values($out); + } + + protected function parseAcctSplitBunchList($raw) + { + if ($raw === null || $raw === '') { + return []; + } + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + return $decoded; + } + return is_array($raw) ? $raw : []; + } + + /** + * @param string[] $huifuIds + */ + protected function markOnboardBindByHuifuIds(array $huifuIds, $status, $message) + { + if ($huifuIds === []) { + return; + } + $now = time(); + $msg = mb_substr((string) $message, 0, 480); + try { + Db::name('shopro_huifu_onboard') + ->where('huifu_id', 'in', $huifuIds) + ->where('onboard_status', Onboard::STATUS_APPROVED) + ->update([ + 'split_bind_status' => $status, + 'split_bind_message' => $msg, + 'split_bind_time' => $now, + ]); + } catch (\Exception $e) { + // 未执行 upgrade 列时忽略 + } + } + + protected function updateOnboardBindFields(Onboard $row, $status, $message) + { + try { + $row->split_bind_status = $status; + $row->split_bind_message = mb_substr((string) $message, 0, 480); + $row->split_bind_time = time(); + $row->save(); + } catch (\Exception $e) { + // 列不存在时不阻断 + } + } +} diff --git a/addons/shopro_huifu/upgrade_huifu_split_bind.sql b/addons/shopro_huifu/upgrade_huifu_split_bind.sql new file mode 100644 index 0000000..dc13f69 --- /dev/null +++ b/addons/shopro_huifu/upgrade_huifu_split_bind.sql @@ -0,0 +1,9 @@ +-- 进件汇付分账接收方绑定状态(split/config 提交结果) +ALTER TABLE `__PREFIX__shopro_huifu_onboard` + ADD COLUMN `split_bind_status` varchar(20) DEFAULT NULL COMMENT 'ok|fail|skip|pending' AFTER `resp_snapshot`, + ADD COLUMN `split_bind_message` varchar(500) DEFAULT NULL COMMENT 'split/config 回执摘要' AFTER `split_bind_status`, + ADD COLUMN `split_bind_time` int(10) DEFAULT NULL COMMENT '最近绑定尝试时间' AFTER `split_bind_message`; + +INSERT IGNORE INTO `__PREFIX__shopro_huifu_config` (`name`, `value`, `group`, `description`) VALUES +('split_auto_bind_receiver', '1', 'split', '进件通过后自动调 v2/merchant/split/config 绑定分账接收方(0关1开)'), +('split_config_async_url', '', 'split', '分账配置异步通知 virgo:// 地址,可选'); diff --git a/application/command.php b/application/command.php index bec8bac..80da871 100644 --- a/application/command.php +++ b/application/command.php @@ -25,6 +25,7 @@ return [ 'addons\shopro_huifu\command\TestPayConnection', 'addons\shopro_huifu\command\ProbeWechatOnboarding', 'addons\shopro_huifu\command\QuerySplitConfig', + 'addons\shopro_huifu\command\SyncSplitReceivers', 'addons\shopro_huifu\command\DiagnoseFreezeFlow', 'addons\shopro_huifu\command\OnboardPoll', 'addons\shopro_huifu\command\OnboardAutoSubmit',