Files
oil/server-tp6/app/common/service/QytRefund.php
T
2026-09-18 20:55:21 +08:00

525 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}