208 lines
9.3 KiB
PHP
208 lines
9.3 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | 企油通 · 运单油费「发放结果」状态机
|
||
// +----------------------------------------------------------------------
|
||
// | 7.7 运单油费下发是【异步】接口,受理成功不等于发放成功。拿到最终结果有两条路:
|
||
// | · 推:3.10 发放结果通知(一个批次一条,整批处理完后企油通主动推给我们)
|
||
// | · 拉:7.8 waybillBatch 按 batch_no 查询(兜底,见下)
|
||
// |
|
||
// | 两条路得出的结论必须完全一致,所以判定与状态跃迁都收敛在本类,
|
||
// | QytNotify(推)与 QytStatusQueue(拉)只负责把数据喂进来。
|
||
// |
|
||
// | 为什么有了推送还要留着拉:
|
||
// | 1. 未在企油通后台配置接收地址 -> 根本不推(且不视为失败、不补推)
|
||
// | 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);
|
||
}
|
||
}
|