根本不推(且不视为失败、不补推) // | 2. 推送失败重试 5 次后放弃(文档建议此时用 7.8 查) // | 3. 我们自己的接收端宕机 / 发版重启窗口期 // | 所以轮询保留为安全网,只是间隔放长(见 QytStatusQueue::intervalSql) // | // | 并发/幂等: // | 状态跃迁是条件 UPDATE(... WHERE oil_status_zsy=1),affected=1 才退款/回调, // | 因此「同一批次重复推送」「推送与轮询同时到达」都只会生效一次。 // +---------------------------------------------------------------------- declare (strict_types=1); namespace app\common\service; use think\facade\Db; class QytWaybill { // 明细级 handle_status(7.8 出参 / 3.10 通知 data.list[].handle_status) const HANDLE_PENDING = 1; const HANDLE_SUCCESS = 2; const HANDLE_FAILED = 3; // 批次级 status(7.8 出参 data.status / 3.10 通知 data.status) const BATCH_PENDING = 1; // 待处理 const BATCH_RUNNING = 2; // 处理中 const BATCH_ALL_OK = 3; // 全部成功 const BATCH_PART_OK = 4; // 部分成功 const BATCH_FAILED = 5; // 处理失败 // judge() 的结论 const R_SUCCESS = 'success'; const R_FAILED = 'failed'; const R_PENDING = 'pending'; /** * 纯函数:根据批次级 status 与本条明细,判定这一单的最终发放结果。 * * 文档 3.10 明确:**以批次级 status 为准**——批次因「企业信息不存在」之类 * 整体失败时,明细里可能仍然写着「待处理」。只看 handle_status 会让这种单 * 永远停在发放中,钱也永远不退。 * * @param int $batchStatus 批次级 status * @param array|null $row 本条明细 ['handle_status','error_msg','remark'],不存在传 null * @return array ['result' => success|failed|pending, 'text' => string] */ public static function judge(int $batchStatus, ?array $row): array { $handle = $row ? (int) ($row['handle_status'] ?? 0) : 0; // 1) 整批处理失败:不管明细写的是什么,这一单都拿不到钱 if ($batchStatus === self::BATCH_FAILED && $handle !== self::HANDLE_SUCCESS) { $msg = $row ? trim((string) ($row['error_msg'] ?? '')) : ''; return ['result' => self::R_FAILED, 'text' => $msg !== '' ? $msg : '企油通返回整批处理失败']; } // 2) 明细里没有这一条:批次还在排队,继续等 if (!$row) { return ['result' => self::R_PENDING, 'text' => '']; } if ($handle === self::HANDLE_SUCCESS) { return ['result' => self::R_SUCCESS, 'text' => trim((string) ($row['remark'] ?? '')) ?: '发放成功']; } if ($handle === self::HANDLE_FAILED) { return ['result' => self::R_FAILED, 'text' => trim((string) ($row['error_msg'] ?? '')) ?: '企油通发放失败']; } // 1 待处理,或未知值 -> 继续等 return ['result' => self::R_PENDING, 'text' => '']; } /** * 从 3.10 通知或 7.8 响应的 data.list 里挑出某条运单流水 * @return array|null ['handle_status','error_msg','remark'] */ public static function pick(array $data, string $waybillNoCode): ?array { return QytOil::pickWaybill($data, $waybillNoCode); } /** * 发放成功:条件跃迁 1 -> 2,胜出者才回调下游 / 同步小程序端 * * @param array $order partners_order 记录 * @param string $text 备注(上游 remark) * @param string $from 结果来源,只用于日志:'3.10 通知' / '7.8 轮询' * @return bool 是否由本次调用完成跃迁 */ public static function markSuccess(array $order, string $text, string $from = ''): bool { $affected = Db::name('partners_order') ->where('order_id', $order['order_id']) ->where('oil_status_zsy', 1) ->update([ 'oil_status' => 1, 'oil_status_zsy' => 2, 'oil_time' => time(), 'oil_text' => mb_substr($text, 0, 190), 'update_time' => time(), ]); if (!$affected) { return false; } MallOil::syncGoods($order, 'share_oil'); (new QueueService())->push('notify_queue', json_encode([ 'order_id' => $order['order_id'], 'type' => 'add', 'optType' => 'share_oil', 'retry_count' => 0, ], 320)); self::log("{$from} 确认发放成功 order={$order['order_id']} batch_no={$order['batch_no']}"); return true; } /** * 发放失败:条件跃迁 1 -> 3,胜出者才退款(保证只退一次) * * @return bool 是否由本次调用完成跃迁 */ public static function markFailed(array $order, string $reason, string $from = ''): bool { $affected = Db::name('partners_order') ->where('order_id', $order['order_id']) ->where('oil_status_zsy', 1) ->update([ 'oil_status_zsy' => 3, 'oil_text' => mb_substr('发放失败:' . $reason, 0, 190), 'update_time' => time(), ]); if (!$affected) { return false; } // 原路退款:合作商单回补合作商余额,商城单退回下单用户余额 MallOil::giveBack($order, (float) $order['total_price'], '发放失败'); MallOil::syncGoods($order, 'share_oil'); self::log("{$from} 确认发放失败 order={$order['order_id']} 原因={$reason},已原路退款 {$order['total_price']}"); return true; } /** * 按判定结果落状态。已经不在「发放中」的单会被安全忽略, * 但「已判失败又被告知成功」属于资损风险,必须告警而不是静默翻转。 * * @return string 实际发生的动作:success / failed / pending / skipped / conflict */ public static function apply(array $order, array $judged, string $from = ''): string { $current = (int) $order['oil_status_zsy']; if ($judged['result'] === self::R_PENDING) { return self::R_PENDING; } if ($current !== 1) { // 终态与上游结论不一致 -> 一律只告警、不自动翻转: // 3 -> 成功:我们已经退过款,钱却可能真发出去了(资损) // 2 -> 失败:我们已经通知下游发放成功、小程序端也显示可加油了 // 两种都要人工到企油通后台核对,自动改状态只会把问题放大。 $conflict = ($current === 3 && $judged['result'] === self::R_SUCCESS) || ($current === 2 && $judged['result'] === self::R_FAILED); if ($conflict) { self::log("【告警】order={$order['order_id']} batch_no={$order['batch_no']} 本地发放态为「" . ($current === 3 ? '发放失败(已退款)' : '发放成功(已通知下游)') . "」,但{$from}返回「" . ($judged['result'] === self::R_SUCCESS ? '发放成功' : '发放失败') . "」:{$judged['text']}。请人工到企油通后台核对,系统不会自动翻转状态。"); return 'conflict'; } return 'skipped'; } if ($judged['result'] === self::R_SUCCESS) { return self::markSuccess($order, $judged['text'], $from) ? self::R_SUCCESS : 'skipped'; } return self::markFailed($order, $judged['text'], $from) ? self::R_FAILED : 'skipped'; } /** * 记录本次通知时间与次数,便于排查「这单到底推没推过、推了几次」。 * 纯留痕,失败不能影响发放态跃迁(比如迁移脚本还没跑、少了这两列)。 */ public static function touchNotify(int $orderId, int $notifyTime): void { try { Db::name('partners_order')->where('order_id', $orderId)->update([ 'qyt_notify_time' => $notifyTime ?: time(), 'qyt_notify_num' => Db::raw('qyt_notify_num + 1'), ]); } catch (\Throwable $e) { self::log('3.10 通知留痕失败 order=' . $orderId . ':' . $e->getMessage() . '(不影响发放态,确认 qyt_migration_waybill_notify.sql 是否已执行)'); } } protected static function log(string $text): void { (new QytOil())->saveLog($text); } }