完成了基本调整调整

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
+94 -1
View File
@@ -18,7 +18,9 @@ use app\common\model\Partners as PartnersModel;
use app\common\crypto\RsaUtil;
use app\common\service\PartnersBalance;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use app\common\service\QytDriver;
use app\common\service\QytOil;
use app\common\service\QytRefund;
use app\store\model\order\QrCode as QrCodeModel;
use cores\exception\BaseException;
use think\facade\Request;
@@ -168,6 +170,14 @@ class OilCard extends Controller
if($created){
// 回填流水的订单ID(建单前尚不知 order_id)
PartnersBalance::bindOrder($balanceLogId, (int)$OrderModel->order_id);
// 司机档案(按身份证号归档并标记信息冲突;内部吞异常,不影响建单)
QytDriver::touch([
'order_id' => $OrderModel->order_id,
'id_card' => $idCard,
'driver_name' => $driverName,
'mobile' => $mobile,
'store_id' => $this->storeId,
]);
// 存入队列处理
$queueService = new QueueService();
$queueService->push('partners_queue', json_encode(['order_id'=>$OrderModel->order_id],320));
@@ -186,6 +196,76 @@ class OilCard extends Controller
}
}
/**
* 订单退款(整单全额)
* 企油通侧:7.17 查司机支付凭证 -> 7.16 按订单金额调低额度;成功后回补合作商余额,并推送油卡变动通知(optType=refund)
* @return Json
*/
public function refund()
{
$headers = Request::header();
$data = json_decode(Request::instance()->getContent(), true);
$appKey = $headers['appkey'] ?? '';
$requestId = $headers['requestid'] ?? '';
$method = $headers['method'] ?? '';
if($method != 'syb.oil.oilCard.refund'){
return $this->oilRenderError('12', '0005');
}
try {
Db::name('request_log')->insert([
'request_id' => $requestId,
'app_key' => $appKey,
'create_time' => time()
]);
} catch (\think\Exception $e) {
if (strpos($e->getMessage(), 'Duplicate entry') !== false) {
return $this->oilRenderError('12', '0012');
}
return $this->oilRenderError('99', '9999');
}
$partners = PartnersModel::detail(['appKey' => $appKey]);
if(!$partners){
return $this->oilRenderError('02','0004');
}
$crypto = new RsaUtil($partners);
$result = $crypto->processResponse(['header'=>$headers,'encryptData'=>$data]);
if ($result['code'] !== '00') {
return $this->oilRenderError($result['code'], $result['errCode'], '', $result['errMessage'] ?? '');
}
$body = $result['data'];
if($partners['orgNo'] != ($body['orgNo'] ?? '')){
return $this->oilRenderError('12','0014');
}
$oilCardNo = trim((string)($body['oilCardNo'] ?? ''));
if($oilCardNo === ''){
return $this->oilRenderError('12','0001', '', '缺少必填请求参数 oilCardNo');
}
$reason = mb_substr(trim((string)($body['reason'] ?? '')), 0, 100);
// 只能退自己的订单
$order = OrderModel::detail(['oilCardNo' => $oilCardNo, 'partners_id' => $partners['partners_id']]);
if(!$order){
return $this->oilRenderError('01','2036');
}
$res = QytRefund::apply((int)$order['order_id'], 'api', (string)$appKey, $reason);
// 已退款(含重复调用的幂等返回)或结果确认中:都按受理成功返回,由 refundStatus 区分
// 确认中说明 7.16 已发出、上游可能已调额,不能告诉下游「失败」让它重试
if ($res['ok'] || $res['status'] === QytRefund::CONFIRM) {
return $this->oilRenderSuccess($res['data'], '00', $res['message']);
}
switch ($res['reason']) {
case QytRefund::REASON_NOT_FOUND:
return $this->oilRenderError('01', '2036', '', $res['message']);
case QytRefund::REASON_PROCESSING:
return $this->oilRenderError('01', '2032', '', $res['message']);
case QytRefund::REASON_STATE:
return $this->oilRenderError('01', '2048', '', $res['message']);
default:
return $this->oilRenderError('01', '2049', '', $res['message']);
}
}
public static function createOrderNo(): string
{
// 获取当前时间戳到毫秒(13位)
@@ -469,7 +549,11 @@ class OilCard extends Controller
return $this->oilRenderError('12','0013', '', $validate->getError());
}
$detail = OrderModel::detail(['oilCardNo' => $data['oilCardNo']]);
// 只能查自己的订单(原先不校验归属,一个合作商可以查到别家的单)
$detail = OrderModel::detail(['oilCardNo' => $data['oilCardNo'], 'partners_id' => $partners['partners_id']]);
if(!$detail){
return $this->oilRenderError('01','2036');
}
// 组装返回格式
$result = [
'mobile' => $detail['mobile'],
@@ -482,6 +566,15 @@ class OilCard extends Controller
'oil_time' => $detail['oil_time'],
'use_status' => $detail['use_status'],
'use_time' => $detail['use_time'],
// 一单可多次加油:已加油金额 / 加油次数 / 剩余可加油(即可退)金额
'used_amount' => number_format((float)$detail['used_amount'], 2, '.', ''),
'used_num' => (int)$detail['used_num'],
'remain_amount' => number_format(max(0, (float)$detail['total_price'] - (float)$detail['used_amount']
- ((int)$detail['refund_status'] === 2 ? (float)$detail['refund_amount'] : 0)), 2, '.', ''),
// 退款:0未退款 1退款中 2已退款 3退款失败 4退款结果确认中
'refund_status' => (int)$detail['refund_status'],
'refund_amount' => number_format((float)$detail['refund_amount'], 2, '.', ''),
'refund_time' => $detail['refund_time'] ? date('Y-m-d H:i:s', (int)$detail['refund_time']) : '',
];
return $this->oilRenderSuccess($result);
} else {
+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);
}
}
@@ -340,58 +340,91 @@ class CallbackQueue extends Command
continue;
}
$payTime = !empty($c['pay_time']) ? (strtotime($c['pay_time']) ?: time()) : time();
// 落消费流水(uk_consume 去重:重复回调直接忽略)
// 一单可以分多次加油:每条推送是一次加油,consume_amount = 本次占用运单金额(分)
$consumeFen = (int)($c['consume_amount'] ?? 0);
$consumeYuan = number_format($consumeFen / 100, 2, '.', '');
$orderCode = trim((string)($c['order_code'] ?? ''));
// 去重键:文档里 order_code 是选填,缺失时用本笔消费的特征字段做指纹,
// 否则同一运单的第二笔加油会被唯一键误判为重复推送而丢掉(口径与迁移脚本回填一致)
$consumeKey = $orderCode !== '' ? $orderCode : 'FP' . md5(implode('|', [
$c['quota_id'] ?? '', $c['pay_time'] ?? '', $c['consume_amount'] ?? '',
$c['pay_fee'] ?? '', $c['station_code'] ?? '', $c['litter'] ?? '',
]));
$consumeId = 0;
try {
Db::name('qyt_consume')->insert([
'order_id' => $order['order_id'],
'waybill_no' => $c['waybill_no'] ?? '',
'waybill_no_code' => $code,
'order_code' => $c['order_code'] ?? '',
'station_code' => $c['station_code'] ?? '',
'station_name' => $c['station_name'] ?? '',
'oil_name' => $c['oil_name'] ?? '',
'litter' => $c['litter'] ?? '',
'oil_price' => (int)($c['oil_price'] ?? 0),
'actual_oil_price' => (int)($c['actual_oil_price'] ?? 0),
'pay_fee' => (int)($c['pay_fee'] ?? 0),
'consume_amount' => (int)($c['consume_amount'] ?? 0),
'pay_time' => $payTime,
'raw' => json_encode($c, 320),
'store_id' => $order['store_id'],
'create_time' => time(),
]);
// 流水落库与订单累计放在同一事务:唯一键挡住重推,重推既不重复累计也不重复通知
Db::transaction(function () use ($order, $c, $code, $orderCode, $consumeKey, $payTime, $consumeFen, $consumeYuan, &$consumeId) {
$consumeId = (int)Db::name('qyt_consume')->insertGetId([
'order_id' => $order['order_id'],
'waybill_no' => $c['waybill_no'] ?? '',
'waybill_no_code' => $code,
'order_code' => $orderCode,
'consume_key' => $consumeKey,
'station_code' => $c['station_code'] ?? '',
'station_name' => $c['station_name'] ?? '',
'oil_name' => $c['oil_name'] ?? '',
'litter' => $c['litter'] ?? '',
'oil_price' => (int)($c['oil_price'] ?? 0),
'actual_oil_price' => (int)($c['actual_oil_price'] ?? 0),
'pay_fee' => (int)($c['pay_fee'] ?? 0),
'consume_amount' => $consumeFen,
'pay_time' => $payTime,
'raw' => json_encode($c, 320),
'store_id' => $order['store_id'],
'create_time' => time(),
]);
// 累计已加油金额 / 次数,并记下最近一次加油信息(原子自增,不读后写)
Db::name('partners_order')->where('order_id', $order['order_id'])->update([
'used_amount' => Db::raw('`used_amount` + ' . $consumeYuan),
'used_num' => Db::raw('`used_num` + 1'),
'use_text' => '加油',
'qyt_order_code' => $orderCode,
'station_name' => $c['station_name'] ?? '',
'oil_name' => $c['oil_name'] ?? '',
'litter' => $c['litter'] ?? '',
'pay_fee' => (int)($c['pay_fee'] ?? 0),
'consume_time' => $payTime,
'update_time' => time(),
]);
// use_status 语义不变(0 未加油 / 1 有加油记录):只在第一笔加油时置 1 并记首次加油时间
Db::name('partners_order')->where('order_id', $order['order_id'])->where('use_status', 0)->update([
'use_status' => 1,
'use_time' => time(),
'shareNo' => $orderCode !== '' ? $orderCode : $order['shareNo'],
]);
});
} catch (\Exception $e) {
if (strpos($e->getMessage(), 'Duplicate') === false) {
throw $e;
}
$SybOil->saveLogStore('企油通消费回调重复,忽略流水:' . $code . '/' . ($c['order_code'] ?? ''));
$SybOil->saveLogStore('企油通消费回调重复,忽略(不重复累计、不重复通知):' . $code . '/' . $consumeKey);
continue;
}
// 条件跃迁 use_status 0 -> 1(要求已发放),仅胜出者通知下单平台
$affected = Db::name('partners_order')->where('order_id', $order['order_id'])
->where('use_status', 0)
->where('oil_status', 1)
->update([
'use_status' => 1,
'use_time' => time(),
'use_text' => '加油',
'qyt_order_code' => $c['order_code'] ?? '',
'shareNo' => $c['order_code'] ?: $order['shareNo'],
'station_name' => $c['station_name'] ?? '',
'oil_name' => $c['oil_name'] ?? '',
'litter' => $c['litter'] ?? '',
'pay_fee' => (int)($c['pay_fee'] ?? 0),
'consume_time' => $payTime,
'update_time' => time(),
]);
if ($affected) {
$queueService = new QueueService();
$queueService->push('notify_queue', json_encode([
'order_id' => $order['order_id'],
'optType' => 'add_oil',
'retry_count' => 0,
], 320));
$SybOil->saveLogStore('企油通消费回调处理成功:' . json_encode($c, 320));
// 超额告警:累计加油 + 已退款 > 订单金额。上游额度按司机凭证累计,
// 归属到本单的消费可能与本地可退金额算出的不一致,需要人工核对
$fresh = Db::name('partners_order')->where('order_id', $order['order_id'])
->field('total_price,used_amount,refund_amount,oil_status_zsy')->find();
if ($fresh && (float)$fresh['used_amount'] + (float)$fresh['refund_amount'] > (float)$fresh['total_price'] + 0.005) {
$SybOil->saveLogStore('【告警】订单已加油+已退款超出订单金额 order=' . $order['order_id']
. ' total=' . $fresh['total_price'] . ' used=' . $fresh['used_amount'] . ' refund=' . $fresh['refund_amount']);
}
// 每笔加油都通知下单平台(amount = 本次占用金额)
$notify = json_encode([
'order_id' => $order['order_id'],
'optType' => 'add_oil',
'consume_id' => $consumeId,
'retry_count' => 0,
], 320);
$queueService = new QueueService();
if ((int)($fresh['oil_status_zsy'] ?? 2) === 2) {
$queueService->push('notify_queue', $notify);
} else {
// 7.8 发放结果还没确认就先收到了加油:延后 60 秒再通知,尽量让「分油成功」通知先到下游
$queueService->laterPush('notify_queue', $notify, 60);
}
$SybOil->saveLogStore('企油通消费回调处理成功:' . json_encode($c, 320));
}
break;
default:
+29 -5
View File
@@ -87,12 +87,31 @@ class NotifyQueue extends Command
return false;
}
$partnersOil = new PartnersOil();
$optType = (string)($data['optType'] ?? '');
// 加油:一单可多次加油,每笔单独通知。消息里带 consume_id 时推这一笔的占用金额和消费单号;
// 老消息没有 consume_id(上线前入队的)则沿用旧口径推订单金额
$consume = null;
if ($optType == 'add_oil' && !empty($data['consume_id'])) {
$consume = Db::name('qyt_consume')->where('id', (int)$data['consume_id'])->find();
}
if ($optType == 'refund') {
$amount = $order['refund_amount'];
} elseif ($consume) {
$amount = number_format((int)$consume['consume_amount'] / 100, 2, '.', '');
} else {
$amount = $order['total_price'];
}
$data = [
'oilCardNo' => $order['oilCardNo'],
'amount' => $order['total_price'],
'shareNo' => $order['shareNo'],
'type' => $data['optType'] == 'share_oil' ? 'add' : 'sub',
'optType' => $data['optType'],
// 分油:订单金额;加油:本次加油占用金额;退款:退款金额
'amount' => $amount,
// 订单实付金额(即分油金额),所有场景都带,方便对接方拿 amount 对照参考
'orderAmount' => number_format((float)$order['total_price'], 2, '.', ''),
'shareNo' => $consume && $consume['order_code'] !== '' ? $consume['order_code'] : $order['shareNo'],
'type' => $optType == 'share_oil' ? 'add' : 'sub',
'optType' => $optType,
];
$header['method'] = 'syb.notify.oilCard.change';
$res = $partnersOil->sendRequest($partners, $partners['callback'], $data, $header);
@@ -103,11 +122,16 @@ class NotifyQueue extends Command
'oil_callback' => 1,
'oil_callback_time' => time(),
]);
}else{
}elseif($data['optType'] == 'add_oil'){
// 退款通知成功不记 use_callback,避免把「加油已回调」标错
$order->save([
'use_callback' => 1,
'use_callback_time' => time(),
]);
// 逐笔记录这一笔加油已通知
if ($consume) {
Db::name('qyt_consume')->where('id', $consume['id'])->update(['notify_status' => 1, 'notify_time' => time()]);
}
}
return true;
}
@@ -3,6 +3,7 @@ declare (strict_types=1);
namespace app\store\controller;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use app\store\controller\Controller;
use app\store\model\Partners as PartnersModel;
use app\store\model\partners\Order as OrderModel;
@@ -40,15 +41,47 @@ class Partners extends Controller
$newList = [];
$OrderModel = new OrderModel();
$date = date("Y-m-d");
// 企油通企业:口径与下单时 Enterprise::resolve 一致(绑定且启用 -> 用绑定的;否则 -> 默认企业)
$bindIds = array_filter(array_map('intval', array_column($list['data'], 'qyt_enterprise_id')));
$enterprises = $bindIds
? array_column(EnterpriseModel::whereIn('enterprise_id', $bindIds)
->field('enterprise_id,company_name,passageway,is_enable')->select()->toArray(), null, 'enterprise_id')
: [];
$default = EnterpriseModel::getDefault();
foreach ($list['data'] as $val){
$val['day_money'] =$OrderModel->getOrderTotalPrice($date, $date, $val);
$val['total_money'] =$OrderModel->getOrderTotalPrice(null, null, $val);
$val = array_merge($val, $this->enterpriseInfo((int)$val['qyt_enterprise_id'], $enterprises, $default));
$newList[] = $val;
}
$list['data'] = $newList;
return $this->renderSuccess(['list'=>$list,'user'=>$this->store['user']]);
}
/**
* 合作商实际使用的企油通企业
* qyt_enterprise_source:bind 绑定 / default 未绑定走默认 / invalid 绑定的企业已停用或删除(实际走默认)/ none 无可用企业
*/
private function enterpriseInfo(int $bindId, array $enterprises, $default): array
{
$bound = $bindId > 0 ? ($enterprises[$bindId] ?? null) : null;
if ($bound && (int)$bound['is_enable'] === 1) {
$source = 'bind';
$use = $bound;
} else {
$source = $bindId > 0 ? 'invalid' : 'default';
$use = $default ? $default->toArray() : null;
!$use && $source = $bindId > 0 ? 'invalid' : 'none';
}
return [
'qyt_enterprise_source' => $source,
// 绑定失效时也把原绑定企业名带出来,便于排查
'qyt_bind_name' => $bound['company_name'] ?? '',
'qyt_enterprise_name' => $use['company_name'] ?? '',
'qyt_enterprise_channel' => isset($use['passageway']) ? ($use['passageway'] === 'pingan' ? '平安银行' : '支付宝企业码') : '',
];
}
/**
* 获取所有商户
* @return mixed
@@ -16,7 +16,9 @@ namespace app\store\controller\partners;
use app\common\service\PartnersBalance;
use app\common\service\QueueService;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use app\common\service\QytInvoice;
use app\common\service\QytOil;
use app\common\service\QytRefund;
use app\store\controller\Controller;
use app\store\model\order\QrCode as QrCodeModel;
use app\store\model\partners\Order as OrderModel;
@@ -66,8 +68,12 @@ class Order extends Controller
if (!$order) {
return $this->renderError('订单不存在');
}
if ($order['use_status'] != 0) {
return $this->renderError('该单已加油核销');
// 一单可以分多次加油:没加完的单继续给加油地址
if ((int) $order['refund_status'] === 2) {
return $this->renderError('该单已退款,不能再加油');
}
if ((float) $order['used_amount'] + 0.005 >= (float) $order['total_price']) {
return $this->renderError('该单金额已全部加油');
}
if ($order['oil_status'] != 1) {
return $this->renderError('该单尚未发放成功,无法获取加油地址');
@@ -193,12 +199,120 @@ class Order extends Controller
if ($order['use_status'] != 1) {
return $this->renderError('该单未加油核销,无可回调内容');
}
(new QueueService())->push('notify_queue', json_encode([
'order_id' => $orderId,
'type' => 'sub',
'optType' => 'add_oil',
'retry_count' => 0,
], 320));
return $this->renderSuccess('已重新推送加油结果');
// 每笔加油单独通知:优先补推还没通知成功的;都通知过了就全部重推一遍(下游按 shareNo 去重)
$ids = Db::name('qyt_consume')->where('order_id', $orderId)->where('notify_status', 0)->order('id')->column('id');
$all = !$ids;
$all && $ids = Db::name('qyt_consume')->where('order_id', $orderId)->order('id')->column('id');
$queue = new QueueService();
if (!$ids) {
// 没有消费流水(改造前的历史单),按旧口径推一次
$queue->push('notify_queue', json_encode(['order_id' => $orderId, 'optType' => 'add_oil', 'retry_count' => 0], 320));
return $this->renderSuccess('已重新推送加油结果');
}
foreach ($ids as $consumeId) {
$queue->push('notify_queue', json_encode([
'order_id' => $orderId,
'optType' => 'add_oil',
'consume_id' => (int) $consumeId,
'retry_count' => 0,
], 320));
}
return $this->renderSuccess(($all ? '已重新推送全部 ' : '已补推未通知的 ') . count($ids) . ' 笔加油结果');
}
// ================================================================
// 退款(整单全额:7.17 查凭证 + 7.16 调低额度)
// ================================================================
/**
* 发起退款
*/
public function refund(): Json
{
$orderId = (int) $this->request->param('orderId');
$reason = trim((string) $this->request->param('reason', ''));
if (!Db::name('partners_order')->where('order_id', $orderId)->count()) {
return $this->renderError('订单不存在');
}
$res = QytRefund::apply($orderId, 'admin', $this->operator(), $reason);
return $res['ok'] ? $this->renderSuccess($res['data'], $res['message']) : $this->renderError($res['message']);
}
/**
* 确认「待人工确认」的退款:向企油通取证(7.13 流水 + 7.17 凭证)
*/
public function refundConfirm(): Json
{
$res = QytRefund::confirm((int) $this->request->param('orderId'));
return $this->decided($res) ? $this->renderSuccess($res['data'], $res['message']) : $this->renderError($res['message']);
}
/**
* 人工判定「待人工确认」的退款
* result = success / failed,remark 必填(判定依据)
*/
public function refundResolve(): Json
{
$result = (string) $this->request->param('result');
if (!in_array($result, ['success', 'failed'], true)) {
return $this->renderError('判定结果不合法');
}
$res = QytRefund::resolve(
(int) $this->request->param('orderId'),
$result === 'success',
$this->operator(),
(string) $this->request->param('remark', '')
);
return $this->decided($res) ? $this->renderSuccess($res['data'], $res['message']) : $this->renderError($res['message']);
}
/**
* 订单的退款流水
*/
public function refundLog(): Json
{
$list = Db::name('qyt_refund')
->where('order_id', (int) $this->request->param('orderId'))
->field('refund_id,refund_no,source,operator,reason,amount,quota_id,total_before,available_before,total_after,available_after,status,message,create_time,update_time')
->order('refund_id', 'desc')
->select()->toArray();
foreach ($list as &$row) {
$row['status_text'] = QytRefund::STATUS_TEXT[(int) $row['status']] ?? '';
$row['create_time_text'] = date('Y-m-d H:i:s', (int) $row['create_time']);
}
return $this->renderSuccess(compact('list'));
}
// ================================================================
// 发票(7.14,按「企业 + 消费月份 + 司机」关联)
// ================================================================
public function invoice(): Json
{
$order = Db::name('partners_order')->where('order_id', (int) $this->request->param('orderId'))->find();
if (!$order) {
return $this->renderError('订单不存在');
}
if ((int) $order['use_status'] !== 1) {
return $this->renderError('该订单还没有加油记录,暂无发票');
}
$res = QytInvoice::forOrder($order, (bool) $this->request->param('refresh', 0));
return $res['ok'] ? $this->renderSuccess($res, $res['message']) : $this->renderError($res['message']);
}
/**
* 取证 / 人工判定是否得出了结论:退款成功,或明确判定为「未生效」都算请求成功,
* 由返回的 refundStatus 区分;状态不符、取证失败、仍无法判定才算失败
*/
private function decided(array $res): bool
{
return $res['ok']
|| ($res['status'] === QytRefund::FAILED && $res['reason'] === QytRefund::REASON_UPSTREAM);
}
/** 当前后台操作人 */
private function operator(): string
{
return (string) ($this->store['user']['user_name'] ?? 'admin');
}
}
@@ -0,0 +1,195 @@
<?php
// +----------------------------------------------------------------------
// | 后台 · 企油通司机管理
// +----------------------------------------------------------------------
// | list() 司机档案 + 从订单实时汇总的累计发放 / 已退款 / 订单数
// | detail() 档案、涉及企业、最近订单(发放失败原因里能看到二要素核验结果)
// | upstream() 实时查企油通:balance 7.12 钱包余额 / wallet 7.13 钱包流水 / voucher 7.17 支付凭证
// |
// | 身份证号全程只在后端使用,返回前端一律打码。
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\store\controller\qyt;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use app\common\service\QytOil;
use app\common\service\QytRefund;
use app\store\controller\Controller;
use think\facade\Db;
use think\response\Json;
class Driver extends Controller
{
public function list(): Json
{
$param = $this->request->param();
$keyword = trim((string) ($param['keyword'] ?? ''));
$query = Db::name('qyt_driver')->where('store_id', $this->storeId);
if ($keyword !== '') {
if (preg_match('/^\d{17}[\dXx]$/', $keyword)) {
$query->where('id_card', strtoupper($keyword));
} elseif (preg_match('/^1\d{10}$/', $keyword)) {
$query->where('mobile', $keyword);
} else {
$query->whereLike('name', '%' . $keyword . '%');
}
}
if (isset($param['conflict']) && $param['conflict'] !== '' && (int) $param['conflict'] >= 0) {
$query->where('conflict', (int) $param['conflict']);
}
$list = $query->order('last_order_time', 'desc')->paginate(15)->toArray();
$stats = $this->stats(array_column($list['data'], 'id_card'));
foreach ($list['data'] as &$row) {
$row = $this->formatDriver($row, $stats[$row['id_card']] ?? []);
}
return $this->renderSuccess(compact('list'));
}
public function detail(int $driverId): Json
{
$driver = $this->findDriver($driverId);
if (!$driver) {
return $this->renderError('司机不存在');
}
$stats = $this->stats([$driver['id_card']]);
// 涉及过的企业(上游查询要按企业查)
$entIds = Db::name('partners_order')->where('store_id', $this->storeId)
->where('id_card', $driver['id_card'])->where('qyt_enterprise_id', '>', 0)
->group('qyt_enterprise_id')->column('qyt_enterprise_id');
$enterprises = $entIds
? EnterpriseModel::whereIn('enterprise_id', $entIds)
->field('enterprise_id,company_name,passageway,status')->select()->toArray()
: [];
$orders = Db::name('partners_order')->where('store_id', $this->storeId)->where('id_card', $driver['id_card'])
->field('order_id,oilCardNo,partners_order_no,total_price,driver_name,mobile,oil_status_zsy,oil_text,use_status,refund_status,refund_text,qyt_enterprise_id,create_time')
->order('order_id', 'desc')->limit(20)->select()->toArray();
foreach ($orders as &$o) {
$o['mobile'] = QytOil::maskMobile((string) $o['mobile']);
$o['refund_status_text'] = QytRefund::STATUS_TEXT[(int) $o['refund_status']] ?? '';
$o['create_time_text'] = date('Y-m-d H:i:s', (int) $o['create_time']);
}
return $this->renderSuccess([
'detail' => $this->formatDriver($driver, $stats[$driver['id_card']] ?? []),
'enterprises' => $enterprises,
'orders' => $orders,
]);
}
/**
* 实时查企油通
* type: balance 7.12 / wallet 7.13 / voucher 7.17
*/
public function upstream(int $driverId, int $enterpriseId, string $type = 'balance', int $page = 1): Json
{
$driver = $this->findDriver($driverId);
if (!$driver) {
return $this->renderError('司机不存在');
}
$enterprise = EnterpriseModel::detail($enterpriseId);
if (!$enterprise) {
return $this->renderError('企业不存在');
}
$qyt = QytOil::forEnterprise($enterprise);
$mobile = (string) $driver['mobile'];
$idCard = (string) $driver['id_card'];
$page = max(1, $page);
switch ($type) {
case 'balance':
$res = $qyt->driverBalanceQuery($mobile, $idCard, $page, 20);
break;
case 'wallet':
$res = $qyt->driverWalletQuery($mobile, $idCard, $page, 20);
break;
case 'voucher':
$res = $qyt->getVoucherList($mobile, $idCard, $page, 20);
break;
default:
return $this->renderError('查询类型不合法');
}
if ($res['code'] !== '00') {
return $this->renderError('企油通返回:' . ($res['message'] ?: '查询失败'));
}
$data = (array) $res['data'];
// 7.12 / 7.13 金额是「分」,统一转成元再给前端;7.17 本来就是元
if ($type === 'balance' || $type === 'wallet') {
foreach (['totalGrantAmount', 'availableBalance'] as $k) {
isset($data[$k]) && $data[$k . 'Yuan'] = QytOil::fenToYuan($data[$k]);
}
foreach (['list', 'transactionDetails'] as $listKey) {
foreach ((array) ($data[$listKey] ?? []) as $i => $item) {
foreach (['total_amount', 'available_amount', 'amount'] as $k) {
if (isset($item[$k]) && is_numeric($item[$k])) {
$data[$listKey][$i][$k . '_yuan'] = QytOil::fenToYuan((int) $item[$k]);
}
}
}
}
}
return $this->renderSuccess(['type' => $type, 'data' => QytOil::maskDeep($data)]);
}
// ================================================================
private function findDriver(int $driverId): ?array
{
$row = Db::name('qyt_driver')->where('store_id', $this->storeId)->where('driver_id', $driverId)->find();
return $row ?: null;
}
/**
* 从订单实时汇总,按身份证号分组
*/
private function stats(array $idCards): array
{
if (!$idCards) {
return [];
}
$rows = Db::name('partners_order')
->where('store_id', $this->storeId)
->whereIn('id_card', $idCards)
->fieldRaw('id_card,'
. ' COUNT(*) AS order_num,'
. ' SUM(IF(oil_status_zsy = 2, total_price, 0)) AS grant_amount,'
. ' SUM(IF(refund_status = 2, refund_amount, 0)) AS refund_amount,'
. ' SUM(IF(use_status = 1, 1, 0)) AS used_num,'
. ' SUM(used_amount) AS used_amount,'
. ' SUM(IF(oil_status_zsy = 3, 1, 0)) AS fail_num,'
. ' SUM(IF(oil_status_zsy = 1, 1, 0)) AS pending_num')
->group('id_card')
->select()->toArray();
return array_column($rows, null, 'id_card');
}
private function formatDriver(array $row, array $stat): array
{
$grant = round((float) ($stat['grant_amount'] ?? 0), 2);
$refund = round((float) ($stat['refund_amount'] ?? 0), 2);
$out = [
'driver_id' => (int) $row['driver_id'],
'name' => $row['name'],
'mobile' => $row['mobile'],
'id_card_mask' => QytOil::maskIdCard((string) $row['id_card']),
'conflict' => (int) $row['conflict'],
'conflict_text' => $row['conflict_text'],
'last_order_time_text' => $row['last_order_time'] ? date('Y-m-d H:i:s', (int) $row['last_order_time']) : '',
'order_num' => (int) ($stat['order_num'] ?? 0),
'used_num' => (int) ($stat['used_num'] ?? 0),
'fail_num' => (int) ($stat['fail_num'] ?? 0),
'pending_num' => (int) ($stat['pending_num'] ?? 0),
'grant_amount' => number_format($grant, 2, '.', ''),
'refund_amount' => number_format($refund, 2, '.', ''),
'used_amount' => number_format((float) ($stat['used_amount'] ?? 0), 2, '.', ''),
// 本地口径的净发放额,仅供参考;真实可用额度以企油通 7.12 为准
'net_amount' => number_format($grant - $refund, 2, '.', ''),
];
return $out;
}
}
@@ -234,6 +234,25 @@ class Enterprise extends Controller
return $this->renderSuccess("同步完成:新增 {$added} 家,更新 {$updated} 家");
}
/**
* 7.11 企业账户余额(仅平安银行通道;银行侧实时查询,勿高频调用)
*/
public function balance(int $enterpriseId): Json
{
$model = EnterpriseModel::detail($enterpriseId);
if (!$model) {
return $this->renderError('企业不存在');
}
$res = QytOil::forEnterprise($model)->distributable();
if ($res['code'] !== '00') {
return $this->renderError('企油通返回:' . ($res['message'] ?: '查询失败'));
}
return $this->renderSuccess([
'balance' => QytOil::fenToYuan($res['data']['balance'] ?? 0),
'available_balance' => QytOil::fenToYuan($res['data']['available_balance'] ?? 0),
], '查询成功');
}
/** 记录同步结果 */
private function saveSync($model, string $text): void
{
@@ -0,0 +1,71 @@
<?php
// +----------------------------------------------------------------------
// | 后台 · 企油通发票管理(7.14)
// +----------------------------------------------------------------------
// | list() 发票记录(按 企业 + 通道 + 月份 + 司机手机号 维度)
// | sync() 同步:指定企业则同步该企业(可带司机手机号);不指定则批量同步所有启用企业的当月全量
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\store\controller\qyt;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use app\common\service\QytInvoice;
use app\store\controller\Controller;
use think\facade\Db;
use think\response\Json;
class Invoice extends Controller
{
public function list(): Json
{
$param = $this->request->param();
$query = Db::name('qyt_invoice');
!empty($param['qyt_enterprise_id']) && $query->where('qyt_enterprise_id', (int) $param['qyt_enterprise_id']);
!empty($param['month']) && $query->where('month', (string) $param['month']);
!empty($param['mobile']) && $query->where('mobile', trim((string) $param['mobile']));
if (isset($param['has_invoice']) && $param['has_invoice'] !== '' && (int) $param['has_invoice'] >= 0) {
(int) $param['has_invoice'] === 1 ? $query->where('invoice_count', '>', 0) : $query->where('invoice_count', 0);
}
$list = $query->order(['month' => 'desc', 'invoice_id' => 'desc'])->paginate(15)->toArray();
$names = EnterpriseModel::column('company_name', 'enterprise_id');
foreach ($list['data'] as &$row) {
$row = QytInvoice::format($row);
$row['company_name'] = $names[$row['qyt_enterprise_id']] ?? '(企业已删除)';
}
return $this->renderSuccess(compact('list'));
}
public function sync(): Json
{
$month = trim((string) $this->request->param('month'));
$mobile = trim((string) $this->request->param('mobile', ''));
$enterpriseId = (int) $this->request->param('enterpriseId', 0);
if ($enterpriseId > 0) {
$enterprise = EnterpriseModel::detail($enterpriseId);
if (!$enterprise) {
return $this->renderError('企业不存在');
}
$res = QytInvoice::sync($enterprise, $month, $mobile);
return $res['ok'] ? $this->renderSuccess($res['row'], $res['message']) : $this->renderError($res['message']);
}
// 批量:所有启用企业的该月全量
$ok = $fail = 0;
$errors = [];
foreach (EnterpriseModel::where('is_enable', 1)->select() as $enterprise) {
$res = QytInvoice::sync($enterprise, $month, '');
if ($res['ok']) {
$ok++;
} else {
$fail++;
$errors[] = $enterprise['company_name'] . ':' . $res['message'];
}
}
$message = "批量同步完成:成功 {$ok} 家,失败 {$fail} 家" . ($errors ? '(' . implode(';', array_slice($errors, 0, 3)) . ')' : '');
return $fail && !$ok ? $this->renderError($message) : $this->renderSuccess([], $message);
}
}
@@ -79,6 +79,8 @@ class Order extends OrderModel
'oil_status' => -1, // 分油(发放)状态
'oil_status_zsy' => -1, // 企油通发放态 0未提交 1发放中 2成功 3失败
'use_status' => -1, // 加油核销状态
'refund_status' => -1, // 退款状态 0未退款 1退款中 2已退款 3退款失败 4待人工确认
'id_card' => '', // 司机身份证号(从司机管理跳转过来时使用)
'betweenTime' => [] // 起止时间
]);
// 检索查询条件
@@ -100,6 +102,10 @@ class Order extends OrderModel
$params['oil_status_zsy'] > -1 && $filter[] = ['oil_status_zsy', '=', (int)$params['oil_status_zsy']];
// 加油核销状态
$params['use_status'] > -1 && $filter[] = ['use_status', '=', (int)$params['use_status']];
// 退款状态
$params['refund_status'] > -1 && $filter[] = ['order.refund_status', '=', (int)$params['refund_status']];
// 司机身份证号(精确匹配)
!empty($params['id_card']) && $filter[] = ['order.id_card', '=', strtoupper(trim((string)$params['id_card']))];
// 起止时间
if (!empty($params['betweenTime'])) {
$times = between_time($params['betweenTime']);