第二次更新
This commit is contained in:
@@ -17,6 +17,7 @@ use app\api\model\partners\Order as OrderModel;
|
||||
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\QytOil;
|
||||
use app\store\model\order\QrCode as QrCodeModel;
|
||||
use cores\exception\BaseException;
|
||||
@@ -85,6 +86,8 @@ class OilCard extends Controller
|
||||
$oilCardType = $data['oilCardType'] ?? '';
|
||||
// 企油通:司机姓名(下单平台必传,用于运单油费下发)
|
||||
$driverName = trim((string)($data['name'] ?? ''));
|
||||
// 企油通:司机身份证号。平安银行通道要求实名必传,支付宝企业码通道可不传
|
||||
$idCard = strtoupper(trim((string)($data['idCard'] ?? '')));
|
||||
// 请求参数orgNo与请求头appKey不存在对应关系
|
||||
if($partners['orgNo'] != $orgNo){
|
||||
return $this->oilRenderError('12','0014');
|
||||
@@ -95,6 +98,19 @@ class OilCard extends Controller
|
||||
if($driverName === ''){
|
||||
return $this->oilRenderError('12','0001', '', '缺少必填请求参数 name(司机姓名)');
|
||||
}
|
||||
// 解析本单使用的企油通企业:合作商绑定的企业 -> 默认企业
|
||||
$enterprise = EnterpriseModel::resolve((int)$partners['qyt_enterprise_id']);
|
||||
if(!$enterprise){
|
||||
$this->saveLog('share 未匹配到可用的企油通企业,partners_id=' . $partners['partners_id']);
|
||||
return $this->oilRenderError('99','9999', '', '系统未配置可用企业,请联系平台');
|
||||
}
|
||||
// 身份证号:传了就必须合法;该企业当前通道要求实名时不传直接拒单
|
||||
if($idCard !== '' && !preg_match('/^\d{17}[\dX]$/', $idCard)){
|
||||
return $this->oilRenderError('12','0013', '', 'idCard 身份证号格式不正确(18位)');
|
||||
}
|
||||
if($idCard === '' && $enterprise->requireIdCard()){
|
||||
return $this->oilRenderError('12','0001', '', '缺少必填请求参数 idCard(司机身份证号,当前支付通道要求实名)');
|
||||
}
|
||||
// 金额:由下单平台直接传入(单位:元,最多两位小数)
|
||||
if($amount === '' || !preg_match('/^\d{1,8}(\.\d{1,2})?$/', (string)$amount) || (float)$amount <= 0){
|
||||
return $this->oilRenderError('12','0013', '', 'amount 金额格式不正确(单位:元,最多两位小数且大于0)');
|
||||
@@ -122,6 +138,10 @@ class OilCard extends Controller
|
||||
$created = $OrderModel->saveOrder([
|
||||
'mobile' => $mobile,
|
||||
'driver_name' => $driverName,
|
||||
'id_card' => $idCard,
|
||||
// 企业与通道随单固化:之后改绑定或切默认企业,在途单仍按下单时的企业发放
|
||||
'qyt_enterprise_id' => $enterprise['enterprise_id'],
|
||||
'passageway' => $enterprise['passageway'],
|
||||
'oilCardType' => $oilCardType,
|
||||
'oilCardNo' => $oilCardNo,
|
||||
'partners_order_no' => $saleOrderNo,
|
||||
@@ -237,7 +257,12 @@ class OilCard extends Controller
|
||||
return $this->oilRenderError('12','0013', '', '未找到对应订单');
|
||||
}
|
||||
// 企油通:生成司机免登加油 H5 地址(7.15)。出参 qrCode 结构不变,值由二维码链接改为 H5 URL。
|
||||
$h5Url = (new QytOil())->h5Url($order['mobile']);
|
||||
// creditcode 取本单固化的企业,把登录固定到该企业;企业须已通过企油通审核
|
||||
$orderEnterprise = EnterpriseModel::detail((int)$order['qyt_enterprise_id']);
|
||||
if(!$orderEnterprise){
|
||||
return $this->oilRenderError('12','0013', '', '本单绑定的企业不存在,无法生成加油链接');
|
||||
}
|
||||
$h5Url = QytOil::forEnterprise($orderEnterprise)->h5Url($order['mobile']);
|
||||
if(!$h5Url){
|
||||
return $this->oilRenderError('12','0013', '', '生成加油链接失败');
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class Qyt
|
||||
|
||||
public function __construct(array $conf = null)
|
||||
{
|
||||
$this->conf = $conf ?: (array) config('qyt');
|
||||
$this->conf = $conf ?: \app\common\service\QytConfig::get();
|
||||
$this->privateKey = $this->loadPrivateKey($this->conf['private_key'] ?? '');
|
||||
$this->platformPublicKey = $this->loadPublicKey($this->conf['platform_public_key'] ?? '');
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ class Setting extends EnumBasics
|
||||
// 分类页模板
|
||||
const PAGE_CATEGORY_TEMPLATE = 'page_category_template';
|
||||
|
||||
// 企油通对接设置(非敏感项;RSA 密钥仍在 .env,不入库)
|
||||
const QYT = 'qyt';
|
||||
|
||||
/**
|
||||
* 获取订单类型值
|
||||
* @return array
|
||||
@@ -110,6 +113,10 @@ class Setting extends EnumBasics
|
||||
'value' => self::PAGE_CATEGORY_TEMPLATE,
|
||||
'describe' => '分类页模板',
|
||||
],
|
||||
self::QYT => [
|
||||
'value' => self::QYT,
|
||||
'describe' => '企油通对接设置',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企油通企业模型
|
||||
// +----------------------------------------------------------------------
|
||||
// | 一个接入方 appId 下可挂多个企业(见《企油通接口对接文档.md》7.22)。
|
||||
// | 企业级参数:business_license / passageway / institution_id 等存本表;
|
||||
// | 接入方级参数:appId、RSA 私钥、接口域名仍在 .env,私钥不入库。
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\common\model\qyt;
|
||||
|
||||
use cores\BaseModel;
|
||||
|
||||
class Enterprise extends BaseModel
|
||||
{
|
||||
protected $name = 'qyt_enterprise';
|
||||
|
||||
protected $pk = 'enterprise_id';
|
||||
|
||||
/** 通道:配置键 => 企油通 passageway 编码 */
|
||||
const PASSAGEWAY_CODE = [
|
||||
'alipay' => 1,
|
||||
'pingan' => 2,
|
||||
];
|
||||
|
||||
/** 通道名称 */
|
||||
const PASSAGEWAY_NAME = [
|
||||
'alipay' => '支付宝企业码',
|
||||
'pingan' => '平安银行',
|
||||
];
|
||||
|
||||
/** 企业状态(文档 2.6) */
|
||||
const STATUS_TEXT = [
|
||||
'pending' => '待完善',
|
||||
'submitted' => '待审核',
|
||||
'approved' => '审核通过',
|
||||
'rejected' => '审核拒绝',
|
||||
'frozen' => '已冻结',
|
||||
];
|
||||
|
||||
/**
|
||||
* 企业详情
|
||||
* @param int|array $where
|
||||
* @return static|null
|
||||
*/
|
||||
public static function detail($where)
|
||||
{
|
||||
!is_array($where) && $where = ['enterprise_id' => (int) $where];
|
||||
return static::get($where);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取默认企业(合作商未绑定时的兜底)
|
||||
* @return static|null
|
||||
*/
|
||||
public static function getDefault()
|
||||
{
|
||||
return static::where('is_enable', 1)->where('is_default', 1)
|
||||
->order('enterprise_id', 'asc')->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析某个合作商本次下单应使用的企业
|
||||
* 优先级:合作商绑定的企业 -> 默认企业;都取不到返回 null,由调用方拒单
|
||||
* @param int $enterpriseId 合作商绑定的企业ID,0 表示未绑定
|
||||
* @return static|null
|
||||
*/
|
||||
public static function resolve(int $enterpriseId = 0)
|
||||
{
|
||||
if ($enterpriseId > 0) {
|
||||
$one = static::where('enterprise_id', $enterpriseId)->where('is_enable', 1)->find();
|
||||
if ($one) {
|
||||
return $one;
|
||||
}
|
||||
}
|
||||
return static::getDefault();
|
||||
}
|
||||
|
||||
/** 当前通道的企油通 passageway 编码 */
|
||||
public function passagewayCode(): int
|
||||
{
|
||||
return self::PASSAGEWAY_CODE[$this->getData('passageway')] ?? self::PASSAGEWAY_CODE['alipay'];
|
||||
}
|
||||
|
||||
/** 当前通道中文名 */
|
||||
public function passagewayName(): string
|
||||
{
|
||||
return self::PASSAGEWAY_NAME[$this->getData('passageway')] ?? '';
|
||||
}
|
||||
|
||||
/** 当前通道是否要求司机身份证号(平安银行要求实名) */
|
||||
public function requireIdCard(): bool
|
||||
{
|
||||
return $this->getData('passageway') === 'pingan';
|
||||
}
|
||||
|
||||
/** 状态中文(访问器,后台列表直接可用) */
|
||||
public function getStatusTextAttr($value, $data): string
|
||||
{
|
||||
return self::STATUS_TEXT[$data['status'] ?? ''] ?? ($data['status'] ?? '');
|
||||
}
|
||||
|
||||
/** 通道中文(访问器) */
|
||||
public function getPassagewayTextAttr($value, $data): string
|
||||
{
|
||||
return self::PASSAGEWAY_NAME[$data['passageway'] ?? ''] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,25 @@ class Setting extends BaseModel
|
||||
"d) 买家在完成该笔交易(订单状态为“已签收”)后才能得到此笔交易的相应积分,如购买商品参加店铺其他优惠,则优惠的金额部分不享受积分获取;",
|
||||
],
|
||||
],
|
||||
// 企油通对接设置
|
||||
// ⚠️ 只放非敏感项:RSA 私钥与平台公钥始终从 .env 读取,绝不入库,
|
||||
// 避免数据库备份文件等同于资金级密钥副本。见 app\common\service\QytConfig
|
||||
SettingEnum::QYT => [
|
||||
'key' => SettingEnum::QYT,
|
||||
'describe' => '企油通对接设置',
|
||||
// 均为「接入方级」参数。企业级参数(执照号 / 通道 / 机构号)
|
||||
// 在「后台 → 企业管理」里按企业维护,不在这里配。
|
||||
'values' => [
|
||||
'base_url' => '', // 接口域名,不含结尾斜杠
|
||||
'app_id' => '', // 接入方 appId
|
||||
'no_prefix' => 'HM', // 编号前缀默认值(企业可各自覆盖)
|
||||
'voucher_name' => '运单卡', // 7.7 凭证名称默认值(企业可各自覆盖)
|
||||
'h5_base_url' => '', // 7.15 落地页
|
||||
'h5_app_id' => '', // 7.15 免登 appId
|
||||
'consume_notify_url' => '', // 7.10 回调地址(仅记录,实际在企油通后台配)
|
||||
'log' => 1, // 是否记录详细请求/响应日志
|
||||
],
|
||||
],
|
||||
// 分类页模板
|
||||
SettingEnum::PAGE_CATEGORY_TEMPLATE => [
|
||||
'key' => SettingEnum::PAGE_CATEGORY_TEMPLATE,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企油通配置读取
|
||||
// +----------------------------------------------------------------------
|
||||
// | 三层来源,从低到高:
|
||||
// | 1. config/qyt.php —— 读 .env,含【密钥】与接口域名(接入方级)
|
||||
// | 2. 后台「企油通对接设置」—— yoshop_store_setting.key='qyt'(接入方级默认值)
|
||||
// | 3. 企业信息 —— yoshop_qyt_enterprise(企业级:执照号 / 通道 / 机构号 …)
|
||||
// |
|
||||
// | 分级依据见《企油通接口对接文档.md》:appId 与 RSA 密钥属于「接入方(慧明)」,
|
||||
// | 一个 appId 下可挂多个企业;business_license、passageway、institution_id 是企业级。
|
||||
// |
|
||||
// | 敏感项(private_key、platform_public_key)只从 .env 读,任何一层都不能覆盖,
|
||||
// | 数据库备份 / SQL 注入 / 后台账号失窃都拿不到签名私钥。
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\enum\Setting as SettingEnum;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use think\facade\Db;
|
||||
|
||||
class QytConfig
|
||||
{
|
||||
/** 常驻进程内缓存有效期(秒):后台改完最迟这么久生效 */
|
||||
const TTL = 10;
|
||||
|
||||
/** 后台设置可覆盖的接入方级标量项 */
|
||||
const OVERRIDABLE = [
|
||||
'base_url', 'app_id', 'no_prefix', 'voucher_name',
|
||||
'h5_base_url', 'h5_app_id', 'consume_notify_url',
|
||||
];
|
||||
|
||||
/** @var array|null */
|
||||
protected static $cache = null;
|
||||
|
||||
/** @var int */
|
||||
protected static $cacheAt = 0;
|
||||
|
||||
/**
|
||||
* 接入方级配置(不含任何企业信息)
|
||||
*/
|
||||
public static function get(): array
|
||||
{
|
||||
if (self::$cache !== null && (time() - self::$cacheAt) < self::TTL) {
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
$conf = (array) config('qyt');
|
||||
$db = static::dbValues();
|
||||
|
||||
foreach (self::OVERRIDABLE as $k) {
|
||||
if (isset($db[$k]) && trim((string) $db[$k]) !== '') {
|
||||
$conf[$k] = trim((string) $db[$k]);
|
||||
}
|
||||
}
|
||||
// 日志开关是布尔,0 是合法值,不能用非空判断
|
||||
if (isset($db['log'])) {
|
||||
$conf['log'] = (bool) $db['log'];
|
||||
}
|
||||
|
||||
self::$cache = $conf;
|
||||
self::$cacheAt = time();
|
||||
return $conf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 叠加企业级参数,得到某个企业实际使用的完整配置
|
||||
*
|
||||
* @param EnterpriseModel|array $enterprise
|
||||
* @return array 额外带 enterprise_id / enterprise_name / passageway_code / require_id_card
|
||||
*/
|
||||
public static function forEnterprise($enterprise): array
|
||||
{
|
||||
$conf = self::get();
|
||||
$e = $enterprise instanceof EnterpriseModel ? $enterprise->toArray() : (array) $enterprise;
|
||||
|
||||
$passageway = (string) ($e['passageway'] ?? 'alipay');
|
||||
!isset(EnterpriseModel::PASSAGEWAY_CODE[$passageway]) && $passageway = 'alipay';
|
||||
|
||||
$conf['enterprise_id'] = (int) ($e['enterprise_id'] ?? 0);
|
||||
$conf['enterprise_name'] = (string) ($e['company_name'] ?? '');
|
||||
$conf['business_license'] = (string) ($e['business_license'] ?? '');
|
||||
$conf['passageway'] = $passageway;
|
||||
$conf['passageway_code'] = EnterpriseModel::PASSAGEWAY_CODE[$passageway];
|
||||
$conf['passageway_name'] = EnterpriseModel::PASSAGEWAY_NAME[$passageway];
|
||||
$conf['institution_id'] = (string) ($e['institution_id'] ?? '');
|
||||
// 平安银行通道要求实名
|
||||
$conf['require_id_card'] = $passageway === 'pingan';
|
||||
|
||||
// 以下留空则回落接入方级默认值
|
||||
foreach (['app_id', 'no_prefix', 'voucher_name'] as $k) {
|
||||
$v = trim((string) ($e[$k] ?? ''));
|
||||
$v !== '' && $conf[$k] = $v;
|
||||
}
|
||||
|
||||
return $conf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取单项,支持点号:QytConfig::item('h5_base_url')
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public static function item(string $path, $default = null)
|
||||
{
|
||||
$node = self::get();
|
||||
foreach (explode('.', $path) as $seg) {
|
||||
if (!is_array($node) || !array_key_exists($seg, $node)) {
|
||||
return $default;
|
||||
}
|
||||
$node = $node[$seg];
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
/** 清掉进程内缓存(单元测试 / 后台保存后使用) */
|
||||
public static function flush(): void
|
||||
{
|
||||
self::$cache = null;
|
||||
self::$cacheAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读后台设置。直接查表而不用 Setting 模型,是因为常驻命令行进程里
|
||||
* BaseModel::$storeId 不会被初始化(只有 store/api 模块才赋值),
|
||||
* 走模型会查不到数据。本项目为单商城部署,取 store_id 最小的一条即可。
|
||||
*/
|
||||
protected static function dbValues(): array
|
||||
{
|
||||
try {
|
||||
$json = Db::name('store_setting')
|
||||
->where('key', SettingEnum::QYT)
|
||||
->order('store_id', 'asc')
|
||||
->value('values');
|
||||
if (empty($json)) {
|
||||
return [];
|
||||
}
|
||||
$arr = json_decode((string) $json, true);
|
||||
return is_array($arr) ? $arr : [];
|
||||
} catch (\Throwable $e) {
|
||||
// 表不存在 / 库连不上时静默回落到 .env,不能因为读设置失败就中断发放
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,310 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企油通 · 上游接口客户端(支付宝企业码通道)
|
||||
// | 企油通 · 上游接口客户端
|
||||
// +----------------------------------------------------------------------
|
||||
// | 本次实际对接仅 3 个接口:
|
||||
// | 7.7 运单油费下发 createAndSendQuota —— 本类 createAndSendQuota()
|
||||
// | 7.15 H5 跳转地址 (本地签名生成 URL) —— 本类 h5Url()
|
||||
// | 7.10 运单加油明细推送(异步回调) —— app\api\controller\QytNotify
|
||||
// | 通道固定为支付宝企业码(passageway=1),司机手机号 + 姓名必填。
|
||||
// | 多企业:一个接入方 appId 下可挂多个企业,业务接口靠 business_license 区分。
|
||||
// | 用法:QytOil::forEnterprise($enterprise)->createAndSendQuota([...])
|
||||
// | 不带企业的接口(7.22 企业列表)用 new QytOil() 即可。
|
||||
// |
|
||||
// | 已实现:
|
||||
// | 7.2 企业申请创建 openAnAccount
|
||||
// | 7.5 企业账户查询 accountQuery
|
||||
// | 7.7 运单油费下发 createAndSendQuota
|
||||
// | 7.15 H5免登地址 h5Url(本地签名,不发请求)
|
||||
// | 7.22 企业列表查询 enterpriseList
|
||||
// | 7.10 消费回调由 app\api\controller\QytNotify 接收
|
||||
// |
|
||||
// | 通道:alipay=支付宝企业码(passageway=1) / pingan=平安银行(passageway=2),
|
||||
// | 通道开关存在企业信息里。平安通道要求实名,7.7 必传 id_card。
|
||||
// | 金额:对外「元」入参,7.7 内部换算为「分」。
|
||||
// |
|
||||
// | ⚠️ 7.7 是异步接口,返回成功只代表【受理】,真实发放结果需用 7.8
|
||||
// | waybillBatch 按 batch_no 查询(见文档 §5 第 7 条)。当前状态机仍按
|
||||
// | 「受理即完成」处理,7.8 轮询尚未接入。
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\crypto\Qyt as QytCrypto;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
|
||||
class QytOil extends BaseService
|
||||
{
|
||||
// 支付宝企业码通道
|
||||
const PASSAGEWAY_ALIPAY = 1;
|
||||
// 通道标识
|
||||
const CHANNEL_ALIPAY = 'alipay';
|
||||
const CHANNEL_PINGAN = 'pingan';
|
||||
|
||||
/** @var array config('qyt') */
|
||||
/** @var array 合并后的配置(接入方级 + 企业级) */
|
||||
protected $conf;
|
||||
|
||||
/** @var QytCrypto */
|
||||
protected $crypto;
|
||||
|
||||
/** @var EnterpriseModel|array|null 当前企业 */
|
||||
protected $enterprise = null;
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
$this->conf = (array) config('qyt');
|
||||
$this->conf = QytConfig::get();
|
||||
$this->crypto = new QytCrypto($this->conf);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 企业上下文
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 绑定企业后返回实例(业务接口的常规入口)
|
||||
* @param EnterpriseModel|array $enterprise
|
||||
*/
|
||||
public static function forEnterprise($enterprise): self
|
||||
{
|
||||
return (new static())->useEnterprise($enterprise);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换当前企业:执照号 / 通道 / 机构号 / 编号前缀等随之变化
|
||||
* @param EnterpriseModel|array $enterprise
|
||||
*/
|
||||
public function useEnterprise($enterprise): self
|
||||
{
|
||||
$this->enterprise = $enterprise;
|
||||
$this->conf = QytConfig::forEnterprise($enterprise);
|
||||
$this->crypto = new QytCrypto($this->conf);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function enterpriseId(): int
|
||||
{
|
||||
return (int) ($this->conf['enterprise_id'] ?? 0);
|
||||
}
|
||||
|
||||
public function enterpriseName(): string
|
||||
{
|
||||
return (string) ($this->conf['enterprise_name'] ?? '');
|
||||
}
|
||||
|
||||
public function businessLicense(): string
|
||||
{
|
||||
return (string) ($this->conf['business_license'] ?? '');
|
||||
}
|
||||
|
||||
/** 当前通道标识 alipay / pingan */
|
||||
public function channelKey(): string
|
||||
{
|
||||
return (string) ($this->conf['passageway'] ?? self::CHANNEL_ALIPAY);
|
||||
}
|
||||
|
||||
/** 当前通道中文名 */
|
||||
public function channelName(): string
|
||||
{
|
||||
return (string) ($this->conf['passageway_name'] ?? '');
|
||||
}
|
||||
|
||||
/** 当前通道的企油通 passageway 编码:1 支付宝企业码 / 2 平安银行 */
|
||||
public function channelCode(): int
|
||||
{
|
||||
return (int) ($this->conf['passageway_code'] ?? 1);
|
||||
}
|
||||
|
||||
/** 当前通道是否必须上送司机身份证号(平安银行=是) */
|
||||
public function requireIdCard(): bool
|
||||
{
|
||||
return !empty($this->conf['require_id_card']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成企油通全平台唯一编号(加企业前缀),确定性派生 -> 重试保持一致
|
||||
* batch_no / waybill_no / waybill_no_code 都是【全平台唯一】而非接入方内唯一,
|
||||
* 所以各企业建议用不同前缀,避免与其他接入方或兄弟企业撞号。
|
||||
* @param string $type B=batch / W=waybill / C=waybill_no_code
|
||||
* @param string $seed 订单号 / order_id 等业务种子
|
||||
*/
|
||||
public function no(string $type, string $seed): string
|
||||
{
|
||||
return ((string) ($this->conf['no_prefix'] ?: 'HM')) . $type . $seed;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 7.7 运单油费下发(一张订单 = 1 batch / 1 waybill / 1 employee)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* @param array $data ['batch_no','waybill_no','waybill_no_code','mobile','name','amount'(元)]
|
||||
* @param array $data ['batch_no','waybill_no','waybill_no_code','mobile','name','amount'(元),'id_card'(平安通道必填)]
|
||||
* @return array 归一化 ['code'=>'00'|其它, 'data'=>[], 'message'=>'', 'dup_batch'=>bool]
|
||||
*/
|
||||
public function createAndSendQuota(array $data): array
|
||||
{
|
||||
if (($err = $this->requireEnterprise()) !== null) {
|
||||
return $err;
|
||||
}
|
||||
// 支付宝企业码通道下 institution_id 必填;缺了就地失败,不把空值发给企油通
|
||||
if ($this->channelCode() === 1 && $this->conf['institution_id'] === '') {
|
||||
return $this->fail("企业[{$this->enterpriseName()}]未配置机构号(institution_id),支付宝企业码通道无法下发");
|
||||
}
|
||||
|
||||
$idCard = trim((string) ($data['id_card'] ?? ''));
|
||||
if ($this->requireIdCard() && $idCard === '') {
|
||||
return $this->fail("通道[{$this->channelName()}]要求实名,缺少司机身份证号(idCard)");
|
||||
}
|
||||
|
||||
$employee = [
|
||||
'mobile' => $data['mobile'],
|
||||
'name' => $data['name'],
|
||||
'quota_amount' => self::yuanToFen($data['amount']), // 分
|
||||
'voucher_name' => $this->conf['voucher_name'],
|
||||
'effective_start' => '', // 空=当日生效
|
||||
'effective_end' => '', // 空=长期有效
|
||||
'waybill_no_code' => $data['waybill_no_code'],
|
||||
];
|
||||
// 身份证号仅在要求实名的通道上送,避免向不需要的通道多传敏感信息
|
||||
if ($this->requireIdCard()) {
|
||||
$employee['id_card'] = $idCard;
|
||||
}
|
||||
|
||||
$params = [
|
||||
'batch_no' => $data['batch_no'],
|
||||
'business_license' => $this->conf['business_license'],
|
||||
'passageway' => (string) self::PASSAGEWAY_ALIPAY,
|
||||
'institution_id' => $this->conf['institution_id'],
|
||||
'business_license' => $this->businessLicense(),
|
||||
'passageway' => (string) $this->channelCode(),
|
||||
'institution_id' => (string) $this->conf['institution_id'],
|
||||
'waybill_list' => [[
|
||||
'waybill_no' => $data['waybill_no'],
|
||||
'employee_list' => [[
|
||||
'mobile' => $data['mobile'],
|
||||
'name' => $data['name'],
|
||||
'quota_amount' => self::yuanToFen($data['amount']), // 分
|
||||
'voucher_name' => $this->conf['voucher_name'],
|
||||
'effective_start' => '', // 空=当日生效
|
||||
'effective_end' => '', // 空=长期有效
|
||||
'waybill_no_code' => $data['waybill_no_code'],
|
||||
]],
|
||||
'employee_list' => [$employee],
|
||||
]],
|
||||
];
|
||||
|
||||
return $this->post('/enterprise/api/zhyk/createAndSendQuota', $params);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 7.8 运单发放结果查询(7.7 是异步的,真实结果只能从这里拿)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* @param string $batchNo 7.7 提交时用的批次号
|
||||
* @return array data: batch_no / status(1待处理 2处理中 3全部成功 4部分成功 5处理失败) / list[]
|
||||
* list[] 里每条含 waybill_no_code / handle_status(1待处理 2成功 3失败) / error_msg
|
||||
*/
|
||||
public function waybillBatch(string $batchNo): array
|
||||
{
|
||||
$params = ['batch_no' => $batchNo];
|
||||
// business_license 可选;传了会校验归属,能更早发现串企业的问题
|
||||
$this->businessLicense() !== '' && $params['business_license'] = $this->businessLicense();
|
||||
return $this->post('/enterprise/api/zhyk/waybillBatch', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 7.8 的返回里挑出某条运单流水的处理结果
|
||||
* @return array|null ['handle_status'=>int, 'error_msg'=>string, 'remark'=>string]
|
||||
*/
|
||||
public static function pickWaybill(array $data, string $waybillNoCode): ?array
|
||||
{
|
||||
foreach ((array) ($data['list'] ?? []) as $row) {
|
||||
if ((string) ($row['waybill_no_code'] ?? '') === $waybillNoCode) {
|
||||
return [
|
||||
'handle_status' => (int) ($row['handle_status'] ?? 0),
|
||||
'error_msg' => (string) ($row['error_msg'] ?? ''),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 7.2 企业申请创建
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* @param array $data ['business_license','business_license_url','passageway'(如 "1,2"),'company_name','contact_mobile','contact_name']
|
||||
*/
|
||||
public function openAnAccount(array $data): array
|
||||
{
|
||||
return $this->post('/enterprise/api/zhyk/openAnAccount', [
|
||||
'business_license' => $data['business_license'],
|
||||
'business_license_url' => $data['business_license_url'],
|
||||
'passageway' => (string) $data['passageway'],
|
||||
'company_name' => $data['company_name'],
|
||||
'contact_mobile' => $data['contact_mobile'],
|
||||
'contact_name' => $data['contact_name'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 7.5 企业账户信息查询
|
||||
// ================================================================
|
||||
|
||||
public function accountQuery(): array
|
||||
{
|
||||
if (($err = $this->requireEnterprise()) !== null) {
|
||||
return $err;
|
||||
}
|
||||
return $this->post('/enterprise/api/zhyk/accountQuery', [
|
||||
'business_license' => $this->businessLicense(),
|
||||
'passageway' => (string) $this->channelCode(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 7.22 企业列表查询(枚举本 appId 名下企业,需先开通权限,未开通返回 403)
|
||||
// ================================================================
|
||||
|
||||
public function enterpriseList(array $params = []): array
|
||||
{
|
||||
return $this->post('/enterprise/api/enterprise/list', [
|
||||
'page' => $params['page'] ?? 1,
|
||||
'page_size' => $params['page_size'] ?? 100,
|
||||
]);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 7.15 H5 免登加油地址(本地签名生成 URL,不发起接口请求)
|
||||
// 文档:本地址与支付通道无关,落地页由系统按企业实际通道自动判定
|
||||
// ================================================================
|
||||
|
||||
public function h5Url(string $mobile): string
|
||||
{
|
||||
$creditcode = !empty($this->conf['h5_with_creditcode']) ? (string) $this->conf['business_license'] : '';
|
||||
// creditcode 传了则本次登录固定到该企业,且必须一并入签
|
||||
$creditcode = !empty($this->conf['h5_with_creditcode']) ? $this->businessLicense() : '';
|
||||
return $this->crypto->h5Url($mobile, $creditcode);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 内部:统一请求
|
||||
// 内部
|
||||
// ================================================================
|
||||
|
||||
/** 未绑定企业 / 企业没有执照号时的统一拦截 */
|
||||
protected function requireEnterprise(): ?array
|
||||
{
|
||||
if ($this->businessLicense() === '') {
|
||||
return $this->fail('未指定企业或企业缺少统一社会信用代码(business_license)');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 构造一个失败返回,并记日志 */
|
||||
protected function fail(string $message): array
|
||||
{
|
||||
$this->saveLog('[配置错误] ' . $message);
|
||||
return ['code' => 'ERR', 'data' => [], 'message' => $message, 'dup_batch' => false, 'raw' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array ['code' => '00'成功 | 其它, 'data' => array, 'message' => string, 'dup_batch' => bool, 'raw' => array]
|
||||
*/
|
||||
protected function post(string $path, array $bizParams): array
|
||||
{
|
||||
$url = rtrim((string) $this->conf['base_url'], '/') . $path;
|
||||
$url = rtrim((string) $this->conf['base_url'], '/') . $path;
|
||||
$body = $this->crypto->envelope($bizParams);
|
||||
|
||||
$this->saveLog("[请求] {$path} " . json_encode(['url' => $url, 'params' => $bizParams], JSON_UNESCAPED_UNICODE));
|
||||
// 日志脱敏:手机号 / 身份证号不落明文
|
||||
$this->saveLog("[请求] {$path} " . json_encode(
|
||||
['企业' => $this->enterpriseName(), 'url' => $url, 'params' => self::maskDeep($bizParams)],
|
||||
JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$resp = $this->curlPost($url, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$this->saveLog("[响应] {$path} " . $resp);
|
||||
$this->saveLog("[响应] {$path} " . self::maskText($resp));
|
||||
|
||||
$json = json_decode($resp, true);
|
||||
if (!is_array($json)) {
|
||||
@@ -97,15 +312,13 @@ class QytOil extends BaseService
|
||||
}
|
||||
|
||||
// 通用响应结构:{ status: ok|error, data, message, serialNo, sign }
|
||||
$ok = ($json['status'] ?? '') === 'ok';
|
||||
// 【TODO 待对接人确认】是否所有响应都带 sign、验签串如何构造
|
||||
// if ($ok && !empty($json['sign'])) { $ok = $this->crypto->verify($json['data'] ?? [], $json['sign']); }
|
||||
|
||||
$ok = ($json['status'] ?? '') === 'ok';
|
||||
$message = (string) ($json['message'] ?? '');
|
||||
|
||||
// 幂等:batch_no 已存在 —— 视为「已受理」,供 7.7 重试判定
|
||||
$dupBatch = false;
|
||||
if (!$ok && $message !== '') {
|
||||
foreach ((array) (config('qyt.dup_batch_hints') ?: ['已存在', '重复', 'duplicate', 'exist']) as $hint) {
|
||||
foreach ((array) ($this->conf['dup_batch_hints'] ?: ['已存在', '重复', 'duplicate', 'exist']) as $hint) {
|
||||
if (mb_stripos($message, $hint) !== false) {
|
||||
$dupBatch = true;
|
||||
break;
|
||||
@@ -145,9 +358,49 @@ class QytOil extends BaseService
|
||||
return (string) $resp;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 工具
|
||||
// ================================================================
|
||||
// ---------------- 敏感信息脱敏(日志 / 后台展示复用) ----------------
|
||||
|
||||
/** 手机号:138****8000 */
|
||||
public static function maskMobile(string $s): string
|
||||
{
|
||||
return strlen($s) === 11 ? substr($s, 0, 3) . '****' . substr($s, -4) : $s;
|
||||
}
|
||||
|
||||
/** 身份证号:4501**********1234 */
|
||||
public static function maskIdCard(string $s): string
|
||||
{
|
||||
$len = strlen($s);
|
||||
return $len >= 8 ? substr($s, 0, 4) . str_repeat('*', $len - 8) . substr($s, -4) : $s;
|
||||
}
|
||||
|
||||
/** 递归脱敏数组(按键名识别) */
|
||||
public static function maskDeep($data)
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
foreach ($data as $k => $v) {
|
||||
if (is_array($v)) {
|
||||
$data[$k] = self::maskDeep($v);
|
||||
} elseif ($k === 'mobile') {
|
||||
$data[$k] = self::maskMobile((string) $v);
|
||||
} elseif ($k === 'id_card' || $k === 'idCard') {
|
||||
$data[$k] = self::maskIdCard((string) $v);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** 脱敏 JSON 文本里的手机号 / 身份证号(用于原始响应报文落日志) */
|
||||
public static function maskText(string $s): string
|
||||
{
|
||||
$s = preg_replace_callback('/"(id_card|idCard)"\s*:\s*"([^"]+)"/i',
|
||||
function ($m) { return '"' . $m[1] . '":"' . self::maskIdCard($m[2]) . '"'; }, $s);
|
||||
return (string) preg_replace_callback('/"mobile"\s*:\s*"(\d{11})"/',
|
||||
function ($m) { return '"mobile":"' . self::maskMobile($m[1]) . '"'; }, $s);
|
||||
}
|
||||
|
||||
// ---------------- 工具 ----------------
|
||||
|
||||
/** 元 → 分 */
|
||||
public static function yuanToFen($yuan): int
|
||||
@@ -161,17 +414,6 @@ class QytOil extends BaseService
|
||||
return bcdiv((string) (int) $fen, '100', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成企油通全平台唯一编号(加企业前缀),确定性派生 -> 重试保持一致
|
||||
* @param string $type B=batch / W=waybill / C=waybill_no_code
|
||||
* @param string $seed 订单号 / order_id 等业务种子
|
||||
*/
|
||||
public static function genNo(string $type, string $seed): string
|
||||
{
|
||||
$prefix = (string) (config('qyt.no_prefix') ?: 'HM');
|
||||
return $prefix . $type . $seed;
|
||||
}
|
||||
|
||||
/** 记录日志到 runtime/storelog */
|
||||
public function saveLog($data): void
|
||||
{
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
// | OilCard::share 创建 yoshop_partners_order 后 push 'partners_queue',本命令消费:
|
||||
// | 1. 每个 order_id 用 RedisLock 串行化,锁内重读订单状态
|
||||
// | 2. 生成企油通全平台唯一编号(确定性派生,可安全重试)
|
||||
// | 3. 调 7.7 createAndSendQuota;支付宝企业码通道下「受理成功即完成发放」
|
||||
// | 4. 发放成功 -> oil_status=1 / oil_status_zsy=2,投 notify_queue 回调下单平台
|
||||
// | 3. 调 7.7 createAndSendQuota
|
||||
// | 4. 受理成功 -> oil_status_zsy=1「发放中」,交给 qyt:status 轮询 7.8 定结果
|
||||
// |
|
||||
// | ⚠️ 7.7 是异步接口,返回成功只代表【受理】(文档 §5 第 7 条)。本命令不再直接
|
||||
// | 判定发放成功,也不再投 notify_queue —— 那是 QytStatusQueue 拿到 7.8 结果后的事。
|
||||
// |
|
||||
// | 并发/原子性:
|
||||
// | · oil_status_zsy 只在 7.7 结果确定后离开 0;空闲时 reconcile() 补投丢失的队列消息
|
||||
// | · 状态跃迁为条件 UPDATE(... WHERE oil_status_zsy=0),affected=1 才回调,天然幂等
|
||||
// | · 状态跃迁为条件 UPDATE(... WHERE oil_status_zsy=0),affected=1 才继续,天然幂等
|
||||
// | · batch_no 确定性派生 -> 崩溃重试用同一值,依赖企油通按 batch_no 幂等去重
|
||||
// | 启动:php think partners:queue(可多进程;RedisLock 保证同一订单串行)
|
||||
// +----------------------------------------------------------------------
|
||||
@@ -18,6 +21,7 @@ namespace app\console\command;
|
||||
|
||||
use app\common\lock\RedisLock;
|
||||
use app\common\model\partners\Order as OrderModel;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use app\common\service\PartnersBalance;
|
||||
use app\common\service\QueueService;
|
||||
use app\common\service\QytOil;
|
||||
@@ -82,25 +86,57 @@ class PartnersQueue extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 确定性派生编号(含企业前缀),重试时保持一致
|
||||
$seed = $order['partners_order_no'] ?: (string) $order['order_id'];
|
||||
$batchNo = QytOil::genNo('B', $seed);
|
||||
$waybillNo = QytOil::genNo('W', $seed);
|
||||
$waybillNoCode = QytOil::genNo('C', $seed);
|
||||
// 用下单时固化在订单上的企业,而不是当前默认企业:
|
||||
// 之后改了合作商绑定或切了默认企业,在途订单仍按原企业发放
|
||||
$enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']);
|
||||
if (!$enterprise) {
|
||||
$failed = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)
|
||||
->update(['oil_status_zsy' => 3, 'oil_text' => '本单绑定的企油通企业不存在,无法发放', 'update_time' => time()]);
|
||||
$failed && $this->refundBalance($order);
|
||||
return;
|
||||
}
|
||||
$qyt->useEnterprise($enterprise);
|
||||
|
||||
// 1. 编号:库里已生成过就直接沿用,绝不重算。
|
||||
// no_prefix 现在可在后台修改,若重试时重新派生,batch_no 会变成另一个值,
|
||||
// 上游会当成新批次 -> 同一订单被发放两次。
|
||||
// 种子里带上重发次数:后台「重新发放」会清空编号并把 qyt_retry_num +1,
|
||||
// 这样重发能拿到全新的 batch_no(否则企油通按老批次幂等,7.8 永远返回那条旧的失败记录)。
|
||||
$retryNum = (int) $order['qyt_retry_num'];
|
||||
$seed = ($order['partners_order_no'] ?: (string) $order['order_id'])
|
||||
. ($retryNum > 0 ? 'R' . $retryNum : '');
|
||||
$batchNo = $order['batch_no'] ?: $qyt->no('B', $seed);
|
||||
$waybillNo = $order['waybill_no'] ?: $qyt->no('W', $seed);
|
||||
$waybillNoCode = $order['waybill_no_code'] ?: $qyt->no('C', $seed);
|
||||
$name = trim((string) $order['driver_name']);
|
||||
$idCard = trim((string) $order['id_card']);
|
||||
if ($name === '' || $order['mobile'] === '') {
|
||||
$failed = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)
|
||||
->update(['oil_status_zsy' => 3, 'oil_text' => '缺司机姓名/手机号,无法发放', 'update_time' => time()]);
|
||||
$failed && $this->refundBalance($order);
|
||||
return;
|
||||
}
|
||||
// 通道切换后,下单时不需要身份证的老单可能落到实名通道上:
|
||||
// 直接判失败退款,不做无意义重试(重试也永远补不到身份证号)
|
||||
if ($qyt->requireIdCard() && $idCard === '') {
|
||||
$failed = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)
|
||||
->update([
|
||||
'oil_status_zsy' => 3,
|
||||
'oil_text' => '当前通道(' . $qyt->channelName() . ')要求实名,该单缺司机身份证号',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$failed && $this->refundBalance($order);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 先落编号(状态不动,仍为 0)
|
||||
// 2. 先落编号 + 本单实际使用的支付通道(状态不动,仍为 0)
|
||||
// 通道随单落库:切换通道后老单仍能追溯当时走的是哪条通道
|
||||
Db::name('partners_order')->where('order_id', $orderId)->update([
|
||||
'batch_no' => $batchNo,
|
||||
'waybill_no' => $waybillNo,
|
||||
'waybill_no_code' => $waybillNoCode,
|
||||
'oil_text' => '发放中',
|
||||
'passageway' => $qyt->channelKey(),
|
||||
'oil_text' => '发放中(' . $qyt->channelName() . ')',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
@@ -111,6 +147,7 @@ class PartnersQueue extends Command
|
||||
'waybill_no_code' => $waybillNoCode,
|
||||
'mobile' => $order['mobile'],
|
||||
'name' => $name,
|
||||
'id_card' => $idCard,
|
||||
'amount' => $order['total_price'],
|
||||
]);
|
||||
|
||||
@@ -137,24 +174,20 @@ class PartnersQueue extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 受理成功 = 发放成功(支付宝企业码通道);条件跃迁 0 -> 2,胜出者回调下单平台
|
||||
// 4. 受理成功【不等于】发放成功:7.7 是异步接口,真实结果要用 7.8 按 batch_no 查。
|
||||
// 这里只把状态推进到「发放中」,由 qyt:status 轮询 7.8 后才决定成功/失败并回调下游。
|
||||
$affected = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)->update([
|
||||
'oil_status' => 1,
|
||||
'oil_status_zsy' => 2,
|
||||
'oil_time' => time(),
|
||||
'oil_status_zsy' => 1,
|
||||
'shareNo' => $waybillNoCode,
|
||||
'oil_text' => $res['data']['msg'] ?? '发放成功',
|
||||
'oil_text' => '已受理,等待企油通发放结果'
|
||||
. (empty($res['dup_batch']) ? '' : '(batch 已存在,按已受理处理)'),
|
||||
'qyt_query_time' => 0,
|
||||
'qyt_query_num' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if ($affected) {
|
||||
$this->queueService->push('notify_queue', json_encode([
|
||||
'order_id' => $orderId,
|
||||
'type' => 'add',
|
||||
'optType' => 'share_oil',
|
||||
'retry_count' => 0,
|
||||
], 320));
|
||||
$qyt->saveLog("7.7 发放成功 order={$orderId} batch_no={$batchNo}"
|
||||
. (empty($res['dup_batch']) ? '' : '(batch 已存在,按已发放处理)'));
|
||||
$qyt->saveLog("7.7 已受理 order={$orderId} batch_no={$batchNo} 通道={$qyt->channelName()}"
|
||||
. (empty($res['dup_batch']) ? '' : '(batch 已存在,按已受理处理)'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 不改状态(仍为 0),交由 reconcile 兜底重投
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企油通「运单发放结果」轮询 (7.8 waybillBatch)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 为什么需要它:7.7 运单油费下发是【异步】接口,返回成功只代表受理,
|
||||
// | 真实发放结果必须按 batch_no 调 7.8 查(《企油通接口对接文档.md》§5 第 7 条)。
|
||||
// |
|
||||
// | 状态流转(oil_status_zsy):
|
||||
// | 0 未提交 --PartnersQueue 调7.7受理--> 1 发放中 --本命令查7.8--> 2 成功 / 3 失败
|
||||
// |
|
||||
// | 7.8 单条 handle_status: 1 待处理 / 2 成功 / 3 失败
|
||||
// | 7.8 整批 status: 1 待处理 / 2 处理中 / 3 全部成功 / 4 部分成功 / 5 处理失败
|
||||
// |
|
||||
// | 并发/原子性:
|
||||
// | · 每个 order_id 用 RedisLock 串行化,可多进程运行
|
||||
// | · 状态跃迁是条件 UPDATE(... WHERE oil_status_zsy=1),affected=1 才回调/退款,天然幂等
|
||||
// |
|
||||
// | ⚠️ 绝不因为「查不到结果」而自动判失败退款:
|
||||
// | 钱可能已经发到司机手里,自动退款会造成资损。查询失败或长期待处理只会持续
|
||||
// | 重试并在日志里告警,由人工介入处理。
|
||||
// |
|
||||
// | 启动:php think qyt:status
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\console\command;
|
||||
|
||||
use app\common\lock\RedisLock;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use app\common\service\PartnersBalance;
|
||||
use app\common\service\QueueService;
|
||||
use app\common\service\QytOil;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
|
||||
class QytStatusQueue extends Command
|
||||
{
|
||||
/** 每轮扫描的最大订单数 */
|
||||
const BATCH_LIMIT = 100;
|
||||
|
||||
/** 空转时的休眠秒数 */
|
||||
const IDLE_SLEEP = 5;
|
||||
|
||||
/** 查询次数达到该值后仍未定论,视为异常滞留,写告警日志 */
|
||||
const STUCK_NUM = 100;
|
||||
|
||||
/** @var QueueService */
|
||||
protected $queueService;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qyt:status')
|
||||
->setDescription('轮询企油通 7.8 运单发放结果,确定订单最终发放态');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$this->queueService = new QueueService();
|
||||
while (true) {
|
||||
$handled = 0;
|
||||
try {
|
||||
$handled = $this->scan();
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('轮询主循环异常:' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
}
|
||||
// 有活干就立刻进入下一轮,没有就歇一会儿
|
||||
$handled === 0 && sleep(self::IDLE_SLEEP);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描一批「发放中」的订单并查询结果
|
||||
* @return int 本轮实际处理的订单数
|
||||
*/
|
||||
protected function scan(): int
|
||||
{
|
||||
$now = time();
|
||||
// 按退避间隔挑出到点该查的单:qyt_query_time + interval(num) <= now
|
||||
$ids = Db::name('partners_order')
|
||||
->where('oil_status_zsy', 1)
|
||||
->where('order_status', 10)
|
||||
->whereRaw('qyt_query_time + ' . $this->intervalSql() . ' <= ' . $now)
|
||||
->order('qyt_query_time', 'asc')
|
||||
->limit(self::BATCH_LIMIT)
|
||||
->column('order_id');
|
||||
|
||||
foreach ($ids as $orderId) {
|
||||
$this->handleOne((int) $orderId);
|
||||
}
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退避间隔(秒)的 SQL 表达式:查得越久间隔越长,避免高频打上游
|
||||
* 前 10 次 30s,10~30 次 120s,之后 600s
|
||||
*/
|
||||
protected function intervalSql(): string
|
||||
{
|
||||
return 'CASE WHEN qyt_query_num < 10 THEN 30 WHEN qyt_query_num < 30 THEN 120 ELSE 600 END';
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个订单的发放结果
|
||||
*/
|
||||
protected function handleOne(int $orderId): void
|
||||
{
|
||||
$lock = new RedisLock('qyt:status:' . $orderId, 60);
|
||||
if (!$lock->acquireLock()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$order = Db::name('partners_order')->where('order_id', $orderId)->find();
|
||||
// 锁内重读:可能已被其它进程处理完
|
||||
if (!$order || (int) $order['oil_status_zsy'] !== 1) {
|
||||
return;
|
||||
}
|
||||
if (empty($order['batch_no']) || empty($order['waybill_no_code'])) {
|
||||
$this->log("order={$orderId} 处于发放中但缺少 batch_no/waybill_no_code,无法查询,需人工核对");
|
||||
$this->touch($orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
$enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']);
|
||||
if (!$enterprise) {
|
||||
// 不判失败:单子可能真的已经发出去了,只是企业记录被删了
|
||||
$this->log("order={$orderId} 绑定的企业不存在,无法查询 7.8,需人工核对");
|
||||
$this->touch($orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
$qyt = QytOil::forEnterprise($enterprise);
|
||||
$res = $qyt->waybillBatch((string) $order['batch_no']);
|
||||
|
||||
// 记一次查询(无论成败都要更新时间,否则会死循环打上游)
|
||||
$this->touch($orderId);
|
||||
|
||||
if ($res['code'] !== '00') {
|
||||
$this->log("order={$orderId} 7.8 查询失败:{$res['message']}(保持发放中,稍后重试)");
|
||||
$this->alertIfStuck($order, $qyt);
|
||||
return;
|
||||
}
|
||||
|
||||
$batchStatus = (int) ($res['data']['status'] ?? 0);
|
||||
$row = QytOil::pickWaybill($res['data'], (string) $order['waybill_no_code']);
|
||||
|
||||
// 整批处理失败,且明细里没有本条 -> 判失败
|
||||
if (!$row && $batchStatus === 5) {
|
||||
$this->markFailed($order, '企油通返回整批处理失败', $qyt);
|
||||
return;
|
||||
}
|
||||
// 明细里还没有本条:批次还在排队,继续等
|
||||
if (!$row) {
|
||||
$this->alertIfStuck($order, $qyt);
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($row['handle_status']) {
|
||||
case 2: // 成功
|
||||
$this->markSuccess($order, $row['remark'] ?: '发放成功', $qyt);
|
||||
break;
|
||||
case 3: // 失败
|
||||
$this->markFailed($order, $row['error_msg'] ?: '企油通发放失败', $qyt);
|
||||
break;
|
||||
default: // 1 待处理,或未知值 -> 继续等
|
||||
$this->alertIfStuck($order, $qyt);
|
||||
break;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->log("order={$orderId} 轮询异常:" . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
} finally {
|
||||
$lock->releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放成功:条件跃迁 1 -> 2,胜出者才回调下单平台
|
||||
*/
|
||||
protected function markSuccess(array $order, string $text, QytOil $qyt): void
|
||||
{
|
||||
$affected = Db::name('partners_order')
|
||||
->where('order_id', $order['order_id'])
|
||||
->where('oil_status_zsy', 1)
|
||||
->update([
|
||||
'oil_status' => 1,
|
||||
'oil_status_zsy' => 2,
|
||||
'oil_time' => time(),
|
||||
'oil_text' => $text,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if (!$affected) {
|
||||
return;
|
||||
}
|
||||
$this->queueService->push('notify_queue', json_encode([
|
||||
'order_id' => $order['order_id'],
|
||||
'type' => 'add',
|
||||
'optType' => 'share_oil',
|
||||
'retry_count' => 0,
|
||||
], 320));
|
||||
$qyt->saveLog("7.8 确认发放成功 order={$order['order_id']} batch_no={$order['batch_no']}"
|
||||
. " 查询次数={$order['qyt_query_num']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放失败:条件跃迁 1 -> 3,胜出者才退款(保证只退一次)
|
||||
*/
|
||||
protected function markFailed(array $order, string $reason, QytOil $qyt): void
|
||||
{
|
||||
$affected = Db::name('partners_order')
|
||||
->where('order_id', $order['order_id'])
|
||||
->where('oil_status_zsy', 1)
|
||||
->update([
|
||||
'oil_status_zsy' => 3,
|
||||
'oil_text' => '发放失败:' . $reason,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if (!$affected) {
|
||||
return;
|
||||
}
|
||||
PartnersBalance::refund(
|
||||
(int) $order['partners_id'],
|
||||
(float) $order['total_price'],
|
||||
(string) $order['partners_order_no'],
|
||||
(int) $order['order_id'],
|
||||
(int) $order['store_id']
|
||||
);
|
||||
$qyt->saveLog("7.8 确认发放失败 order={$order['order_id']} 原因={$reason},已回补合作商余额 {$order['total_price']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 长期拿不到结果时告警。只写日志,不改状态、不退款
|
||||
*/
|
||||
protected function alertIfStuck(array $order, QytOil $qyt): void
|
||||
{
|
||||
$num = (int) $order['qyt_query_num'] + 1;
|
||||
if ($num < self::STUCK_NUM || $num % 20 !== 0) {
|
||||
return;
|
||||
}
|
||||
$qyt->saveLog("【告警】order={$order['order_id']} batch_no={$order['batch_no']} 已查询 {$num} 次"
|
||||
. '仍未拿到 7.8 结果,长期处于「发放中」。不会自动判失败(款可能已发出),请人工到企油通后台核对。');
|
||||
}
|
||||
|
||||
/** 记录一次查询:更新时间与次数,驱动退避 */
|
||||
protected function touch(int $orderId): void
|
||||
{
|
||||
Db::name('partners_order')->where('order_id', $orderId)->update([
|
||||
'qyt_query_time' => time(),
|
||||
'qyt_query_num' => Db::raw('qyt_query_num + 1'),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function log(string $text): void
|
||||
{
|
||||
(new QytOil())->saveLog('qyt:status ' . $text);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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\QytOil;
|
||||
use app\store\controller\Controller;
|
||||
use app\store\model\order\QrCode as QrCodeModel;
|
||||
@@ -71,7 +72,15 @@ class Order extends Controller
|
||||
if ($order['oil_status'] != 1) {
|
||||
return $this->renderError('该单尚未发放成功,无法获取加油地址');
|
||||
}
|
||||
$h5Url = (new QytOil())->h5Url((string) $order['mobile']);
|
||||
// 免登链接要带本单企业的 creditcode(营业执照号),把登录固定到该企业
|
||||
$enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']);
|
||||
if (!$enterprise) {
|
||||
return $this->renderError('本单绑定的企油通企业不存在,无法生成加油地址');
|
||||
}
|
||||
if ($enterprise['status'] !== 'approved') {
|
||||
return $this->renderError('企业「' . $enterprise['company_name'] . '」尚未通过企油通审核,无法生成加油地址');
|
||||
}
|
||||
$h5Url = QytOil::forEnterprise($enterprise)->h5Url((string) $order['mobile']);
|
||||
if (!$h5Url) {
|
||||
return $this->renderError('生成加油地址失败,请检查企油通配置');
|
||||
}
|
||||
@@ -115,11 +124,23 @@ class Order extends Controller
|
||||
if ($logId === false) {
|
||||
return $this->renderError('合作商余额不足,请先充值后再重发');
|
||||
}
|
||||
// 仅允许从「发放失败(3)」重置为「未提交(0)」,避免与正在处理的单冲突
|
||||
// 仅允许从「发放失败(3)」重置为「未提交(0)」,避免与「发放中(1)」的单冲突。
|
||||
// 同时清空编号并把重发次数 +1:企油通按 batch_no 幂等,沿用老批次的话
|
||||
// 7.8 会一直返回那条旧的失败记录,重发永远不可能成功。
|
||||
$reset = Db::name('partners_order')->where('order_id', $orderId)
|
||||
->where('oil_status', 0)
|
||||
->where('oil_status_zsy', 3)
|
||||
->update(['oil_status_zsy' => 0, 'oil_text' => '后台重新发放', 'update_time' => time()]);
|
||||
->update([
|
||||
'oil_status_zsy' => 0,
|
||||
'batch_no' => '',
|
||||
'waybill_no' => '',
|
||||
'waybill_no_code' => '',
|
||||
'qyt_retry_num' => Db::raw('qyt_retry_num + 1'),
|
||||
'qyt_query_time' => 0,
|
||||
'qyt_query_num' => 0,
|
||||
'oil_text' => '后台重新发放',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if (!$reset) {
|
||||
// 状态已被其它进程改变 -> 回补刚扣的余额
|
||||
PartnersBalance::refund(
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 后台 · 企油通企业管理
|
||||
// +----------------------------------------------------------------------
|
||||
// | list/detail/add/edit/delete/setDefault 本地维护
|
||||
// | apply() 7.2 企业申请创建(提交到企油通)
|
||||
// | syncAccount() 7.5 企业账户信息查询(回填开户状态)
|
||||
// | syncList() 7.22 企业列表查询(拉取本 appId 名下企业,需先开通权限)
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\store\controller\qyt;
|
||||
|
||||
use app\common\model\qyt\Enterprise as CommonEnterprise;
|
||||
use app\common\service\QytOil;
|
||||
use app\store\controller\Controller;
|
||||
use app\store\model\qyt\Enterprise as EnterpriseModel;
|
||||
use think\facade\Db;
|
||||
use think\response\Json;
|
||||
|
||||
class Enterprise extends Controller
|
||||
{
|
||||
/**
|
||||
* 企业列表
|
||||
*/
|
||||
public function list(): Json
|
||||
{
|
||||
$model = new EnterpriseModel;
|
||||
$list = $model->getList($this->request->param());
|
||||
return $this->renderSuccess(compact('list'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉选项(合作商绑定企业时用)
|
||||
*/
|
||||
public function options(): Json
|
||||
{
|
||||
return $this->renderSuccess(['list' => EnterpriseModel::options()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业详情
|
||||
*/
|
||||
public function detail(int $enterpriseId): Json
|
||||
{
|
||||
$detail = EnterpriseModel::detail($enterpriseId);
|
||||
if (!$detail) {
|
||||
return $this->renderError('企业不存在');
|
||||
}
|
||||
return $this->renderSuccess(compact('detail'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增企业
|
||||
*/
|
||||
public function add(): Json
|
||||
{
|
||||
$model = new EnterpriseModel;
|
||||
if ($model->add($this->postForm())) {
|
||||
return $this->renderSuccess('添加成功');
|
||||
}
|
||||
return $this->renderError($model->getError() ?: '添加失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑企业
|
||||
*/
|
||||
public function edit(int $enterpriseId): Json
|
||||
{
|
||||
$model = EnterpriseModel::detail($enterpriseId);
|
||||
if (!$model) {
|
||||
return $this->renderError('企业不存在');
|
||||
}
|
||||
if ($model->edit($this->postForm())) {
|
||||
return $this->renderSuccess('更新成功');
|
||||
}
|
||||
return $this->renderError($model->getError() ?: '更新失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除企业
|
||||
*/
|
||||
public function delete(int $enterpriseId): Json
|
||||
{
|
||||
$model = EnterpriseModel::detail($enterpriseId);
|
||||
if (!$model) {
|
||||
return $this->renderError('企业不存在');
|
||||
}
|
||||
if ($model->remove()) {
|
||||
return $this->renderSuccess('删除成功');
|
||||
}
|
||||
return $this->renderError($model->getError() ?: '删除失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设为默认企业(合作商未绑定企业时的兜底)
|
||||
*/
|
||||
public function setDefault(int $enterpriseId): Json
|
||||
{
|
||||
$model = EnterpriseModel::detail($enterpriseId);
|
||||
if (!$model) {
|
||||
return $this->renderError('企业不存在');
|
||||
}
|
||||
if ($model->setDefault()) {
|
||||
return $this->renderSuccess('设置成功');
|
||||
}
|
||||
return $this->renderError($model->getError() ?: '设置失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 7.2 企业申请创建:把本地企业提交到企油通开户
|
||||
*/
|
||||
public function apply(int $enterpriseId): Json
|
||||
{
|
||||
$model = EnterpriseModel::detail($enterpriseId);
|
||||
if (!$model) {
|
||||
return $this->renderError('企业不存在');
|
||||
}
|
||||
if (empty($model['business_license_url'])) {
|
||||
return $this->renderError('请先上传营业执照照片地址(7.2 必填)');
|
||||
}
|
||||
// 申请可同时开通多个通道,文档要求逗号分隔的编码,如 "1,2"
|
||||
$codes = $model['bound_passageway'] !== ''
|
||||
? $model['bound_passageway']
|
||||
: (string) CommonEnterprise::PASSAGEWAY_CODE[$model['passageway']];
|
||||
|
||||
$res = QytOil::forEnterprise($model)->openAnAccount([
|
||||
'business_license' => $model['business_license'],
|
||||
'business_license_url' => $model['business_license_url'],
|
||||
'passageway' => $codes,
|
||||
'company_name' => $model['company_name'],
|
||||
'contact_mobile' => $model['contact_mobile'],
|
||||
'contact_name' => $model['contact_name'],
|
||||
]);
|
||||
|
||||
if ($res['code'] !== '00') {
|
||||
$this->saveSync($model, '申请失败:' . $res['message']);
|
||||
return $this->renderError('企油通返回:' . ($res['message'] ?: '申请失败'));
|
||||
}
|
||||
// status: 0 失败 / 1 待审核 / 2 审核成功
|
||||
$status = (int) ($res['data']['status'] ?? 0);
|
||||
$map = [0 => 'rejected', 1 => 'submitted', 2 => 'approved'];
|
||||
$model->save([
|
||||
'enterprise_number' => (string) ($res['data']['enterprise_number'] ?? $model['enterprise_number']),
|
||||
'status' => $map[$status] ?? 'submitted',
|
||||
'bound_passageway' => $codes,
|
||||
'sync_time' => time(),
|
||||
'sync_text' => (string) ($res['data']['msg'] ?? '已提交'),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
return $this->renderSuccess((string) ($res['data']['msg'] ?? '已提交,等待企油通审核'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 7.5 企业账户信息查询:回填开户行 / 账户 / 开户状态
|
||||
*/
|
||||
public function syncAccount(int $enterpriseId): Json
|
||||
{
|
||||
$model = EnterpriseModel::detail($enterpriseId);
|
||||
if (!$model) {
|
||||
return $this->renderError('企业不存在');
|
||||
}
|
||||
$res = QytOil::forEnterprise($model)->accountQuery();
|
||||
if ($res['code'] !== '00') {
|
||||
$this->saveSync($model, '账户查询失败:' . $res['message']);
|
||||
return $this->renderError('企油通返回:' . ($res['message'] ?: '查询失败'));
|
||||
}
|
||||
$d = $res['data'];
|
||||
$model->save([
|
||||
'account' => (string) ($d['account'] ?? ''),
|
||||
'account_bank' => (string) ($d['account_bank'] ?? ''),
|
||||
'account_name' => (string) ($d['account_name'] ?? ''),
|
||||
'account_status' => (int) ($d['status'] ?? 0),
|
||||
'company_name' => $d['enterprise_name'] ?? $model['company_name'],
|
||||
'sync_time' => time(),
|
||||
'sync_text' => (int) ($d['status'] ?? 0) === 2 ? '开户成功' : '审核中',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
return $this->renderSuccess('同步成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 7.22 企业列表查询:拉取本 appId 名下企业,本地没有的自动建档
|
||||
* 需先向企油通开通「企业列表查询权限」,未开通会返回 403
|
||||
*/
|
||||
public function syncList(): Json
|
||||
{
|
||||
$res = (new QytOil())->enterpriseList(['page' => 1, 'page_size' => 100]);
|
||||
if ($res['code'] !== '00') {
|
||||
return $this->renderError('企油通返回:' . ($res['message'] ?: '查询失败')
|
||||
. '(该接口需先开通企业列表查询权限)');
|
||||
}
|
||||
$rows = (array) ($res['data']['list'] ?? []);
|
||||
$added = $updated = 0;
|
||||
foreach ($rows as $row) {
|
||||
$license = trim((string) ($row['business_license'] ?? ''));
|
||||
if ($license === '') {
|
||||
continue;
|
||||
}
|
||||
// 已绑定通道数组如 [1,2],转成本地逗号串
|
||||
$bound = implode(',', array_map('intval', (array) ($row['passageway'] ?? [])));
|
||||
$exist = Db::name('qyt_enterprise')->where('business_license', $license)->find();
|
||||
if ($exist) {
|
||||
Db::name('qyt_enterprise')->where('enterprise_id', $exist['enterprise_id'])->update([
|
||||
'company_name' => (string) ($row['enterprise_name'] ?? $exist['company_name']),
|
||||
'status' => (string) ($row['status'] ?? $exist['status']),
|
||||
'bound_passageway' => $bound ?: $exist['bound_passageway'],
|
||||
'sync_time' => time(),
|
||||
'sync_text' => '已从企业列表同步',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
// 新企业建档:默认走支付宝企业码,机构号需人工补填后才能下发
|
||||
Db::name('qyt_enterprise')->insert([
|
||||
'company_name' => (string) ($row['enterprise_name'] ?? ''),
|
||||
'business_license' => $license,
|
||||
'passageway' => in_array(2, (array) ($row['passageway'] ?? []), true) && !in_array(1, (array) ($row['passageway'] ?? []), true)
|
||||
? 'pingan' : 'alipay',
|
||||
'bound_passageway' => $bound,
|
||||
'status' => (string) ($row['status'] ?? 'pending'),
|
||||
'is_enable' => 1,
|
||||
'is_default' => 0,
|
||||
'sort' => 100,
|
||||
'sync_time' => time(),
|
||||
'sync_text' => '由企业列表自动建档,请补填机构号',
|
||||
'store_id' => $this->storeId,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$added++;
|
||||
}
|
||||
return $this->renderSuccess("同步完成:新增 {$added} 家,更新 {$updated} 家");
|
||||
}
|
||||
|
||||
/** 记录同步结果 */
|
||||
private function saveSync($model, string $text): void
|
||||
{
|
||||
$model->save(['sync_time' => time(), 'sync_text' => $text, 'update_time' => time()]);
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,37 @@ class Setting extends SettingModel
|
||||
$callback = [
|
||||
'store' => function ($values) {
|
||||
return $this->validStore($values);
|
||||
}
|
||||
},
|
||||
SettingEnum::QYT => function ($values) {
|
||||
return $this->validQyt($values);
|
||||
},
|
||||
];
|
||||
// 验证商城设置
|
||||
return isset($callback[$key]) ? $callback[$key]($values) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证企油通对接设置
|
||||
* 只做「填错了会让发放整体失败」的强校验,留空一律回落 .env,不强制必填
|
||||
* @param array $values
|
||||
* @return bool
|
||||
*/
|
||||
private function validQyt(array $values)
|
||||
{
|
||||
foreach (['base_url' => '接口域名', 'h5_base_url' => 'H5落地页地址'] as $key => $name) {
|
||||
$url = trim((string) ($values[$key] ?? ''));
|
||||
if ($url !== '' && !preg_match('#^https?://#i', $url)) {
|
||||
$this->error = "{$name}必须以 http:// 或 https:// 开头";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (rtrim(trim((string) ($values['base_url'] ?? '')), '/') !== trim((string) ($values['base_url'] ?? ''))) {
|
||||
$this->error = '接口域名结尾不要带斜杠';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证商城设置
|
||||
* @param array $values
|
||||
|
||||
@@ -13,6 +13,7 @@ declare (strict_types=1);
|
||||
namespace app\store\model\partners;
|
||||
|
||||
use app\common\model\partners\Order as OrderModel;
|
||||
use app\common\service\QytOil;
|
||||
|
||||
/**
|
||||
* 合作商订单
|
||||
@@ -21,6 +22,18 @@ use app\common\model\partners\Order as OrderModel;
|
||||
*/
|
||||
class Order extends OrderModel
|
||||
{
|
||||
/**
|
||||
* 身份证号脱敏(仅后台展示层)
|
||||
* 注意:本访问器只挂在 store 模型上;发放流程用的是 app\common\model\partners\Order,
|
||||
* 取到的仍是明文,不会把打码后的身份证号发给企油通。
|
||||
* @param string|null $value
|
||||
* @return string
|
||||
*/
|
||||
public function getIdCardAttr($value): string
|
||||
{
|
||||
return QytOil::maskIdCard((string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取余额变动明细列表
|
||||
* @param array $param
|
||||
@@ -64,7 +77,7 @@ class Order extends OrderModel
|
||||
'driver_name' => '', // 司机姓名
|
||||
'orderNo' => '', // 单号:油卡编号/下单平台单号/批次号/运单号
|
||||
'oil_status' => -1, // 分油(发放)状态
|
||||
'oil_status_zsy' => -1, // 企油通发放态 0未提交 2成功 3失败
|
||||
'oil_status_zsy' => -1, // 企油通发放态 0未提交 1发放中 2成功 3失败
|
||||
'use_status' => -1, // 加油核销状态
|
||||
'betweenTime' => [] // 起止时间
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 后台 · 企油通企业模型
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\store\model\qyt;
|
||||
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use think\facade\Db;
|
||||
|
||||
class Enterprise extends EnterpriseModel
|
||||
{
|
||||
/**
|
||||
* 列表
|
||||
*/
|
||||
public function getList(array $param = [])
|
||||
{
|
||||
$filter = [];
|
||||
if (!empty($param['name'])) {
|
||||
$filter[] = ['company_name|business_license', 'like', '%' . trim($param['name']) . '%'];
|
||||
}
|
||||
if (isset($param['status']) && $param['status'] !== '' && $param['status'] !== 'all') {
|
||||
$filter[] = ['status', '=', $param['status']];
|
||||
}
|
||||
if (isset($param['passageway']) && $param['passageway'] !== '' && $param['passageway'] !== 'all') {
|
||||
$filter[] = ['passageway', '=', $param['passageway']];
|
||||
}
|
||||
return $this->where($filter)
|
||||
->order(['is_default' => 'desc', 'sort' => 'asc', 'enterprise_id' => 'desc'])
|
||||
->paginate(15);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部启用中的企业(下拉选择用)
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return (new static)->where('is_enable', 1)
|
||||
->order(['is_default' => 'desc', 'sort' => 'asc'])
|
||||
->field(['enterprise_id', 'company_name', 'business_license', 'passageway', 'status', 'is_default'])
|
||||
->select()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*/
|
||||
public function add(array $data): bool
|
||||
{
|
||||
if (!$this->validForm($data)) {
|
||||
return false;
|
||||
}
|
||||
$data['store_id'] = self::$storeId;
|
||||
$data['create_time'] = time();
|
||||
$data['update_time'] = time();
|
||||
return $this->transaction(function () use ($data) {
|
||||
$ok = $this->save($data) !== false;
|
||||
// 设为默认时,把其他企业的默认标记清掉
|
||||
$ok && !empty($data['is_default']) && $this->clearOtherDefault((int) $this->enterprise_id);
|
||||
return $ok;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
public function edit(array $data): bool
|
||||
{
|
||||
if (!$this->validForm($data, (int) $this['enterprise_id'])) {
|
||||
return false;
|
||||
}
|
||||
$data['update_time'] = time();
|
||||
return $this->transaction(function () use ($data) {
|
||||
$ok = $this->save($data) !== false;
|
||||
$ok && !empty($data['is_default']) && $this->clearOtherDefault((int) $this['enterprise_id']);
|
||||
return $ok;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除:已产生订单的企业不允许删除,改用停用
|
||||
*/
|
||||
public function remove(): bool
|
||||
{
|
||||
$used = Db::name('partners_order')->where('qyt_enterprise_id', $this['enterprise_id'])->count();
|
||||
if ($used > 0) {
|
||||
$this->error = "该企业已产生 {$used} 笔订单,不能删除;如需停用请把「启用」关掉";
|
||||
return false;
|
||||
}
|
||||
$bound = Db::name('partners')->where('qyt_enterprise_id', $this['enterprise_id'])->count();
|
||||
if ($bound > 0) {
|
||||
$this->error = "还有 {$bound} 个合作商绑定了该企业,请先改绑";
|
||||
return false;
|
||||
}
|
||||
if ($this['is_default']) {
|
||||
$this->error = '默认企业不能删除,请先把默认设到其他企业';
|
||||
return false;
|
||||
}
|
||||
return $this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设为默认
|
||||
*/
|
||||
public function setDefault(): bool
|
||||
{
|
||||
if (!$this['is_enable']) {
|
||||
$this->error = '已停用的企业不能设为默认';
|
||||
return false;
|
||||
}
|
||||
return $this->transaction(function () {
|
||||
$this->save(['is_default' => 1, 'update_time' => time()]);
|
||||
$this->clearOtherDefault((int) $this['enterprise_id']);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 清掉其他企业的默认标记 */
|
||||
protected function clearOtherDefault(int $keepId): void
|
||||
{
|
||||
Db::name('qyt_enterprise')
|
||||
->where('enterprise_id', '<>', $keepId)
|
||||
->where('is_default', 1)
|
||||
->update(['is_default' => 0, 'update_time' => time()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单校验
|
||||
*/
|
||||
protected function validForm(array $data, int $selfId = 0): bool
|
||||
{
|
||||
if (empty($data['company_name'])) {
|
||||
$this->error = '请填写企业名称';
|
||||
return false;
|
||||
}
|
||||
$license = trim((string) ($data['business_license'] ?? ''));
|
||||
if ($license === '') {
|
||||
$this->error = '请填写统一社会信用代码';
|
||||
return false;
|
||||
}
|
||||
if (!preg_match('/^[0-9A-Z]{18}$/', $license)) {
|
||||
$this->error = '统一社会信用代码应为 18 位大写字母或数字,请检查是否混入了空格或括号';
|
||||
return false;
|
||||
}
|
||||
$exist = Db::name('qyt_enterprise')->where('business_license', $license);
|
||||
$selfId > 0 && $exist->where('enterprise_id', '<>', $selfId);
|
||||
if ($exist->count() > 0) {
|
||||
$this->error = '该统一社会信用代码已存在';
|
||||
return false;
|
||||
}
|
||||
if (!isset(self::PASSAGEWAY_CODE[$data['passageway'] ?? ''])) {
|
||||
$this->error = '支付通道取值不合法';
|
||||
return false;
|
||||
}
|
||||
// 支付宝企业码通道必须有机构号,否则每一单都会在下发时失败并退款
|
||||
if (($data['passageway'] ?? '') === 'alipay' && trim((string) ($data['institution_id'] ?? '')) === '') {
|
||||
$this->error = '支付宝企业码通道必须填写机构号(institution_id)';
|
||||
return false;
|
||||
}
|
||||
if (!empty($data['contact_mobile']) && !preg_match('/^1[3-9]\d{9}$/', (string) $data['contact_mobile'])) {
|
||||
$this->error = '企业联系人电话格式不正确';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user