完成了基本调整调整

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
+21 -2
View File
@@ -108,8 +108,27 @@ class Qyt
$params['sign'] = $this->md5Sha1RsaSign($params);
// 文档示例形如 https://paytaskpage.worknoya.vip/#/?appId=..&mobile=..&timestamp=..&sign=..#/
return $base . '/?' . http_build_query($params) . '#/';
// 最终形如 https://paytaskpage.worknoya.vip/#/?appId=..&mobile=..&timestamp=..&sign=iwxE****g==
//
// ⚠️ 两个坑,都是实测踩出来的,改动前务必看完:
//
// 1) 不能用 http_build_query:它会把 base64 签名里的 + / = 编码成 %2B %2F %3D。
// 参数挂在 # 之后的 fragment 里,浏览器不会自动解码,落地页若不做
// decodeURIComponent,拿到的签名串就和我们签的不一致 -> 验签失败。
// 文档示例里末尾是裸的 ==,对方期望的就是未编码的原始值。
// appId / mobile / timestamp / creditcode 都是字母数字,本来也无需编码。
// 若对接人日后确认页面会解码,把 h5_sign_urlencode 配成 true 即可切回。
//
// 2) 末尾【不能】再拼 '#/'。文档示例末尾是有 #/ 的,但实测:带上之后
// 桌面浏览器能打开,微信里打开 / 扫码一律提示验签失败 —— 落地页会把
// 这个 #/ 一起算进最后一个参数 sign 的值里。去掉后微信正常。
// ⚠️ 别照着文档示例把 '#/' 加回来。
$encode = !empty($this->conf['h5_sign_urlencode']);
$pairs = [];
foreach ($params as $k => $v) {
$pairs[] = $k . '=' . ($encode ? rawurlencode((string) $v) : (string) $v);
}
return $base . '/?' . implode('&', $pairs);
}
// ================================================================
@@ -0,0 +1,96 @@
<?php
// +----------------------------------------------------------------------
// | 企油通 · 司机档案
// +----------------------------------------------------------------------
// | 按身份证号唯一。上游 7.12 / 7.13 / 7.17 都以「手机号 + 身份证号」定位司机,
// | 7.7 下发前会做实名二要素核验,所以本地提前把会导致核验失败的数据标出来:
// | · 同一身份证号出现不同姓名
// | · 同一手机号对应多个身份证号
// | · 同一身份证号换了手机号
// | 累计发放 / 退款 / 订单数不存冗余,列表查询时从订单实时汇总,避免对不上账。
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\service;
use think\facade\Db;
class QytDriver
{
/**
* 下单成功后更新司机档案。任何异常都吞掉 —— 档案是辅助数据,不能影响建单
*/
public static function touch(array $order): void
{
$idCard = strtoupper(trim((string) ($order['id_card'] ?? '')));
if ($idCard === '') {
return;
}
$name = trim((string) ($order['driver_name'] ?? ''));
$mobile = trim((string) ($order['mobile'] ?? ''));
$storeId = (int) ($order['store_id'] ?? 0);
$now = time();
try {
$row = Db::name('qyt_driver')->where('store_id', $storeId)->where('id_card', $idCard)->find();
if (!$row) {
try {
Db::name('qyt_driver')->insert([
'id_card' => $idCard, 'name' => $name, 'mobile' => $mobile,
'last_order_time' => $now, 'store_id' => $storeId, 'create_time' => $now, 'update_time' => $now,
]);
} catch (\Throwable $e) {
if (strpos($e->getMessage(), 'Duplicate') === false) {
throw $e;
}
}
$row = Db::name('qyt_driver')->where('store_id', $storeId)->where('id_card', $idCard)->find();
}
$notes = self::detectConflicts($row, $name, $mobile);
// 同一手机号挂在别的身份证号上:两边都标记
if ($mobile !== '') {
$others = Db::name('qyt_driver')
->where('store_id', $storeId)->where('mobile', $mobile)->where('id_card', '<>', $idCard)
->column('driver_id');
if ($others) {
$notes[] = '同一手机号对应多个身份证号';
Db::name('qyt_driver')->whereIn('driver_id', $others)
->update(['conflict' => 1, 'conflict_text' => '同一手机号对应多个身份证号', 'update_time' => $now]);
}
}
$update = [
'name' => $name !== '' ? $name : $row['name'],
'mobile' => $mobile !== '' ? $mobile : $row['mobile'],
'last_order_time' => $now,
'update_time' => $now,
];
if ($notes) {
$update['conflict'] = 1;
$update['conflict_text'] = mb_substr(implode(';', array_unique($notes)), 0, 250);
}
Db::name('qyt_driver')->where('driver_id', $row['driver_id'])->update($update);
} catch (\Throwable $e) {
(new QytOil())->saveLog('司机档案更新失败 order=' . ($order['order_id'] ?? '') . ':' . $e->getMessage());
}
}
/**
* 纯函数:对比已有档案与本次下单的姓名 / 手机号
*/
public static function detectConflicts(array $row, string $name, string $mobile): array
{
$notes = [];
$oldName = (string) ($row['name'] ?? '');
$oldMobile = (string) ($row['mobile'] ?? '');
if ($oldName !== '' && $name !== '' && $oldName !== $name) {
$notes[] = "同一身份证号出现不同姓名:{$oldName} / {$name}";
}
if ($oldMobile !== '' && $mobile !== '' && $oldMobile !== $mobile) {
$notes[] = '手机号变更:' . QytOil::maskMobile($oldMobile) . ' → ' . QytOil::maskMobile($mobile);
}
return $notes;
}
}
@@ -0,0 +1,200 @@
<?php
// +----------------------------------------------------------------------
// | 企油通 · 企业发票(7.14)
// +----------------------------------------------------------------------
// | 7.14 的粒度是「企业 + 通道 + 月份(+ 司机手机号)」,返回的 invoice 只是一组地址,
// | 和具体订单之间【没有】对应关系。所以:
// | · 表按这个维度存(yoshop_qyt_invoice)
// | · 订单按「订单企业 + 消费月份 + 司机手机号」去关联 —— 同一司机当月多笔订单看到的是同一批发票
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\service;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use think\facade\Db;
class QytInvoice
{
/** 已同步但暂无发票的记录,超过多久再次点开时自动重新同步(秒) */
const RESYNC_EMPTY_AFTER = 3600;
/**
* 调 7.14 同步某个维度的发票并入库
* @param EnterpriseModel|array $enterprise
* @param string $month YYYY-MM
* @param string $mobile 司机手机号,空串=企业全量
* @return array ['ok'=>bool, 'message'=>string, 'row'=>array]
*/
public static function sync($enterprise, string $month, string $mobile = ''): array
{
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
return ['ok' => false, 'message' => '月份格式应为 YYYY-MM', 'row' => []];
}
if ($mobile !== '' && !preg_match('/^1[3-9]\d{9}$/', $mobile)) {
return ['ok' => false, 'message' => '司机手机号格式不正确', 'row' => []];
}
$ent = $enterprise instanceof EnterpriseModel ? $enterprise->toArray() : (array) $enterprise;
if (empty($ent['enterprise_id'])) {
return ['ok' => false, 'message' => '企业不存在', 'row' => []];
}
$res = QytOil::forEnterprise($ent)->invoice($month, $mobile);
$key = [
'qyt_enterprise_id' => (int) $ent['enterprise_id'],
'passageway' => (string) $ent['passageway'],
'month' => $month,
'mobile' => $mobile,
];
$now = time();
if ($res['code'] !== '00') {
$message = '同步失败:' . ($res['message'] ?: '未知错误');
self::upsert($key, [
'business_license' => (string) $ent['business_license'],
'sync_status' => 2,
'sync_time' => $now,
'sync_text' => mb_substr($message, 0, 250),
'store_id' => (int) ($ent['store_id'] ?? 0),
]);
return ['ok' => false, 'message' => $message, 'row' => self::format(self::find($key))];
}
$urls = self::normalizeUrls($res['data']['invoice'] ?? []);
$orders = (array) ($res['data']['order_list'] ?? []);
$text = $urls ? '同步成功,共 ' . count($urls) . ' 张发票' : '同步成功,企油通暂未出具该月发票';
self::upsert($key, [
'business_license' => (string) $ent['business_license'],
'invoice_urls' => json_encode($urls, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'invoice_count' => count($urls),
'order_count' => count($orders),
'raw' => mb_substr((string) json_encode(QytOil::maskDeep((array) $res['data']), JSON_UNESCAPED_UNICODE), 0, 1000000),
'sync_status' => 1,
'sync_time' => $now,
'sync_text' => $text,
'store_id' => (int) ($ent['store_id'] ?? 0),
]);
return ['ok' => true, 'message' => $text, 'row' => self::format(self::find($key))];
}
/**
* 取某个订单关联的发票。本地没有、上次同步失败、或「暂无发票」且已超过 1 小时,会自动去同步一次
*/
public static function forOrder(array $order, bool $refresh = false): array
{
$enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']);
if (!$enterprise) {
return ['ok' => false, 'message' => '本单绑定的企油通企业不存在', 'month' => '', 'row' => []];
}
$month = self::orderMonth($order);
$mobile = (string) $order['mobile'];
$key = [
'qyt_enterprise_id' => (int) $enterprise['enterprise_id'],
'passageway' => (string) $enterprise['passageway'],
'month' => $month,
'mobile' => $mobile,
];
$row = self::find($key);
$stale = !$row
|| (int) $row['sync_status'] !== 1
|| ((int) $row['invoice_count'] === 0 && time() - (int) $row['sync_time'] > self::RESYNC_EMPTY_AFTER);
$message = $row ? (string) $row['sync_text'] : '';
if ($refresh || $stale) {
$r = self::sync($enterprise, $month, $mobile);
$message = $r['message'];
$row = self::find($key);
if (!$r['ok'] && !$row) {
return ['ok' => false, 'message' => $message, 'month' => $month, 'row' => []];
}
}
return ['ok' => true, 'message' => $message, 'month' => $month, 'row' => self::format($row)];
}
/**
* 订单的消费月份:优先取加油流水的支付时间,没有再依次退到消费时间 / 发放时间 / 下单时间
*/
public static function orderMonth(array $order): string
{
$t = (int) Db::name('qyt_consume')->where('order_id', $order['order_id'])->max('pay_time');
foreach (['consume_time', 'oil_time', 'create_time'] as $field) {
if ($t > 0) {
break;
}
$t = is_numeric($order[$field] ?? null) ? (int) $order[$field] : (int) strtotime((string) ($order[$field] ?? ''));
}
return date('Y-m', $t > 0 ? $t : time());
}
/**
* 7.14 的 invoice 文档只写了「发票地址集」,没给元素结构。
* 兼容两种形态:直接是 URL 字符串;或对象(取常见的 url 字段,取不到就找第一个 http 开头的值)
*/
public static function normalizeUrls($invoice): array
{
$out = [];
foreach ((array) $invoice as $item) {
$url = '';
if (is_string($item)) {
$url = trim($item);
} elseif (is_array($item)) {
foreach (['url', 'invoice_url', 'file_url', 'pdf_url', 'download_url', 'path'] as $k) {
if (!empty($item[$k]) && is_string($item[$k])) {
$url = trim($item[$k]);
break;
}
}
if ($url === '') {
foreach ($item as $v) {
if (is_string($v) && preg_match('#^https?://#i', trim($v))) {
$url = trim($v);
break;
}
}
}
}
if ($url !== '' && preg_match('#^https?://#i', $url)) {
$out[] = $url;
}
}
return array_values(array_unique($out));
}
/** 列表 / 详情输出格式 */
public static function format($row): array
{
if (!$row) {
return [];
}
$row = (array) $row;
unset($row['raw']);
$urls = json_decode((string) ($row['invoice_urls'] ?? ''), true);
$row['invoice_urls'] = is_array($urls) ? $urls : [];
$row['mobile_mask'] = $row['mobile'] !== '' ? QytOil::maskMobile((string) $row['mobile']) : '企业全量';
$row['sync_time_text'] = !empty($row['sync_time']) ? date('Y-m-d H:i:s', (int) $row['sync_time']) : '';
return $row;
}
protected static function find(array $key): ?array
{
$row = Db::name('qyt_invoice')->where($key)->find();
return $row ?: null;
}
protected static function upsert(array $key, array $data): void
{
$data['update_time'] = time();
if (Db::name('qyt_invoice')->where($key)->update($data)) {
return;
}
try {
Db::name('qyt_invoice')->insert($key + $data + ['create_time' => time()]);
} catch (\Throwable $e) {
// 并发下另一请求刚插入:唯一键冲突,改为更新
if (strpos($e->getMessage(), 'Duplicate') === false) {
throw $e;
}
Db::name('qyt_invoice')->where($key)->update($data);
}
}
}
+145 -1
View File
@@ -44,6 +44,13 @@ class QytOil extends BaseService
/** @var EnterpriseModel|array|null 当前企业 */
protected $enterprise = null;
/**
* @var bool 最近一次请求是否属于「网络层异常」(超时、连不上、响应不是 JSON)。
* 这类失败不能说明上游没处理——对 7.16 这种没有幂等键的写接口,必须和
* 「上游明确返回失败」区分开,前者转人工确认,后者才可以放心重试。
*/
protected $transportError = false;
protected function initialize()
{
$this->conf = QytConfig::get();
@@ -204,6 +211,132 @@ class QytOil extends BaseService
return null;
}
// ================================================================
// 7.17 获取支付凭证(须真实 RSA 验签)
// ================================================================
/**
* 查询某司机在当前企业下的支付凭证,退款(7.16)要用其中的 quota_id
* @return array data.list[]: quota_id / total_amount / available_amount / used_amount / status ...(金额单位:元)
*/
public function getVoucherList(string $mobile, string $idCard, int $page = 1, int $pageSize = 50): array
{
if (($err = $this->requireEnterprise()) !== null) {
return $err;
}
return $this->post('/enterprise/api/quota/getVoucherList', [
'passageway' => (string) $this->channelCode(),
'business_license' => $this->businessLicense(),
'mobile' => $mobile,
'id_card' => $idCard,
'page' => $page,
'page_size' => $pageSize,
]);
}
// ================================================================
// 7.16 司机额度调整(须真实 RSA 验签;⚠️ 无幂等键,重复调用会重复调额)
// ================================================================
/**
* @param int|string $quotaId 7.17 返回的凭证主键
* @param string $changeType 02 增加额度 / 03 减少额度
* @param float $amount 调整金额(元)
* @param string $reason 操作原因(≤200 字);退款时写入退款单号,确认结果时靠它在 7.13 流水里定位
* @param string $waybillNo 运单号(可选)
*/
public function adjustVoucher($quotaId, string $changeType, float $amount, string $reason = '', string $waybillNo = ''): array
{
if (($err = $this->requireEnterprise()) !== null) {
return $err;
}
$params = [
'passageway' => (string) $this->channelCode(),
'business_license' => $this->businessLicense(),
'quota_id' => (int) $quotaId,
'change_type' => $changeType,
'amount' => round($amount, 2),
'operation_reason' => mb_substr($reason, 0, 200),
];
$waybillNo !== '' && $params['waybill_no'] = $waybillNo;
return $this->post('/enterprise/api/quota/adjustVoucher', $params);
}
// ================================================================
// 7.11 企业账户余额(仅平安银行通道支持,金额:分)
// ================================================================
public function distributable(): array
{
if (($err = $this->requireEnterprise()) !== null) {
return $err;
}
if ($this->channelCode() !== 2) {
return $this->fail('企业账户余额查询仅支持平安银行通道,支付宝企业码没有独立资金账户');
}
return $this->post('/enterprise/api/zhyk/distributable', [
'business_license' => $this->businessLicense(),
'passageway' => (string) $this->channelCode(),
]);
}
// ================================================================
// 7.12 司机钱包金额 / 7.13 司机钱包明细(金额:分)
// ================================================================
public function driverBalanceQuery(string $mobile, string $idCard, int $page = 1, int $pageSize = 20): array
{
if (($err = $this->requireEnterprise()) !== null) {
return $err;
}
return $this->post('/enterprise/api/zhyk/driverBalanceQuery', [
'business_license' => $this->businessLicense(),
'passageway' => (string) $this->channelCode(),
'mobile' => $mobile,
'id_card' => $idCard,
'page' => $page,
'page_size' => $pageSize,
]);
}
public function driverWalletQuery(string $mobile, string $idCard, int $page = 1, int $pageSize = 20): array
{
if (($err = $this->requireEnterprise()) !== null) {
return $err;
}
return $this->post('/enterprise/api/zhyk/driverWalletQuery', [
'business_license' => $this->businessLicense(),
'passageway' => (string) $this->channelCode(),
'mobile' => $mobile,
'id_card' => $idCard,
'page' => $page,
'page_size' => $pageSize,
]);
}
// ================================================================
// 7.14 企业发票下载(粒度:企业 + 通道 + 月份,可选按司机手机号收窄)
// ================================================================
/**
* @param string $month YYYY-MM
* @param string $mobile 司机手机号;传了只返回该司机相关发票与加油明细
* @return array data: business_license / invoice[] 发票地址集 / order_list[] 加油明细(分)
*/
public function invoice(string $month, string $mobile = ''): array
{
if (($err = $this->requireEnterprise()) !== null) {
return $err;
}
$params = [
'business_license' => $this->businessLicense(),
'passageway' => (string) $this->channelCode(),
'month' => $month,
];
$mobile !== '' && $params['mobile'] = $mobile;
return $this->post('/enterprise/api/zhyk/invoice', $params);
}
// ================================================================
// 7.2 企业申请创建
// ================================================================
@@ -295,12 +428,15 @@ class QytOil extends BaseService
['企业' => $this->enterpriseName(), 'url' => $url, 'params' => self::maskDeep($bizParams)],
JSON_UNESCAPED_UNICODE));
$this->transportError = false;
$resp = $this->curlPost($url, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->saveLog("[响应] {$path} " . self::maskText($resp));
$json = json_decode($resp, true);
if (!is_array($json)) {
return ['code' => 'ERR', 'data' => [], 'message' => '响应解析失败', 'dup_batch' => false, 'raw' => ['body' => $resp]];
// 拿不到可解析的响应,无法判断上游是否已处理
return ['code' => 'ERR', 'data' => [], 'message' => '响应解析失败', 'dup_batch' => false,
'transport_error' => true, 'raw' => ['body' => mb_substr((string) $resp, 0, 500)]];
}
// 通用响应结构:{ status: ok|error, data, message, serialNo, sign }
@@ -323,6 +459,8 @@ class QytOil extends BaseService
'data' => $json['data'] ?? [],
'message' => $message,
'dup_batch' => $dupBatch,
// cURL 失败时 curlPost 会伪造一个 {"status":"error"} 的 JSON,靠标记区分
'transport_error' => $this->transportError,
'raw' => $json,
];
}
@@ -344,9 +482,15 @@ class QytOil extends BaseService
if ($resp === false) {
$err = curl_error($ch);
curl_close($ch);
$this->transportError = true;
return json_encode(['status' => 'error', 'message' => 'cURL: ' . $err], JSON_UNESCAPED_UNICODE);
}
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 网关层 5xx:请求可能已到达上游业务层,也按结果未知处理
if ($httpCode >= 500) {
$this->transportError = true;
}
return (string) $resp;
}
+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);
}
}