From 0d1256a6791e876ad53a180a5b907ce5bfabba0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E4=B9=90=E5=8A=A0=E5=86=B0?= <11869599+addie66@user.noreply.gitee.com> Date: Fri, 23 Oct 2026 15:12:47 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(shopro):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B1=87=E4=BB=98=E5=BB=B6=E6=97=B6=E5=88=86=E8=B4=A6=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 huifu_delay_reconcile.sql 对账脚本,包含多个维度的分账核对查询 - 在 split_commission_verify.php 中添加 audit-delay 命令,支持实时查询汇付未确认金额 - 实现递归解析汇付查单响应的功能,支持嵌套JSON数据提取 - 提供延时分账订单审计功能,可对比本地状态与汇付侧 unconfirm_amt - 支持批量诊断延时分账待确认订单,便于排查资金卡顿问题 ``` --- addons/shopro/tests/huifu_delay_reconcile.sql | 150 ++++++++++++++++++ .../shopro/tests/split_commission_verify.php | 139 +++++++++++++++- 2 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 addons/shopro/tests/huifu_delay_reconcile.sql diff --git a/addons/shopro/tests/huifu_delay_reconcile.sql b/addons/shopro/tests/huifu_delay_reconcile.sql new file mode 100644 index 0000000..7d7d61c --- /dev/null +++ b/addons/shopro/tests/huifu_delay_reconcile.sql @@ -0,0 +1,150 @@ +-- 汇付「延时分账待交易确认」与商城分账记录对账 +-- 用法:在 MySQL 执行(表前缀 fa_ 请按实际修改) +-- 图1 汇付控台「商品描述=商城订单{order_sn}」可与 order_sn 直接对应 + +SELECT '=== 1. 当前分账配置(是否支付即分账)===' AS section; +SELECT name, value, description +FROM fa_shopro_split_config +WHERE name IN ( + 'split_enable', 'split_mode', 'split_provider', 'split_timing', + 'split_amount_source', 'delay_acct_enable', 'platform_huifu_id' +); + +-- 汇付控台截图中的 8 笔订单号(可按需增删) +SET @huifu_pending_sns = '202605495163454091004900,202609215157744091003800,202604274596990091003800,202606500438103091004500,202608341834105091004400,202607585392320091004300,202602490038750091003800,202602385010837091001400'; + +SELECT '=== 2. 汇付待确认列表 vs 商城分账/支付扩展(核心对账)===' AS section; + +SELECT + o.id AS order_id, + o.order_sn, + o.status AS order_status, + o.pay_fee, + FROM_UNIXTIME(o.paytime) AS pay_time, + FROM_UNIXTIME( + NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(o.ext, '$.confirm_time')) AS UNSIGNED), 0) + ) AS buyer_confirm_time, + pe.delay_acct_flag, + pe.confirm_status, + pe.confirm_hf_seq_id, + pe.hf_seq_id, + so.id AS split_order_id, + so.status AS split_status, + CASE so.status + WHEN 0 THEN '待分账' + WHEN 1 THEN '分账中' + WHEN 2 THEN '成功' + WHEN 3 THEN '部分成功' + WHEN 4 THEN '失败' + ELSE IF(so.id IS NULL, '无分账记录', so.status) + END AS split_status_label, + so.split_source, + so.split_amount, + LEFT(so.wx_result, 160) AS split_fail_hint, + CASE + WHEN pe.delay_acct_flag IS NULL OR pe.delay_acct_flag = '' THEN '⚠ 无 pay_ext 或未记录 delay 标记' + WHEN UPPER(pe.delay_acct_flag) <> 'Y' THEN '✗ 非延时分账单,控台待确认需单独核对' + WHEN pe.confirm_status = 'S' OR so.status = 2 THEN '✓ 已交易确认/分账成功' + WHEN so.id IS NULL THEN '✗ 从未生成分账记录(常见:split_timing≠当前订单状态,或支付时未开分账)' + WHEN so.status = 4 THEN '✗ 分账失败,未 confirm 成功(资金仍可能卡在汇付)' + WHEN so.status IN (0, 1) THEN '… 分账处理中' + ELSE '请人工核对' + END AS reconcile_hint +FROM fa_shopro_order o +LEFT JOIN fa_shopro_huifu_pay_ext pe ON pe.order_id = o.id +LEFT JOIN ( + SELECT t.* + FROM fa_shopro_split_order t + INNER JOIN ( + SELECT order_id, MAX(id) AS max_id + FROM fa_shopro_split_order + WHERE channel = 'huifu' + GROUP BY order_id + ) x ON x.max_id = t.id +) so ON so.order_id = o.id +WHERE FIND_IN_SET(o.order_sn, REPLACE(@huifu_pending_sns, ' ', '')) +ORDER BY o.paytime DESC; + +SELECT '=== 3. 近90天:延时分账(Y)但未 confirm 成功(与控台待释放资金相关)===' AS section; + +SELECT + o.id AS order_id, + o.order_sn, + o.pay_fee, + FROM_UNIXTIME(o.paytime) AS pay_time, + pe.confirm_status, + pe.confirm_hf_seq_id, + IFNULL(so.status, -1) AS split_status, + LEFT(so.wx_result, 120) AS fail_msg +FROM fa_shopro_order o +INNER JOIN fa_shopro_huifu_pay_ext pe ON pe.order_id = o.id +LEFT JOIN ( + SELECT order_id, MAX(status) AS status, MAX(wx_result) AS wx_result + FROM fa_shopro_split_order WHERE channel = 'huifu' GROUP BY order_id +) so ON so.order_id = o.id +WHERE o.pay_type = 'huifu_wechat' + AND o.paytime >= UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 90 DAY)) + AND UPPER(IFNULL(pe.delay_acct_flag, '')) = 'Y' + AND IFNULL(pe.confirm_status, '') <> 'S' + AND IFNULL(so.status, 0) <> 2 +ORDER BY o.paytime DESC +LIMIT 100; + +SELECT '=== 4. 有延时分账标记但从未出现分账记录(高概率未触发 split)===' AS section; + +SELECT + o.id, + o.order_sn, + FROM_UNIXTIME(o.paytime) AS pay_time, + o.status AS order_status, + pe.delay_acct_flag, + (SELECT value FROM fa_shopro_split_config WHERE name = 'split_timing' LIMIT 1) AS split_timing +FROM fa_shopro_order o +INNER JOIN fa_shopro_huifu_pay_ext pe ON pe.order_id = o.id +LEFT JOIN fa_shopro_split_order so ON so.order_id = o.id AND so.channel = 'huifu' +WHERE o.pay_type = 'huifu_wechat' + AND o.paytime >= UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 90 DAY)) + AND UPPER(pe.delay_acct_flag) = 'Y' + AND so.id IS NULL +ORDER BY o.id DESC +LIMIT 50; + +SELECT '=== 5. 分账失败(status=4) 汇付延时分账单 ===' AS section; + +SELECT + so.order_id, + so.order_sn, + so.split_amount, + so.retry_count, + FROM_UNIXTIME(so.createtime) AS split_at, + LEFT(so.wx_result, 200) AS fail_reason +FROM fa_shopro_split_order so +INNER JOIN fa_shopro_order o ON o.id = so.order_id +INNER JOIN fa_shopro_huifu_pay_ext pe ON pe.order_id = o.id +WHERE so.channel = 'huifu' + AND so.status = 4 + AND UPPER(IFNULL(pe.delay_acct_flag, '')) = 'Y' +ORDER BY so.id DESC +LIMIT 50; + +SELECT '=== 6. 本地标记已 confirm 但可能未真正释放(需 CLI audit-delay --live 核实)===' AS section; + +SELECT + o.order_sn, + o.pay_fee, + pe.confirm_status, + pe.confirm_hf_seq_id, + so.status AS split_status, + so.confirm_hf_seq_id AS split_confirm_seq, + FROM_UNIXTIME(so.createtime) AS split_at +FROM fa_shopro_order o +INNER JOIN fa_shopro_huifu_pay_ext pe ON pe.order_id = o.id +LEFT JOIN ( + SELECT order_id, MAX(id) AS max_id FROM fa_shopro_split_order WHERE channel = 'huifu' GROUP BY order_id +) x ON x.order_id = o.id +LEFT JOIN fa_shopro_split_order so ON so.id = x.max_id +WHERE o.pay_type = 'huifu_wechat' + AND UPPER(pe.delay_acct_flag) = 'Y' + AND (pe.confirm_status = 'S' OR so.status = 2) +ORDER BY o.paytime DESC +LIMIT 50; diff --git a/addons/shopro/tests/split_commission_verify.php b/addons/shopro/tests/split_commission_verify.php index 0105a2e..bc68534 100644 --- a/addons/shopro/tests/split_commission_verify.php +++ b/addons/shopro/tests/split_commission_verify.php @@ -10,6 +10,7 @@ * php addons/shopro/tests/split_commission_verify.php diagnose-missing [limit=20] * php addons/shopro/tests/split_commission_verify.php try-split {order_sn} --execute # 补跑汇付分账(慎用) * php addons/shopro/tests/split_commission_verify.php try-split {order_sn} --confirm-only # 仅延时分账确认、不分账(排查22000002) + * php addons/shopro/tests/split_commission_verify.php audit-delay [limit=30] [--live] # 延迟户对账:本地+可选汇付查单 unconfirm_amt */ if (PHP_SAPI !== 'cli') { @@ -62,9 +63,14 @@ switch ($command) { $confirmOnly = in_array('--confirm-only', $argv, true); trySplitOrder($sn, $execute, $confirmOnly); break; + case 'audit-delay': + $limit = max(1, min(200, intval($argv[2] ?? 30))); + $live = in_array('--live', $argv, true); + auditDelayUnconfirm($limit, $live); + break; default: echo "未知命令: {$command}\n"; - echo "可用: summary | anomalies | order {order_sn} | recent-success [limit] | diagnose-missing [limit] | try-split {order_sn} [--execute|--confirm-only]\n"; + echo "可用: summary | anomalies | order {order_sn} | recent-success [limit] | diagnose-missing [limit] | try-split {order_sn} [--execute|--confirm-only] | audit-delay [limit] [--live]\n"; exit(1); } @@ -718,6 +724,137 @@ function printOrderDetail($orderSn) } } +/** + * 延迟专户余额排查:列出 delay_acct=Y 订单,可选 live 查汇付 unconfirm_amt + */ +/** 从汇付查单响应(可能嵌套 JSON 字符串)取字段 */ +function huifuQueryFieldRecursive($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 huifuQueryFieldRecursive($decoded, $key); + } + } + return null; + } + if (!is_array($data)) { + return null; + } + if (array_key_exists($key, $data)) { + return $data[$key]; + } + foreach ($data as $v) { + $found = huifuQueryFieldRecursive($v, $key); + if ($found !== null && $found !== '') { + return $found; + } + } + return null; +} + +function auditDelayUnconfirm($limit, $live) +{ + $since = time() - 180 * 86400; + $peFields = 'o.id, o.order_sn, o.pay_fee, o.paytime, o.status, pe.confirm_status, pe.confirm_hf_seq_id'; + if (tableHasColumn('shopro_huifu_pay_ext', 'split_timing_at_pay')) { + $peFields .= ', pe.split_timing_at_pay'; + } + $rows = Db::name('shopro_order')->alias('o') + ->join('shopro_huifu_pay_ext pe', 'pe.order_id = o.id') + ->where('o.pay_type', 'huifu_wechat') + ->where('o.paytime', '>=', $since) + ->whereRaw("UPPER(IFNULL(pe.delay_acct_flag, '')) = 'Y'") + ->field($peFields) + ->order('o.paytime', 'desc') + ->limit($limit) + ->select(); + + echo "========================================\n"; + echo " 延时分账订单审计(最多 {$limit} 笔,近180天 delay_acct=Y)\n"; + echo " --live 会调汇付支付查单,以 unconfirm_amt 为准\n"; + echo "========================================\n\n"; + + $querySvc = null; + if ($live && class_exists('\\addons\\shopro_huifu\\library\\HuifuQueryService')) { + $querySvc = new \addons\shopro_huifu\library\HuifuQueryService(); + } elseif ($live) { + echo "⚠ 未安装 shopro_huifu,忽略 --live\n\n"; + } + + $sumUnconfirm = '0.00'; + $pendingCount = 0; + + foreach ($rows as $row) { + $oid = intval($row['id']); + $split = Db::name('shopro_split_order') + ->where('order_id', $oid) + ->where('channel', 'huifu') + ->order('id', 'desc') + ->find(); + + $splitStatus = $split ? intval($split['status']) : -1; + $splitLabel = $split ? (['0' => '待', '1' => '中', '2' => '成功', '3' => '部分', '4' => '失败'][$splitStatus] ?? $splitStatus) : '无记录'; + + $unconfirm = null; + $remoteDelay = null; + $freeze = null; + $confirmResp = null; + if ($querySvc) { + try { + $q = $querySvc->queryByOrderSn($row['order_sn']); + $unconfirm = huifuQueryFieldRecursive($q, 'unconfirm_amt'); + $remoteDelay = huifuQueryFieldRecursive($q, 'delay_acct_flag'); + $freeze = huifuQueryFieldRecursive($q, 'fund_freeze_stat'); + if ($unconfirm !== null && $unconfirm !== '' && bccomp((string) $unconfirm, '0.00', 2) > 0) { + $sumUnconfirm = bcadd($sumUnconfirm, number_format((float) $unconfirm, 2, '.', ''), 2); + $pendingCount++; + } + } catch (\Throwable $e) { + $confirmResp = '查单失败: ' . $e->getMessage(); + } + } + + $localClaimOk = ($row['confirm_status'] === 'S' || $splitStatus === 2); + $flagMismatch = false; + if ($unconfirm !== null && $unconfirm !== '' && bccomp((string) $unconfirm, '0.00', 2) > 0 && $localClaimOk) { + $flagMismatch = true; + } + + echo str_repeat('-', 72) . "\n"; + echo "order_sn: {$row['order_sn']} id={$oid} pay_fee={$row['pay_fee']}\n"; + $timingAtPay = isset($row['split_timing_at_pay']) ? $row['split_timing_at_pay'] : '-'; + echo 'pay_time: ' . date('Y-m-d H:i:s', $row['paytime']) . " order_status={$row['status']} split_timing_at_pay={$timingAtPay}\n"; + echo "本地: confirm_status=" . ($row['confirm_status'] ?: 'NULL') . " confirm_hf_seq_id=" . ($row['confirm_hf_seq_id'] ?: '-') . "\n"; + echo "本地分账: {$splitLabel}" . ($split ? " split_amount={$split['split_amount']} id={$split['id']}" : '') . "\n"; + if ($querySvc && $confirmResp === null) { + echo "汇付查单: delay_acct_flag={$remoteDelay} unconfirm_amt={$unconfirm} fund_freeze_stat={$freeze}\n"; + } elseif ($confirmResp) { + echo "{$confirmResp}\n"; + } + if ($flagMismatch) { + echo ">>> ⚠ 本地显示已确认/分账成功,但汇付仍有 unconfirm_amt(符合汇付说的「返回链路/终态未对齐」)\n"; + } elseif ($splitLabel === '无记录') { + echo ">>> ⚠ 从未分账:多为 split_timing=confirm 且未确认收货,或当时未触发分账\n"; + } elseif ($splitStatus === 4) { + echo ">>> ⚠ 分账失败,资金可能仍在延迟户\n"; + } elseif ($unconfirm !== null && bccomp((string) $unconfirm, '0.00', 2) > 0) { + echo ">>> ⚠ 汇付侧仍有待确认金额\n"; + } + } + + echo "\n========================================\n"; + if ($querySvc) { + echo "汇付查单汇总: 仍有 unconfirm_amt>0 的订单 {$pendingCount} 笔,合计约 ¥{$sumUnconfirm}\n"; + echo "(可与控台「延迟户」余额对照;内扣手续费后单笔待确认略小于 pay_fee)\n"; + } else { + echo "加 --live 调汇付查单 unconfirm_amt,才能精确对应延迟户余额。\n"; + } + echo "处理: 无分账记录 → 确认收货或 try-split --execute;仅释款 → try-split --confirm-only\n"; +} + function printRecentSuccess($limit) { $rows = Db::name('shopro_split_order')->alias('so')