完成了基本调整调整

This commit is contained in:
2026-09-18 20:55:21 +08:00
parent 864187e105
commit 57e0854a0e
56 changed files with 3122 additions and 105 deletions
+524
View File
@@ -0,0 +1,524 @@
<?php
// +----------------------------------------------------------------------
// | 企油通 · 订单退款(7.17 获取支付凭证 + 7.16 司机额度调整)
// +----------------------------------------------------------------------
// | 规则(已与业务确认):退「剩余未加油金额」,每单只退一次
// | · 订单必须「发放成功」;一单可以分多次加油(7.10 逐笔累计到 used_amount)
// | · 退款金额 = 订单金额 − 已加油金额,固定不可指定;为 0 时不能退
// | · 额度在上游是按司机累计到凭证上的,退款只能「把凭证调低 X 元」。
// | 还要求凭证可用余额 ≥ 退款金额(额度可能被该司机其他订单用掉),否则直接拒绝。
// |
// | 状态(yoshop_partners_order.refund_status):
// | 0 未退款 -> 1 退款中 -> 2 已退款
// | -> 3 退款失败(上游明确拒绝 / 凭证不足,可重新发起)
// | -> 4 待人工确认(7.16 已发出但结果未知)
// |
// | ⚠️ 7.16 没有幂等键,同一请求发两次就调低两次。所以:
// | · 一旦走到 7.16,任何「不确定」都进 4,绝不自动重试
// | · 4 只能通过 confirm()(查上游证据)或 resolve()(人工判定)离开
// | · 退款单号写进 7.16 的 operation_reason,confirm() 优先在 7.13 流水里找它
// |
// | 并发:RedisLock 串行同一订单;状态跃迁是条件 UPDATE,affected=1 才退合作商余额。
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\service;
use app\common\lock\RedisLock;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use think\facade\Db;
class QytRefund
{
const NONE = 0;
const PROCESSING = 1;
const SUCCESS = 2;
const FAILED = 3;
const CONFIRM = 4;
const STATUS_TEXT = [
self::NONE => '未退款',
self::PROCESSING => '退款中',
self::SUCCESS => '已退款',
self::FAILED => '退款失败',
self::CONFIRM => '待人工确认',
];
// 失败分类,开放接口据此映射错误码
const REASON_NOT_FOUND = 'not_found';
const REASON_STATE = 'state';
const REASON_PROCESSING = 'processing';
const REASON_UPSTREAM = 'upstream';
/** 元金额比较容差 */
const EPS = 0.005;
// ================================================================
// 发起退款
// ================================================================
/**
* @param string $source admin / api
* @param string $operator 后台账号 或 合作商 appKey
*/
public static function apply(int $orderId, string $source, string $operator, string $reason = ''): array
{
$lock = new RedisLock('qyt:refund:' . $orderId, 120);
if (!$lock->acquireLock()) {
return self::result(false, self::PROCESSING, '该订单退款正在处理中,请勿重复提交', self::REASON_PROCESSING);
}
$order = null;
$refundId = 0;
$refundNo = '';
// before_adjust:还没调 7.16,出任何问题都可以安全地判失败
// after_adjust :7.16 已发出,出问题只能进人工确认
$stage = 'before_adjust';
try {
$order = Db::name('partners_order')->where('order_id', $orderId)->find();
if (!$order) {
return self::result(false, self::NONE, '订单不存在', self::REASON_NOT_FOUND);
}
$current = (int) $order['refund_status'];
if ($current === self::SUCCESS) {
// 幂等:已退过直接返回成功
return self::result(true, self::SUCCESS, '该订单已退款', '', self::orderData($order));
}
if ($current === self::PROCESSING || $current === self::CONFIRM) {
return self::result(false, $current,
$current === self::CONFIRM ? '该订单退款结果待人工确认,请勿重复发起' : '该订单退款正在处理中',
self::REASON_PROCESSING, self::orderData($order));
}
if (($err = self::checkRefundable($order)) !== '') {
return self::result(false, $current, $err, self::REASON_STATE, self::orderData($order));
}
$enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']);
if (!$enterprise) {
return self::result(false, $current, '本单绑定的企油通企业不存在,无法退款', self::REASON_STATE, self::orderData($order));
}
// 退款金额 = 剩余未加油金额(锁内重读订单后计算)
$amount = self::remainAmount($order);
// 抢占:只有 0/3 能进入退款中
$affected = Db::name('partners_order')
->where('order_id', $orderId)
->whereIn('refund_status', [self::NONE, self::FAILED])
->update(['refund_status' => self::PROCESSING, 'refund_text' => '退款中', 'update_time' => time()]);
if (!$affected) {
return self::result(false, $current, '订单状态已变化,请刷新后重试', self::REASON_PROCESSING, self::orderData($order));
}
$refundNo = 'RF' . $orderId . 'T' . date('YmdHis') . mt_rand(10, 99);
$refundId = (int) Db::name('qyt_refund')->insertGetId([
'refund_no' => $refundNo,
'order_id' => $orderId,
'partners_id' => (int) $order['partners_id'],
'qyt_enterprise_id' => (int) $order['qyt_enterprise_id'],
'source' => $source,
'operator' => mb_substr($operator, 0, 64),
'reason' => mb_substr($reason, 0, 200),
'amount' => $amount,
'status' => self::PROCESSING,
'store_id' => (int) $order['store_id'],
'create_time' => time(),
'update_time' => time(),
]);
$qyt = QytOil::forEnterprise($enterprise);
// 1) 7.17 找凭证(只读接口,失败不影响上游,可以放心判失败)
$vres = $qyt->getVoucherList((string) $order['mobile'], (string) $order['id_card'], 1, 50);
if ($vres['code'] !== '00') {
return self::finishFailed($order, $refundId, $refundNo,
'查询支付凭证失败:' . ($vres['message'] ?: '未知错误'), $vres);
}
$pick = self::pickVoucher((array) ($vres['data']['list'] ?? []), $amount);
if (!$pick['voucher']) {
return self::finishFailed($order, $refundId, $refundNo, $pick['message'], $vres);
}
$voucher = $pick['voucher'];
Db::name('qyt_refund')->where('refund_id', $refundId)->update([
'quota_id' => (string) $voucher['quota_id'],
'total_before' => round((float) ($voucher['total_amount'] ?? 0), 2),
'available_before' => round((float) ($voucher['available_amount'] ?? 0), 2),
'update_time' => time(),
]);
// 2) 7.16 调低额度
$stage = 'after_adjust';
$ares = $qyt->adjustVoucher(
$voucher['quota_id'],
'03',
$amount,
'订单退款 ' . $refundNo . ($reason !== '' ? ':' . $reason : ''),
(string) $order['waybill_no']
);
if (!empty($ares['transport_error'])) {
return self::finishConfirm($order, $refundId, $refundNo,
'退款请求已发出但结果未知(网络超时或响应异常),已转人工确认,切勿重复发起', $ares);
}
$success = $ares['code'] === '00' && !(isset($ares['data']['success']) && $ares['data']['success'] === false);
if (!$success) {
$msg = (string) ($ares['data']['message'] ?? '') ?: $ares['message'];
return self::finishFailed($order, $refundId, $refundNo, '企油通调额失败:' . ($msg ?: '未知错误'), $ares);
}
return self::finishSuccess($order, $refundId, $refundNo, $amount, '退款成功', $ares);
} catch (\Throwable $e) {
(new QytOil())->saveLog("退款异常 order={$orderId} stage={$stage}:" . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
if ($order && $refundId) {
return $stage === 'after_adjust'
? self::finishConfirm($order, $refundId, $refundNo, '退款过程发生异常且 7.16 可能已生效,已转人工确认:' . $e->getMessage(), [])
: self::finishFailed($order, $refundId, $refundNo, '退款过程发生异常:' . $e->getMessage(), []);
}
return self::result(false, self::NONE, '退款处理异常:' . $e->getMessage(), self::REASON_STATE);
} finally {
$lock->releaseLock();
}
}
// ================================================================
// 确认「待人工确认」的退款:向上游取证
// ================================================================
public static function confirm(int $orderId): array
{
$lock = new RedisLock('qyt:refund:' . $orderId, 120);
if (!$lock->acquireLock()) {
return self::result(false, self::CONFIRM, '该订单正在处理中,请稍后再试', self::REASON_PROCESSING);
}
try {
[$order, $refund, $enterprise, $err] = self::loadConfirmContext($orderId);
if ($err !== '') {
return self::result(false, (int) ($order['refund_status'] ?? self::NONE), $err, self::REASON_STATE);
}
$qyt = QytOil::forEnterprise($enterprise);
$amount = round((float) $refund['amount'], 2);
// 证据 1:7.13 钱包流水里带有退款单号的调额记录 —— 最可靠
$inWallet = false;
$wres = $qyt->driverWalletQuery((string) $order['mobile'], (string) $order['id_card'], 1, 50);
if ($wres['code'] === '00') {
foreach ((array) ($wres['data']['transactionDetails'] ?? []) as $t) {
if (strpos((string) ($t['remark'] ?? ''), (string) $refund['refund_no']) !== false) {
$inWallet = true;
break;
}
}
}
// 证据 2:7.17 同一张凭证的总额度 / 可用余额变化
$vres = $qyt->getVoucherList((string) $order['mobile'], (string) $order['id_card'], 1, 50);
$now = null;
if ($vres['code'] === '00') {
foreach ((array) ($vres['data']['list'] ?? []) as $v) {
if ((string) ($v['quota_id'] ?? '') === (string) $refund['quota_id']) {
$now = $v;
break;
}
}
}
if (!$inWallet && $vres['code'] !== '00') {
return self::result(false, self::CONFIRM, '暂时无法向企油通取证:' . ($vres['message'] ?: '查询失败') . ',请稍后再试', self::REASON_UPSTREAM);
}
$totalNow = $now ? round((float) ($now['total_amount'] ?? 0), 2) : null;
$availNow = $now ? round((float) ($now['available_amount'] ?? 0), 2) : null;
if ($now) {
Db::name('qyt_refund')->where('refund_id', $refund['refund_id'])->update([
'total_after' => $totalNow, 'available_after' => $availNow, 'update_time' => time(),
]);
}
// 退款发起前后 10 分钟起,该司机在本企业是否有其他单在发放 / 发放成功(会干扰额度比对)
$grantedSince = Db::name('partners_order')
->where('id_card', $order['id_card'])
->where('qyt_enterprise_id', $order['qyt_enterprise_id'])
->where('order_id', '<>', $order['order_id'])
->whereIn('oil_status_zsy', [1, 2])
->where('update_time', '>=', (int) $refund['create_time'] - 600)
->count() > 0;
$verdict = self::judgeConfirm(
$inWallet,
(float) $refund['total_before'], $totalNow,
(float) $refund['available_before'], $availNow,
$amount,
$grantedSince
);
if ($verdict === 'applied') {
return self::finishSuccess($order, (int) $refund['refund_id'], (string) $refund['refund_no'], $amount,
'人工确认:企油通已调低额度,退款成功', $vres);
}
if ($verdict === 'not_applied') {
return self::finishFailed($order, (int) $refund['refund_id'], (string) $refund['refund_no'],
'确认结果:企油通未调低额度,退款未生效,可重新发起', $vres);
}
return self::result(false, self::CONFIRM,
'上游数据无法自动判定(期间可能有新的发放或加油消费),请到企油通后台核对该司机凭证后使用「人工判定」',
self::REASON_UPSTREAM, self::orderData($order, (string) $refund['refund_no']));
} catch (\Throwable $e) {
return self::result(false, self::CONFIRM, '确认过程异常:' . $e->getMessage(), self::REASON_STATE);
} finally {
$lock->releaseLock();
}
}
/**
* 人工判定「待人工确认」的退款(运营到企油通后台核对后操作)
*/
public static function resolve(int $orderId, bool $success, string $operator, string $remark): array
{
if (trim($remark) === '') {
return self::result(false, self::CONFIRM, '请填写判定依据', self::REASON_STATE);
}
$lock = new RedisLock('qyt:refund:' . $orderId, 120);
if (!$lock->acquireLock()) {
return self::result(false, self::CONFIRM, '该订单正在处理中,请稍后再试', self::REASON_PROCESSING);
}
try {
[$order, $refund, , $err] = self::loadConfirmContext($orderId);
if ($err !== '') {
return self::result(false, (int) ($order['refund_status'] ?? self::NONE), $err, self::REASON_STATE);
}
$text = "人工判定({$operator}):" . mb_substr($remark, 0, 100);
return $success
? self::finishSuccess($order, (int) $refund['refund_id'], (string) $refund['refund_no'], (float) $refund['amount'], $text, [])
: self::finishFailed($order, (int) $refund['refund_id'], (string) $refund['refund_no'], $text, []);
} finally {
$lock->releaseLock();
}
}
// ================================================================
// 纯函数(无副作用,便于单测)
// ================================================================
/**
* 退款前置条件,返回空串表示可以退
*/
public static function checkRefundable(array $order): string
{
if ((int) $order['oil_status_zsy'] !== 2 || (int) $order['oil_status'] !== 1) {
return '仅「发放成功」的订单可以退款';
}
if (self::remainAmount($order) <= self::EPS) {
return '订单金额已全部加油,没有可退金额';
}
if (trim((string) $order['id_card']) === '') {
return '订单缺少司机身份证号,无法查询支付凭证';
}
return '';
}
/**
* 剩余未加油金额(元)= 订单金额 − 已加油金额,不小于 0。这就是可退金额
*/
public static function remainAmount(array $order): float
{
return max(0.0, round((float) ($order['total_price'] ?? 0) - (float) ($order['used_amount'] ?? 0), 2));
}
/**
* 从 7.17 凭证列表里挑一张用于调低额度的凭证
* 规则:只看生效中的凭证,取可用余额最大的一张;它不够就拒绝(整单全额退,不拆多张凭证)
* @return array ['voucher' => array|null, 'message' => string]
*/
public static function pickVoucher(array $list, float $amount): array
{
$active = array_values(array_filter($list, function ($v) {
return is_array($v) && (string) ($v['status'] ?? '') === 'active';
}));
if (!$active) {
return ['voucher' => null, 'message' => '该司机在本企业下没有生效中的支付凭证,无法退款'];
}
usort($active, function ($a, $b) {
return (float) ($b['available_amount'] ?? 0) <=> (float) ($a['available_amount'] ?? 0);
});
$top = $active[0];
$avail = round((float) ($top['available_amount'] ?? 0), 2);
if ($avail + self::EPS < $amount) {
return ['voucher' => null, 'message' => sprintf(
'司机凭证可用余额不足:可用 %.2f 元,需退 %.2f 元(凭证额度按司机累计,可能已被该司机其他订单加油用掉)', $avail, $amount)];
}
return ['voucher' => $top, 'message' => ''];
}
/**
* 根据上游证据判定 7.16 是否已生效
* · 7.13 流水里找到退款单号 -> applied
* · 凭证总额度恰好减少了退款金额 -> applied
* (加油消费只动可用/已用,不动总额度;调额才动总额度)
* · 总额度没变 且 可用余额没有下降 -> not_applied
* · 其余(凭证找不到、期间有新发放或消费导致对不上) -> unknown,交人工
* @return string applied / not_applied / unknown
*/
public static function judgeConfirm(bool $inWallet, float $totalBefore, ?float $totalNow,
float $availBefore, ?float $availNow, float $amount,
bool $grantedSince = false): string
{
if ($inWallet) {
return 'applied';
}
if ($totalNow === null || $availNow === null) {
return 'unknown';
}
// $grantedSince:退款发起前后该司机在本企业有新的发放。此时额度比对不可信——
// 「调低 100 + 新发 100」看起来就是没变,若据此判「未生效」,运营重新发起会重复扣减。
if (abs(($totalBefore - $totalNow) - $amount) <= self::EPS) {
return $grantedSince ? 'unknown' : 'applied';
}
if (abs($totalNow - $totalBefore) <= self::EPS && $availNow + self::EPS >= $availBefore) {
return $grantedSince ? 'unknown' : 'not_applied';
}
return 'unknown';
}
// ================================================================
// 内部
// ================================================================
protected static function finishSuccess(array $order, int $refundId, string $refundNo, float $amount, string $text, array $res): array
{
$amount = round($amount, 2);
$won = false;
Db::transaction(function () use ($order, $refundId, $amount, $text, $res, &$won) {
$won = (bool) Db::name('partners_order')
->where('order_id', $order['order_id'])
->whereIn('refund_status', [self::PROCESSING, self::CONFIRM])
->update([
'refund_status' => self::SUCCESS,
'refund_amount' => $amount,
'refund_time' => time(),
'refund_text' => mb_substr($text, 0, 250),
'update_time' => time(),
]);
Db::name('qyt_refund')->where('refund_id', $refundId)->update([
'status' => self::SUCCESS,
'message' => mb_substr($text, 0, 250),
'response' => self::dumpRes($res),
'update_time' => time(),
]);
// 只有状态跃迁胜出者才回补合作商余额,保证只退一次
if ($won) {
PartnersBalance::refund((int) $order['partners_id'], $amount, (string) $order['partners_order_no'],
(int) $order['order_id'], (int) $order['store_id']);
}
});
if ($won) {
// 退款计算与加油推送之间有时间差:退款期间若又来了加油,已加油 + 已退款可能超过订单金额,告警人工核对
$fresh = Db::name('partners_order')->where('order_id', $order['order_id'])->field('total_price,used_amount')->find();
if ($fresh && (float) $fresh['used_amount'] + $amount > (float) $fresh['total_price'] + self::EPS) {
(new QytOil())->saveLog("【告警】退款后 已加油+已退款 超出订单金额 order={$order['order_id']}"
. " total={$fresh['total_price']} used={$fresh['used_amount']} refund={$amount}");
}
(new QueueService())->push('notify_queue', json_encode([
'order_id' => $order['order_id'],
'optType' => 'refund',
'retry_count' => 0,
], 320));
(new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已回补合作商余额");
}
$order['refund_status'] = self::SUCCESS;
$order['refund_amount'] = $amount;
return self::result(true, self::SUCCESS, $text, '', self::orderData($order, $refundNo));
}
protected static function finishFailed(array $order, int $refundId, string $refundNo, string $text, array $res): array
{
Db::name('partners_order')
->where('order_id', $order['order_id'])
->whereIn('refund_status', [self::PROCESSING, self::CONFIRM])
->update(['refund_status' => self::FAILED, 'refund_text' => mb_substr($text, 0, 250), 'update_time' => time()]);
Db::name('qyt_refund')->where('refund_id', $refundId)->update([
'status' => self::FAILED, 'message' => mb_substr($text, 0, 250), 'response' => self::dumpRes($res), 'update_time' => time(),
]);
(new QytOil())->saveLog("退款失败 order={$order['order_id']} refund_no={$refundNo}:{$text}");
$order['refund_status'] = self::FAILED;
return self::result(false, self::FAILED, $text, self::REASON_UPSTREAM, self::orderData($order, $refundNo));
}
protected static function finishConfirm(array $order, int $refundId, string $refundNo, string $text, array $res): array
{
Db::name('partners_order')
->where('order_id', $order['order_id'])
->where('refund_status', self::PROCESSING)
->update(['refund_status' => self::CONFIRM, 'refund_text' => mb_substr($text, 0, 250), 'update_time' => time()]);
Db::name('qyt_refund')->where('refund_id', $refundId)->update([
'status' => self::CONFIRM, 'message' => mb_substr($text, 0, 250), 'response' => self::dumpRes($res), 'update_time' => time(),
]);
(new QytOil())->saveLog("【告警】退款结果未知 order={$order['order_id']} refund_no={$refundNo}:{$text}");
$order['refund_status'] = self::CONFIRM;
return self::result(false, self::CONFIRM, $text, self::REASON_PROCESSING, self::orderData($order, $refundNo));
}
/**
* @return array [order, refund, enterprise, errorMessage]
*/
protected static function loadConfirmContext(int $orderId): array
{
$order = Db::name('partners_order')->where('order_id', $orderId)->find();
if (!$order) {
return [[], [], null, '订单不存在'];
}
if ((int) $order['refund_status'] !== self::CONFIRM) {
return [$order, [], null, '只有「待人工确认」的退款需要确认,当前状态:' . (self::STATUS_TEXT[(int) $order['refund_status']] ?? '')];
}
$refund = Db::name('qyt_refund')->where('order_id', $orderId)->where('status', self::CONFIRM)
->order('refund_id', 'desc')->find();
if (!$refund) {
return [$order, [], null, '未找到待确认的退款流水'];
}
$enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']);
if (!$enterprise) {
return [$order, $refund, null, '本单绑定的企油通企业不存在'];
}
return [$order, $refund, $enterprise, ''];
}
protected static function orderData(array $order, string $refundNo = ''): array
{
if ($refundNo === '' && !empty($order['order_id'])) {
$refundNo = (string) Db::name('qyt_refund')->where('order_id', $order['order_id'])
->order('refund_id', 'desc')->value('refund_no');
}
return [
'oilCardNo' => (string) ($order['oilCardNo'] ?? ''),
'saleOrderNo' => (string) ($order['partners_order_no'] ?? ''),
'refundNo' => $refundNo,
// 已退款:实际退款金额;其余:当前剩余未加油金额(即本次会退的金额)
'refundAmount' => number_format((int) ($order['refund_status'] ?? 0) === self::SUCCESS
? (float) ($order['refund_amount'] ?? 0)
: self::remainAmount($order), 2, '.', ''),
'usedAmount' => number_format((float) ($order['used_amount'] ?? 0), 2, '.', ''),
'refundStatus' => (int) ($order['refund_status'] ?? 0),
];
}
protected static function result(bool $ok, int $status, string $message, string $reason = '', array $data = []): array
{
return [
'ok' => $ok,
'status' => $status,
'status_text' => self::STATUS_TEXT[$status] ?? '',
'message' => $message,
'reason' => $reason,
'data' => $data,
];
}
protected static function dumpRes(array $res): string
{
if (!$res) {
return '';
}
$keep = ['code' => $res['code'] ?? '', 'message' => $res['message'] ?? '',
'transport_error' => !empty($res['transport_error']), 'data' => QytOil::maskDeep($res['data'] ?? [])];
return mb_substr((string) json_encode($keep, JSON_UNESCAPED_UNICODE), 0, 60000);
}
}