1、合作商余额查询
2、分油账户司机可用额度
This commit is contained in:
@@ -24,6 +24,10 @@ use app\store\model\Company as CompanyModel;
|
||||
use app\store\model\Express as ExpressModel;
|
||||
use app\common\enum\order\PayType as OrderPayTypeEnum;
|
||||
use app\store\model\order\QrCode as QrCodeModel;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use app\common\service\QytOil;
|
||||
use app\api\service\User as UserService;
|
||||
use think\facade\Db;
|
||||
use cores\exception\BaseException;
|
||||
use think\facade\Request;
|
||||
use think\response\Json;
|
||||
@@ -309,9 +313,77 @@ class Order extends Controller
|
||||
*/
|
||||
public function qrCode(): Json
|
||||
{
|
||||
// 企油通改造:小程序油品发放/核销链路已停用(上游定聚已下线)。
|
||||
// 保留接口避免小程序端报错,统一返回提示;如需恢复请改走 QytOil。
|
||||
return $this->renderError('该功能已停用');
|
||||
$orderGoodsId = (int)$this->request->param('orderGoodsId');
|
||||
[$order, $error] = $this->findOilOrder($orderGoodsId);
|
||||
if ($error !== '') {
|
||||
return $this->renderError($error);
|
||||
}
|
||||
// 免登链接要带本单企业的 creditcode(营业执照号),把登录固定到该企业
|
||||
$enterprise = EnterpriseModel::detail((int)$order['qyt_enterprise_id']);
|
||||
if (!$enterprise) {
|
||||
return $this->renderError('本单绑定的企油通企业不存在,无法获取加油地址');
|
||||
}
|
||||
if ($enterprise['status'] !== 'approved') {
|
||||
return $this->renderError('企业尚未通过企油通审核,暂时无法加油');
|
||||
}
|
||||
$h5Url = QytOil::forEnterprise($enterprise)->h5Url((string)$order['mobile']);
|
||||
if (!$h5Url) {
|
||||
return $this->renderError('获取加油地址失败,请稍后重试');
|
||||
}
|
||||
// 留痕
|
||||
(new QrCodeModel())->add([
|
||||
'oilCardNo' => $order['oilCardNo'],
|
||||
'qrCode' => $h5Url,
|
||||
'amount' => $order['goods_price'],
|
||||
'order_id' => (int)$order['mall_order_id'],
|
||||
'user_id' => (int)$order['user_id'],
|
||||
'store_id' => (int)$order['store_id'],
|
||||
]);
|
||||
return $this->renderSuccess(['qrCode' => $h5Url], '提交成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单商品行找到对应的企油通发放单,并校验是否还能加油
|
||||
*
|
||||
* 小程序单在支付成功后由 MallOil::create 建了一条 yoshop_partners_order
|
||||
* (source='mall',mall_order_goods_id 指回这里的订单商品行)。
|
||||
*
|
||||
* @param int $orderGoodsId
|
||||
* @return array [发放单, 错误信息]
|
||||
* @throws BaseException
|
||||
*/
|
||||
private function findOilOrder(int $orderGoodsId): array
|
||||
{
|
||||
if ($orderGoodsId <= 0) {
|
||||
return [null, '缺少参数 orderGoodsId'];
|
||||
}
|
||||
$userId = (int)UserService::getCurrentLoginUserId();
|
||||
$orderGoods = Db::name('order_goods')->where('order_goods_id', '=', $orderGoodsId)->find();
|
||||
if (!$orderGoods) {
|
||||
return [null, '未找到订单商品'];
|
||||
}
|
||||
// 越权校验:只能取自己订单的加油地址
|
||||
if ((int)$orderGoods['user_id'] !== $userId) {
|
||||
return [null, '未找到订单商品'];
|
||||
}
|
||||
$order = Db::name('partners_order')
|
||||
->where('source', '=', 'mall')
|
||||
->where('mall_order_goods_id', '=', $orderGoodsId)
|
||||
->find();
|
||||
if (!$order) {
|
||||
return [null, '该订单还没有发放记录'];
|
||||
}
|
||||
if ((int)$order['oil_status'] !== 1) {
|
||||
return [null, '油费还在发放中,请稍后再试'];
|
||||
}
|
||||
if ((int)$order['refund_status'] === 2) {
|
||||
return [null, '该订单已退款,不能再加油'];
|
||||
}
|
||||
// 一单可以分多次加油:没加完的单继续给加油地址
|
||||
if ((float)$order['used_amount'] + 0.005 >= (float)$order['total_price']) {
|
||||
return [null, '该订单金额已全部加油'];
|
||||
}
|
||||
return [$order, ''];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,9 +393,10 @@ class Order extends Controller
|
||||
*/
|
||||
public function qrCodeImg(): Json
|
||||
{
|
||||
// 企油通改造:小程序油品发放/核销链路已停用(上游定聚已下线)。
|
||||
// 保留接口避免小程序端报错,统一返回提示;如需恢复请改走 QytOil。
|
||||
return $this->renderError('该功能已停用');
|
||||
// 企油通给的是 H5 免登链接,没有二维码图片接口。
|
||||
// 本商城跑在 H5(微信内置浏览器)下,走的是 qrCode + webview;
|
||||
// 本方法只在微信小程序环境下被调用,暂不支持。
|
||||
return $this->renderError('请在微信或浏览器中打开加油链接');
|
||||
}
|
||||
|
||||
public function getBase64($url){
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企油通 · 回调接收
|
||||
// +----------------------------------------------------------------------
|
||||
// | 入口:public/qytconsume.php -> /qytNotify/consume
|
||||
// | 对应企油通 7.10「运单加油明细推送(异步)」,我方须返回 {"code":"100","msg":"Success"}
|
||||
// | 入口:
|
||||
// | public/qytconsume.php -> /qytNotify/consume 企油通 7.10「运单加油明细推送」
|
||||
// | public/qytwaybill.php -> /qytNotify/waybill 企油通 3.10「发放结果通知」
|
||||
// |
|
||||
// | 两个回调都是 multipart/form-data,业务参数在表单字段里(JSON 字符串):
|
||||
// | 7.10 -> params 我方须返回 {"code":"100","msg":"Success"}
|
||||
// | 3.10 -> data + sign 我方只要返回 HTTP 2xx 即算成功,响应体不做要求
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
@@ -70,4 +75,78 @@ class QytNotify extends Controller
|
||||
|
||||
return json(['code' => '100', 'msg' => 'Success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3.10 发放结果通知(运单油费 / 企业福利)
|
||||
*
|
||||
* 企油通把 7.7 提交的批次处理完后(全部成功 / 部分成功 / 处理失败)主动推过来,
|
||||
* 一个批次一条,内容与 7.8 查询结果一致。接上之后就不必再靠轮询等结果。
|
||||
*
|
||||
* 约定(见文档 3.10):
|
||||
* · form-data,字段 data(JSON 字符串)+ sign
|
||||
* · scene 区分场景:oil=运单油费下发(7.7),welfare=员工福利下发(9.3)
|
||||
* · 接收端返回 HTTP 2xx 即视为成功;非 2xx 或 5 秒内无响应会重试,最多 5 次
|
||||
* · 同一批次可能收到多次(重试 / 批次重新提交),必须幂等
|
||||
* · 明细最多约 1 万条,报文可能很大
|
||||
*
|
||||
* 所以这里只做「尽快收下、丢进队列、立刻回 200」,真正的状态跃迁交给
|
||||
* callback:queue 异步做,避免大报文处理超过上游 5 秒超时导致无谓重试。
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function waybill()
|
||||
{
|
||||
$request = Request::instance();
|
||||
// ⚠️ 必须直接读 $_POST:cores\Request 给所有入参挂了全局过滤器
|
||||
// ['my_trim','my_htmlspecialchars','filter_emoji'],走 $request->post() 取到的
|
||||
// JSON 引号会被转义成 ",json_decode 必然失败(通知会被静默丢掉)
|
||||
$formData = isset($_POST['data']) ? trim((string) $_POST['data']) : '';
|
||||
$sign = isset($_POST['sign']) ? trim((string) $_POST['sign']) : '';
|
||||
$raw = $request->getContent();
|
||||
|
||||
$payload = $formData !== '' ? $formData : $raw;
|
||||
$qyt = new QytOil();
|
||||
// 明细可能上万条,日志只留头部,完整报文靠 batch_no 去企油通后台对
|
||||
$qyt->saveLog('3.10 发放结果通知原文[' . $request->contentType() . '](截断):'
|
||||
. mb_substr($payload, 0, 4000) . (mb_strlen($payload) > 4000 ? ' …(已截断)' : ''));
|
||||
|
||||
$data = json_decode($payload, true);
|
||||
// 兜底:若上游或中间层做过 HTML 转义,反转义后再解析一次
|
||||
if (!is_array($data) && strpos($payload, '"') !== false) {
|
||||
$data = json_decode(htmlspecialchars_decode($payload, ENT_QUOTES), true);
|
||||
}
|
||||
// 兼容:直接推 JSON body 且外层包了一层 {data: ..., sign: ...}
|
||||
if (is_array($data) && isset($data['data'])) {
|
||||
$sign === '' && $sign = (string) ($data['sign'] ?? '');
|
||||
$data = is_string($data['data']) ? json_decode($data['data'], true) : $data['data'];
|
||||
}
|
||||
|
||||
if (!is_array($data) || empty($data['batch_no'])) {
|
||||
$qyt->saveLog('3.10 发放结果通知解析失败或缺少 batch_no,已忽略');
|
||||
// 仍然回 2xx:报文我方解析不了,让上游重试 5 次也没有意义,
|
||||
// 这类单由 7.8 轮询兜底(轮询并没有下线)
|
||||
return json(['code' => '100', 'msg' => 'Success']);
|
||||
}
|
||||
|
||||
// TODO 验签(对 data 原串签名,规则见开发指南 2.5,待对接人给到平台公钥与算法确认)
|
||||
// if ($sign !== '' && !(new QytCrypto())->verifyNotify($payload, $sign)) {
|
||||
// $qyt->saveLog('3.10 发放结果通知验签失败 batch_no=' . $data['batch_no']);
|
||||
// return json(['code' => '99', 'msg' => 'sign error']);
|
||||
// }
|
||||
|
||||
// 只处理运单油费(7.7)。员工福利(9.3)我方没有对接,收下回 2xx 即可,
|
||||
// 否则上游会按失败重试 5 次。
|
||||
$scene = (string) ($data['scene'] ?? 'oil');
|
||||
if ($scene !== 'oil') {
|
||||
$qyt->saveLog('3.10 发放结果通知 scene=' . $scene . '(非运单油费),已忽略 batch_no=' . $data['batch_no']);
|
||||
return json(['code' => '100', 'msg' => 'Success']);
|
||||
}
|
||||
|
||||
(new QueueService())->push('callback_queue', json_encode([
|
||||
'header' => ['method' => 'qyt_waybill_result'],
|
||||
'res' => $data,
|
||||
], 320));
|
||||
|
||||
return json(['code' => '100', 'msg' => 'Success']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use app\api\model\User as UserModel;
|
||||
use app\api\model\UserCoupon as UserCouponModel;
|
||||
use app\api\service\User as UserService;
|
||||
use cores\exception\BaseException;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 用户管理
|
||||
@@ -76,6 +77,46 @@ class User extends Controller
|
||||
return $this->renderSuccess('恭喜您,手机号绑定成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 实名信息(姓名 / 身份证号 / 车牌号)
|
||||
*
|
||||
* 分油实名制:上游分油前做「姓名 + 身份证号」二要素认证,所以下单前必须补全。
|
||||
* 车牌号只在本地留存与后台展示,不上送上游。
|
||||
* @return Json
|
||||
* @throws BaseException
|
||||
*/
|
||||
public function realname(): Json
|
||||
{
|
||||
$userInfo = UserService::getCurrentLoginUser(true);
|
||||
return $this->renderSuccess([
|
||||
'realname' => [
|
||||
'realName' => (string)$userInfo['real_name'],
|
||||
'idCard' => (string)$userInfo['id_card'],
|
||||
'plateNo' => (string)$userInfo['plate_no'],
|
||||
// 身份证号已用于分油后不允许再改(上游按身份证号累计司机额度)
|
||||
'idCardLocked' => (string)$userInfo['id_card'] !== '' && Db::name('partners_order')
|
||||
->where('user_id', '=', (int)$userInfo['user_id'])
|
||||
->where('source', '=', 'mall')
|
||||
->count() > 0,
|
||||
'isReady' => UserModel::isRealnameReady($userInfo),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存实名信息
|
||||
* @return Json
|
||||
* @throws BaseException
|
||||
*/
|
||||
public function saveRealname(): Json
|
||||
{
|
||||
$model = new UserModel;
|
||||
if (!$model->realname($this->postForm())) {
|
||||
return $this->renderError($model->getError() ?: '保存失败');
|
||||
}
|
||||
return $this->renderSuccess('保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改个人信息(头像昵称)
|
||||
* @return Json
|
||||
|
||||
@@ -16,6 +16,7 @@ use app\common\enum\user\balanceLog\Scene as SceneEnum;
|
||||
use app\common\library\helper;
|
||||
use app\store\model\user\BalanceLog as BalanceLogModel;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
use app\api\service\User as UserService;
|
||||
use app\api\model\UserOauth as UserOauthModel;
|
||||
use app\common\model\User as UserModel;
|
||||
@@ -112,6 +113,75 @@ class User extends UserModel
|
||||
return $userInfo->save($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存实名信息(姓名 / 身份证号 / 车牌号)
|
||||
*
|
||||
* 分油是实名制的:上游在分油前会做「姓名 + 身份证号」二要素认证,
|
||||
* 所以这三项必须在下单前补全。车牌号只在本地留存与后台展示,不上送上游。
|
||||
*
|
||||
* @param array $form
|
||||
* @return bool
|
||||
* @throws BaseException
|
||||
*/
|
||||
public function realname(array $form): bool
|
||||
{
|
||||
$realName = trim((string)($form['realName'] ?? ''));
|
||||
$idCard = strtoupper(trim((string)($form['idCard'] ?? '')));
|
||||
$plateNo = strtoupper(trim((string)($form['plateNo'] ?? '')));
|
||||
|
||||
if ($realName === '') {
|
||||
$this->error = '请填写真实姓名';
|
||||
return false;
|
||||
}
|
||||
// 中文姓名 2~20 字,允许少数民族姓名里的间隔号
|
||||
if (!preg_match('/^[\x{4e00}-\x{9fa5}][\x{4e00}-\x{9fa5}·]{1,19}$/u', $realName)) {
|
||||
$this->error = '姓名格式不正确,请填写身份证上的中文姓名';
|
||||
return false;
|
||||
}
|
||||
if (!preg_match('/^\d{17}[\dX]$/', $idCard)) {
|
||||
$this->error = '身份证号格式不正确(18位)';
|
||||
return false;
|
||||
}
|
||||
// 省份简称 + 城市字母(不含 I/O,避免与 1/0 混淆) + 5~7 位序号(含新能源 8 位车牌)
|
||||
if (!preg_match('/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,6}$/u', $plateNo)) {
|
||||
$this->error = '车牌号格式不正确';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 当前登录的用户信息
|
||||
$userInfo = UserService::getCurrentLoginUser(true);
|
||||
// 身份证号一旦用于分油就不允许再改:上游按身份证号累计司机额度,
|
||||
// 改了会导致历史订单的额度与司机对不上。姓名/车牌可以改。
|
||||
if ((string)$userInfo['id_card'] !== '' && (string)$userInfo['id_card'] !== $idCard) {
|
||||
$used = Db::name('partners_order')
|
||||
->where('user_id', '=', (int)$userInfo['user_id'])
|
||||
->where('source', '=', 'mall')
|
||||
->count();
|
||||
if ($used > 0) {
|
||||
$this->error = '身份证号已用于分油,不能修改,请联系客服';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $userInfo->save([
|
||||
'real_name' => $realName,
|
||||
'id_card' => $idCard,
|
||||
'plate_no' => $plateNo,
|
||||
]) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实名信息是否已补全(下单前的必要条件)
|
||||
* @param array|static $user
|
||||
* @return bool
|
||||
*/
|
||||
public static function isRealnameReady($user): bool
|
||||
{
|
||||
return trim((string)($user['real_name'] ?? '')) !== ''
|
||||
&& trim((string)($user['id_card'] ?? '')) !== ''
|
||||
&& trim((string)($user['plate_no'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证绑定的手机号
|
||||
* @param array $data
|
||||
|
||||
@@ -370,9 +370,30 @@ class Checkout extends BaseService
|
||||
'balance' => $this->user['balance'],
|
||||
'points' => $this->user['points'],
|
||||
'address_id' => $this->user['address_id'],
|
||||
// 实名信息(分油实名制,下单前必须补全):结算页据此拦截并跳去补全
|
||||
'real_name' => (string)$this->user['real_name'],
|
||||
'id_card' => (string)$this->user['id_card'],
|
||||
'plate_no' => (string)$this->user['plate_no'],
|
||||
'isRealnameReady' => UserModel::isRealnameReady($this->user),
|
||||
'hasOilGoods' => $this->hasOilGoods(),
|
||||
'needRealname' => $this->hasOilGoods() && !UserModel::isRealnameReady($this->user),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次结算是否包含油券商品(oilCardType = zsy_ticket)
|
||||
* 油券要走企油通分油,必须有姓名/身份证号/车牌号
|
||||
*/
|
||||
private function hasOilGoods(): bool
|
||||
{
|
||||
foreach ($this->goodsList as $goods) {
|
||||
if (($goods['oilCardType'] ?? '') === 'zsy_ticket') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户可用的优惠券列表
|
||||
* @param float $orderTotalPrice 总金额
|
||||
@@ -757,6 +778,12 @@ class Checkout extends BaseService
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 油券商品要走企油通分油(实名制:上游做姓名+身份证号二要素认证),
|
||||
// 下单前必须补全实名信息。结算页已拦截,这里是服务端兜底。
|
||||
if ($this->hasOilGoods() && !UserModel::isRealnameReady($this->user)) {
|
||||
$this->error = '请先完善姓名、身份证号和车牌号后再下单';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use app\common\lock\RedisLock;
|
||||
use app\common\model\Coupon;
|
||||
use app\common\service\SybOil;
|
||||
use app\common\service\IdingjuOil;
|
||||
use app\common\service\MallOil;
|
||||
use app\store\model\Company as CompanyModel;
|
||||
use app\store\model\Coupon as CouponModel;
|
||||
use app\store\model\GoodsCoupon as GoodsCouponModel;
|
||||
@@ -108,15 +109,6 @@ class PaySuccess extends BaseService
|
||||
}
|
||||
// 获取订单详情
|
||||
$detail = OrderModel::getUserOrderDetail((int)$this->model['order_id']);
|
||||
// 触发分油接口
|
||||
$shareStatus = $this->shareOil($detail);
|
||||
if (!$shareStatus) {
|
||||
// 记录日志
|
||||
log_record(['name' => '分油失败', [
|
||||
'order_id' => $this->model['order_id'],
|
||||
]]);
|
||||
// return false;
|
||||
}
|
||||
// 事务处理
|
||||
$this->model->transaction(function () use ($payType, $payData, $detail) {
|
||||
// 更新订单状态
|
||||
@@ -126,6 +118,16 @@ class PaySuccess extends BaseService
|
||||
// 记录订单支付信息
|
||||
$this->updatePayInfo($payType);
|
||||
});
|
||||
// 触发分油/发券(放在事务之后:企油通发放是真金白银,必须等订单确实置为已支付再发,
|
||||
// 否则事务回滚会出现「订单未支付但油费已下发」)
|
||||
$shareStatus = $this->shareOil($detail);
|
||||
if (!$shareStatus) {
|
||||
// 记录日志;不影响支付成功的结果,发放失败由 7.8 判定后自动退回用户余额
|
||||
log_record(['name' => '分油失败', [
|
||||
'order_id' => $this->model['order_id'],
|
||||
'error' => $this->error,
|
||||
]]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -250,62 +252,30 @@ class PaySuccess extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后的商品发放
|
||||
* · 油券(zsy_ticket) -> 建企油通发放单,由 partners:queue 调 7.7 下发
|
||||
* · 优惠券(coupon) -> 直接发券(原逻辑不变)
|
||||
*
|
||||
* 返回 false 只用于写一条「分油失败」日志,不阻断支付成功:用户已经付过款,
|
||||
* 订单必须正常置为已支付。发放失败由 7.8 判定后自动退回用户余额,或后台「重新发放」。
|
||||
*/
|
||||
private function shareOil($order): bool
|
||||
{
|
||||
$IdingjuOil = new IdingjuOil();
|
||||
$ok = true;
|
||||
// 下单用户(含实名信息:姓名/身份证号/车牌号,结算时已校验必填)
|
||||
$user = Db::name('user')->where('user_id', (int)$order['user_id'])->find() ?: [];
|
||||
foreach ($order->goods as $goods) {
|
||||
/* @var GoodsModel $goods */
|
||||
// 企油通改造:小程序油品发放链路已停用,支付成功后不再触发上游分油(保留优惠券发放)。
|
||||
// 如需恢复小程序侧发放,改走 QytOil 并补齐司机三要素来源。
|
||||
if ($goods['oilCardType'] == 'zsy_ticket') {
|
||||
continue;
|
||||
}
|
||||
if($goods['oilCardType'] == 'zsy_ticket') {
|
||||
$data['amount'] = $goods['total_price']; // 金额
|
||||
$company = CompanyModel::detail(['orgNo' => $this->getCompany($goods['goods_no'])]);
|
||||
if (!$company) {
|
||||
$this->error = '没有可用参数';
|
||||
return false;
|
||||
// 企油通:一个油券商品行建一条发放单,金额取该行实付金额
|
||||
$res = MallOil::create($order->toArray(), $goods->toArray(), $user);
|
||||
if (!$res['ok']) {
|
||||
$this->error = $res['message'];
|
||||
$goods->save(['oil_text' => mb_substr($res['message'], 0, 190)]);
|
||||
$ok = false;
|
||||
}
|
||||
try {
|
||||
// 查找一个未被调用的用户
|
||||
$account = $this->processUserOrders($company['company_id']);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查询公司余额
|
||||
/*if ($data['amount'] > $company['balance']) {
|
||||
$this->error = '公司余额不足';
|
||||
return false;
|
||||
}*/
|
||||
// 对接平台接口
|
||||
$res = $IdingjuOil->oilOilCardShare([
|
||||
'goodsNo'=>$goods['goods_no'],
|
||||
'amount'=>$data['amount'],
|
||||
'userNo'=>$account['userNo'],
|
||||
'mobile'=>$account['mobile'],
|
||||
'saleOrderNo'=>$goods['oilCardNo'],
|
||||
'oilCardType'=>$goods['oilCardType']
|
||||
], $company);
|
||||
//$res = json_decode('{"code":"00","message":"请求成功","errCode":null,"errMessage":null,"data":{"oilCardNo":null,"amount":100,"balance":null,"shareNo":"CDV2408021928160001"}}', true);
|
||||
if ($res['code'] != 1) {
|
||||
$this->error = ($res['errMessage'] ?? $res['message']);
|
||||
return false;
|
||||
}
|
||||
|
||||
$item['account_id'] = $account['account_id'];
|
||||
$item['userNo'] = $account['userNo'];
|
||||
$item['mobile'] = $account['mobile'];
|
||||
$item['company_id'] = $company['company_id'];
|
||||
$item['shareNo'] = $res['data']['trade_no'] ?? '';
|
||||
$item['qrCode'] = $res['data']['useUrl'] ?? '';
|
||||
$item['oil_status_zsy'] = $res['data']['state'] ?? 0;
|
||||
$item['oil_time'] = time();
|
||||
$item['oil_status'] = $item['oil_status_zsy'] == 2 ? 1 : 0; // 已发券 为1
|
||||
$goods->save($item);
|
||||
} elseif($goods['oilCardType'] == 'coupon'){
|
||||
} elseif ($goods['oilCardType'] == 'coupon') {
|
||||
// 发放优惠券
|
||||
$coupons = GoodsCouponModel::getCouponList($goods['goods_id'])->toArray();
|
||||
if($coupons && is_array($coupons)) {
|
||||
@@ -319,7 +289,7 @@ class PaySuccess extends BaseService
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,6 +200,26 @@ function runtime_root_path(): string
|
||||
return dirname(runtime_path()) . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游对接日志(storelog)的统一目录
|
||||
*
|
||||
* ⚠️ 不能用 runtime_path():多应用模式下 think-multi-app 会把运行时目录改写成
|
||||
* runtime/{应用名}/(vendor/topthink/think-multi-app/src/MultiApp.php::setApp 里的
|
||||
* setRuntimePath),于是同一条业务链路的日志会散到三个地方:
|
||||
* CLI 常驻队列进程 runtime/storelog/
|
||||
* api 入口(回调接收) runtime/api/storelog/
|
||||
* store 后台操作 runtime/store/storelog/
|
||||
* 排查时极易找错目录(比如回调明明收到了,却在 CLI 那个目录里怎么都 grep 不到)。
|
||||
* 这里按项目根目录固定,所有进程写同一处。
|
||||
*
|
||||
* 注:改动只影响新日志,历史日志仍留在原来的三个目录里。
|
||||
* @return string 带结尾分隔符的绝对路径
|
||||
*/
|
||||
function store_log_path(): string
|
||||
{
|
||||
return app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'storelog' . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入日志 (使用tp自带驱动记录到runtime目录中)
|
||||
* @param mixed $value
|
||||
|
||||
@@ -237,15 +237,10 @@ class Goods extends BaseModel
|
||||
$query->join($GoodsCategoryRelName, "{$GoodsCategoryRelName}.goods_id = {$this->name}.goods_id");
|
||||
// 设置分类ID条件
|
||||
$query->where('goods_category_rel.category_id', '=', (int)$params['categoryId']);
|
||||
}elseif(!empty($params['province']) && $params['location'] != 1){
|
||||
// 前端请求,没有分类则不展示商品
|
||||
|
||||
// 关联商品与分类关系记录表
|
||||
$GoodsCategoryRelName = (new GoodsCategoryRelModel)->getName();
|
||||
$query->join($GoodsCategoryRelName, "{$GoodsCategoryRelName}.goods_id = {$this->name}.goods_id");
|
||||
// 设置分类ID条件
|
||||
$query->where('goods_category_rel.category_id', '=', -1);
|
||||
}
|
||||
// 原先这里有一条「带了 province 又没带分类 -> 强制返回空列表」的分支,
|
||||
// 配合首页地图选城市使用。商品不再按省份强制过滤,该分支已移除:
|
||||
// 没传 categoryId 就是查全部商品。
|
||||
// 商品名称
|
||||
!empty($params['goodsName']) && $filter[] = ['goods_name', 'like', "%{$params['goodsName']}%"];
|
||||
// 商品编码
|
||||
|
||||
@@ -328,6 +328,8 @@ class Setting extends BaseModel
|
||||
'h5_base_url' => '', // 7.15 落地页
|
||||
'h5_app_id' => '', // 7.15 免登 appId
|
||||
'consume_notify_url' => '', // 7.10 回调地址(仅记录,实际在企油通后台配)
|
||||
'waybill_notify_url' => '', // 3.10 发放结果通知地址(同上)
|
||||
'waybill_notify' => 0, // 3.10 发放结果通知是否已生效(开 -> 7.8 轮询退为安全网)
|
||||
'log' => 1, // 是否记录详细请求/响应日志
|
||||
],
|
||||
],
|
||||
|
||||
@@ -35,8 +35,8 @@ class IdingjuOil extends BaseService
|
||||
public function saveLogStore($data){
|
||||
$years = date('Ym');
|
||||
$day = date('Ymd');
|
||||
//设置路径目录信息
|
||||
$dir = runtime_path('storelog/').$years.'/'.$day;
|
||||
//设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
|
||||
$dir = store_log_path().$years.'/'.$day;
|
||||
//目录不存在就创建
|
||||
$this->mk_dir_store($dir);
|
||||
$url = $dir.'/'.date('YmdH').'.txt';
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 小程序(商城)订单 -> 企油通发放
|
||||
// +----------------------------------------------------------------------
|
||||
// | 设计:小程序订单不另起一套企油通链路,而是在支付成功后为每个油券商品行
|
||||
// | 建一条 yoshop_partners_order(source='mall'),复用现有机器:
|
||||
// | · partners:queue 7.7 运单油费下发
|
||||
// | · qyt:status 7.8 轮询发放结果
|
||||
// | · 7.10 消费回调 按 waybill_no_code 匹配,无需改动
|
||||
// | · QytRefund 7.17 查凭证 + 7.16 调低额度
|
||||
// | · QytInvoice 7.14 企业发票
|
||||
// | · QytDriver 司机档案
|
||||
// |
|
||||
// | 两种来源的差异只有三处,全部收敛在本类:
|
||||
// | 1. 额度:合作商单扣/补 partners.balance;商城单用户已付款,改退用户余额
|
||||
// | 2. 通知:合作商单 HTTP 回调下单平台;商城单没有下游,改回写 order_goods
|
||||
// | 3. 建单:合作商单由开放接口 OilCard::share 建;商城单由 PaySuccess 建
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\enum\user\balanceLog\Scene as SceneEnum;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use think\facade\Db;
|
||||
|
||||
class MallOil
|
||||
{
|
||||
/** partners_order.source 取值 */
|
||||
const SOURCE_MALL = 'mall';
|
||||
const SOURCE_PARTNER = 'partner';
|
||||
|
||||
/**
|
||||
* 是否小程序(商城)单
|
||||
* @param array|\think\Model $order partners_order 记录
|
||||
* @return bool
|
||||
*/
|
||||
public static function isMall($order): bool
|
||||
{
|
||||
return (string) ($order['source'] ?? self::SOURCE_PARTNER) === self::SOURCE_MALL;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 建单(支付成功后调用)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 为一个油券商品行创建企油通发放单并投递 partners_queue
|
||||
*
|
||||
* 幂等:复用已有唯一键 uk_partner_saleorder(partners_id, partners_order_no),
|
||||
* mall 行 partners_id=0、partners_order_no='M{订单号}-{order_goods_id}'。
|
||||
* 支付回调重入时唯一键冲突 -> 当作「已建过」返回原单,绝不重复发放。
|
||||
*
|
||||
* @param array $order 商城订单(yoshop_order)
|
||||
* @param array $goods 订单商品行(yoshop_order_goods)
|
||||
* @param array $user 下单用户(含 real_name / id_card / plate_no)
|
||||
* @return array ['ok' => bool, 'message' => string, 'order_id' => int]
|
||||
*/
|
||||
public static function create(array $order, array $goods, array $user): array
|
||||
{
|
||||
// 发放金额按油券面值(数量 x 单价),与改造前的口径一致;
|
||||
// 用户实付(优惠券/积分抵扣后)另存 total_pay_price,退款回补余额时按它折算。
|
||||
$amount = round((float) $goods['total_price'], 2);
|
||||
$paid = round((float) $goods['total_pay_price'], 2);
|
||||
$paid > $amount && $paid = $amount;
|
||||
if ($amount <= 0) {
|
||||
return self::fail('商品金额为 0,不发放');
|
||||
}
|
||||
|
||||
$mobile = trim((string) ($user['mobile'] ?? ''));
|
||||
$name = trim((string) ($user['real_name'] ?? ''));
|
||||
$idCard = strtoupper(trim((string) ($user['id_card'] ?? '')));
|
||||
$plateNo = strtoupper(trim((string) ($user['plate_no'] ?? '')));
|
||||
|
||||
// 两个支付通道都要求实名,缺一不可。结算时已拦截,这里是第二道闸。
|
||||
$missing = [];
|
||||
$mobile === '' && $missing[] = '手机号';
|
||||
$name === '' && $missing[] = '姓名';
|
||||
$idCard === '' && $missing[] = '身份证号';
|
||||
if ($missing) {
|
||||
return self::fail('缺' . implode('/', $missing) . ',无法发放');
|
||||
}
|
||||
|
||||
// 本单使用的企油通企业:小程序没有合作商绑定关系,直接取默认企业
|
||||
$enterprise = EnterpriseModel::resolve(0);
|
||||
if (!$enterprise) {
|
||||
return self::fail('系统未配置可用的企油通企业,无法发放');
|
||||
}
|
||||
|
||||
$saleOrderNo = 'M' . $order['order_no'] . '-' . $goods['order_goods_id'];
|
||||
$exist = Db::name('partners_order')
|
||||
->where('partners_id', 0)
|
||||
->where('partners_order_no', $saleOrderNo)
|
||||
->find();
|
||||
if ($exist) {
|
||||
// 已建过(支付回调重入):直接复用,并补投一次队列(下发是条件跃迁,重复投递安全)
|
||||
self::push((int) $exist['order_id']);
|
||||
return ['ok' => true, 'message' => '已存在发放单', 'order_id' => (int) $exist['order_id']];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
// 带上 order_goods_id(全局唯一)保证油卡编号不会撞号
|
||||
$oilCardNo = 'OIL' . $now . 'G' . (int) $goods['order_goods_id'];
|
||||
$row = [
|
||||
'province' => (string) ($goods['province'] ?? ''),
|
||||
'mobile' => $mobile,
|
||||
'driver_name' => $name,
|
||||
'id_card' => $idCard,
|
||||
'plate_no' => $plateNo,
|
||||
// 企业与通道随单固化:之后切默认企业,在途单仍按下单时的企业发放
|
||||
'qyt_enterprise_id' => (int) $enterprise['enterprise_id'],
|
||||
'passageway' => (string) $enterprise['passageway'],
|
||||
'oilCardType' => 'zsy_ticket',
|
||||
'oilCardNo' => $oilCardNo,
|
||||
'partners_order_no' => $saleOrderNo,
|
||||
'partners_id' => 0,
|
||||
'source' => self::SOURCE_MALL,
|
||||
'mall_order_id' => (int) $order['order_id'],
|
||||
'mall_order_goods_id' => (int) $goods['order_goods_id'],
|
||||
'user_id' => (int) $order['user_id'],
|
||||
'goods_id' => (int) $goods['goods_id'],
|
||||
'goods_name' => (string) $goods['goods_name'],
|
||||
'goods_no' => (string) $goods['goods_no'],
|
||||
'goods_price' => $amount,
|
||||
'total_num' => 1,
|
||||
'total_price' => $amount,
|
||||
'total_pay_price' => $paid,
|
||||
'order_status' => 10,
|
||||
'store_id' => (int) $order['store_id'],
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
|
||||
try {
|
||||
$orderId = (int) Db::name('partners_order')->insertGetId($row);
|
||||
} catch (\Throwable $e) {
|
||||
if (strpos($e->getMessage(), 'Duplicate') !== false) {
|
||||
$again = Db::name('partners_order')->where('partners_id', 0)
|
||||
->where('partners_order_no', $saleOrderNo)->find();
|
||||
if ($again) {
|
||||
self::push((int) $again['order_id']);
|
||||
return ['ok' => true, 'message' => '已存在发放单', 'order_id' => (int) $again['order_id']];
|
||||
}
|
||||
}
|
||||
self::log('商城单建发放单失败 order_goods=' . $goods['order_goods_id'] . ':' . $e->getMessage());
|
||||
return self::fail('建发放单失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 把油卡编号写回订单商品行,小程序端与后台基础列继续按原字段展示
|
||||
Db::name('order_goods')->where('order_goods_id', (int) $goods['order_goods_id'])->update([
|
||||
'oilCardNo' => $oilCardNo,
|
||||
'mobile' => $mobile,
|
||||
'oil_text' => '已提交发放',
|
||||
]);
|
||||
|
||||
// 司机档案(按身份证号归档并标记信息冲突;内部吞异常,不影响主流程)
|
||||
QytDriver::touch([
|
||||
'order_id' => $orderId,
|
||||
'id_card' => $idCard,
|
||||
'driver_name' => $name,
|
||||
'mobile' => $mobile,
|
||||
'store_id' => (int) $order['store_id'],
|
||||
]);
|
||||
|
||||
self::push($orderId);
|
||||
return ['ok' => true, 'message' => '已提交发放', 'order_id' => $orderId];
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 额度回补(发放失败 / 退款成功)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 按订单来源把钱退回原处:
|
||||
* · 合作商单 -> 回补 partners.balance
|
||||
* · 商城单 -> 回补下单用户余额并记一条余额流水
|
||||
* 只在状态跃迁胜出后调用,天然只执行一次。
|
||||
*
|
||||
* @param array|\think\Model $order partners_order 记录
|
||||
* @param float $amount 金额(元)
|
||||
* @param string $scene 场景描述,写进流水
|
||||
*/
|
||||
public static function giveBack($order, float $amount, string $scene = '发放失败'): void
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!self::isMall($order)) {
|
||||
PartnersBalance::refund(
|
||||
(int) $order['partners_id'],
|
||||
$amount,
|
||||
(string) $order['partners_order_no'],
|
||||
(int) $order['order_id'],
|
||||
(int) $order['store_id']
|
||||
);
|
||||
self::log("{$scene}已回补合作商余额 order={$order['order_id']} amount={$amount}");
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = (int) ($order['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
self::log("【告警】商城单缺少 user_id,无法退款 order={$order['order_id']} amount={$amount}");
|
||||
return;
|
||||
}
|
||||
// 退回余额按「用户实付 / 油券面值」折算:用过优惠券或积分抵扣时,
|
||||
// 面值大于实付,按面值原额退会白送钱。全额发放失败时折算结果正好等于实付。
|
||||
$amount = self::toPaid($order, $amount);
|
||||
if ($amount <= 0) {
|
||||
return;
|
||||
}
|
||||
$money = self::money($amount);
|
||||
try {
|
||||
Db::transaction(function () use ($userId, $money, $amount, $order, $scene) {
|
||||
Db::name('user')->where('user_id', $userId)
|
||||
->update(['balance' => Db::raw('`balance` + ' . $money)]);
|
||||
Db::name('user_balance_log')->insert([
|
||||
'user_id' => $userId,
|
||||
'scene' => SceneEnum::REFUND,
|
||||
'money' => round($amount, 2),
|
||||
'describe' => $scene . ':' . ($order['oilCardNo'] ?: $order['order_id']),
|
||||
'remark' => '',
|
||||
'store_id' => (int) $order['store_id'],
|
||||
'create_time' => time(),
|
||||
]);
|
||||
});
|
||||
self::log("{$scene}已退回用户余额 order={$order['order_id']} user={$userId} amount={$amount}");
|
||||
} catch (\Throwable $e) {
|
||||
// 退款失败必须人工介入:额度那边可能已经收回了
|
||||
self::log("【告警】商城单退回用户余额失败 order={$order['order_id']} user={$userId}"
|
||||
. " amount={$amount}:" . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减额度(后台「重新发放」用)
|
||||
* 发放失败时额度已原路退回,重发必须重新扣一次:
|
||||
* · 合作商单 -> 扣 partners.balance
|
||||
* · 商城单 -> 扣下单用户余额(按实付折算)
|
||||
* 两者都是 UPDATE ... WHERE balance >= amount,天然防并发超扣。
|
||||
*
|
||||
* @return bool false=余额不足,未做任何变更
|
||||
*/
|
||||
public static function take($order, float $amount, string $scene = '重新发放'): bool
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
if (!self::isMall($order)) {
|
||||
$logId = PartnersBalance::deduct(
|
||||
(int) $order['partners_id'],
|
||||
$amount,
|
||||
(string) $order['partners_order_no'],
|
||||
(int) $order['order_id'],
|
||||
(int) $order['store_id']
|
||||
);
|
||||
return $logId !== false;
|
||||
}
|
||||
|
||||
$userId = (int) ($order['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$amount = self::toPaid($order, $amount);
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
$money = self::money($amount);
|
||||
$affected = Db::name('user')->where('user_id', $userId)
|
||||
->where('balance', '>=', $amount)
|
||||
->update(['balance' => Db::raw('`balance` - ' . $money)]);
|
||||
if (!$affected) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Db::name('user_balance_log')->insert([
|
||||
'user_id' => $userId,
|
||||
'scene' => SceneEnum::CONSUME,
|
||||
'money' => -$amount,
|
||||
'describe' => $scene . ':' . ($order['oilCardNo'] ?: $order['order_id']),
|
||||
'remark' => '',
|
||||
'store_id' => (int) $order['store_id'],
|
||||
'create_time' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
self::log('商城单扣款流水写入失败 order=' . $order['order_id'] . ':' . $e->getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 额度不足时的提示文案 */
|
||||
public static function notEnoughText($order): string
|
||||
{
|
||||
return self::isMall($order)
|
||||
? '下单用户余额不足(发放失败时已退回其余额),无法重新发放'
|
||||
: '合作商余额不足,请先充值后再重发';
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 状态回写(代替合作商单的 HTTP 回调)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 把发放单的状态同步回 yoshop_order_goods。
|
||||
* 小程序端的订单列表/详情、后台订单页的基础列都读 order_goods,
|
||||
* 所以商城单不走 HTTP 通知,改为在同样的时机回写这张表。
|
||||
*
|
||||
* @param array|\think\Model $order partners_order 记录
|
||||
* @param string $optType share_oil / add_oil / refund
|
||||
*/
|
||||
public static function syncGoods($order, string $optType = ''): void
|
||||
{
|
||||
$goodsId = (int) ($order['mall_order_goods_id'] ?? 0);
|
||||
if (!self::isMall($order) || $goodsId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 锁内重读发放单:调用方可能持有的是跃迁前的旧快照
|
||||
$fresh = Db::name('partners_order')->where('order_id', (int) $order['order_id'])->find();
|
||||
if (!$fresh) {
|
||||
return;
|
||||
}
|
||||
$data = [
|
||||
'oil_status' => (int) $fresh['oil_status'],
|
||||
'oil_status_zsy' => (int) $fresh['oil_status_zsy'],
|
||||
'oil_time' => (int) $fresh['oil_time'],
|
||||
'oil_text' => mb_substr((string) $fresh['oil_text'], 0, 190),
|
||||
'shareNo' => (string) $fresh['shareNo'],
|
||||
'use_status' => (int) $fresh['use_status'],
|
||||
'use_time' => (int) $fresh['use_time'],
|
||||
'use_text' => mb_substr((string) $fresh['use_text'], 0, 190),
|
||||
// 一单可分多次加油:把累计值带到订单商品行,小程序端据此算剩余可加油金额
|
||||
'used_amount' => (float) $fresh['used_amount'],
|
||||
'used_num' => (int) $fresh['used_num'],
|
||||
'refund_status' => (int) $fresh['refund_status'],
|
||||
'refund_amount' => (float) $fresh['refund_amount'],
|
||||
];
|
||||
// 发放失败时额度已原路退回用户余额,把这句一起告诉用户,省掉一轮客服询问
|
||||
if ((int) $fresh['oil_status_zsy'] === 3) {
|
||||
$data['oil_text'] = mb_substr($fresh['oil_text'] . ',款项已退回账户余额', 0, 190);
|
||||
}
|
||||
// 已退款的单在小程序端不应再显示「可核销」
|
||||
if ((int) $fresh['refund_status'] === 2) {
|
||||
$data['oil_text'] = mb_substr('已退款:' . $fresh['refund_text'], 0, 190);
|
||||
}
|
||||
Db::name('order_goods')->where('order_goods_id', $goodsId)->update($data);
|
||||
} catch (\Throwable $e) {
|
||||
self::log('商城单回写 order_goods 失败 order=' . $order['order_id'] . ':' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 内部
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 把「油券面值金额」折算成「用户实付金额」
|
||||
* 面值 total_price 与实付 total_pay_price 在用过优惠券/积分时不相等。
|
||||
*/
|
||||
protected static function toPaid($order, float $amount): float
|
||||
{
|
||||
$face = (float) ($order['total_price'] ?? 0);
|
||||
$paid = (float) ($order['total_pay_price'] ?? 0);
|
||||
if ($face <= 0 || $paid >= $face) {
|
||||
return round($amount, 2);
|
||||
}
|
||||
return round(min($amount * $paid / $face, $paid), 2);
|
||||
}
|
||||
|
||||
/** 投递下发队列 */
|
||||
protected static function push(int $orderId): void
|
||||
{
|
||||
(new QueueService())->push('partners_queue', json_encode(['order_id' => $orderId], 320));
|
||||
}
|
||||
|
||||
protected static function fail(string $message): array
|
||||
{
|
||||
return ['ok' => false, 'message' => $message, 'order_id' => 0];
|
||||
}
|
||||
|
||||
/** 金额安全格式化(仅数字与小数点,防拼接注入) */
|
||||
protected static function money(float $amount): string
|
||||
{
|
||||
return number_format(round($amount, 2), 2, '.', '');
|
||||
}
|
||||
|
||||
protected static function log(string $text): void
|
||||
{
|
||||
(new QytOil())->saveLog('[商城单] ' . $text);
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ class PartnersOil extends BaseService
|
||||
public function saveLogStore($data){
|
||||
$years = date('Ym');
|
||||
$day = date('Ymd');
|
||||
//设置路径目录信息
|
||||
$dir = runtime_path('storelog/').$years.'/'.$day;
|
||||
//设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
|
||||
$dir = store_log_path().$years.'/'.$day;
|
||||
//目录不存在就创建
|
||||
$this->mk_dir_store($dir);
|
||||
$url = $dir.'/'.date('YmdH').'.txt';
|
||||
|
||||
@@ -29,6 +29,31 @@ class QueueService
|
||||
return $message ? json_decode($message, true) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阻塞式取出消息:队列空时挂在 Redis 上等,有消息立刻返回。
|
||||
*
|
||||
* 相比 pop() + sleep(1) 的轮询,取到消息的延迟从「最多 1 秒」降到接近 0,
|
||||
* 对「下单 -> 调上游发放」这种链路首段能实打实省掉一截等待。
|
||||
*
|
||||
* @param string $queueName
|
||||
* @param int $timeout 最长阻塞秒数(到点返回 null,便于外层做兜底巡检)
|
||||
* @return array|string|null
|
||||
*/
|
||||
public function bpop($queueName, $timeout = 5)
|
||||
{
|
||||
// BLPOP 会把连接挂住 $timeout 秒,必须短于 Redis 连接的读超时
|
||||
// (未显式配置时取 PHP default_socket_timeout,通常 60 秒),
|
||||
// 否则会抛读超时异常而不是正常返回 null。这里夹到 30 秒以内。
|
||||
$timeout = max(1, min(30, (int) $timeout));
|
||||
// Predis 的 blpop 返回 [队列名, 消息],超时返回 null
|
||||
$res = $this->redis->blpop([$queueName], $timeout);
|
||||
if (empty($res[1])) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($res[1], true);
|
||||
return $decoded === null ? $res[1] : $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟推送(ZSET)
|
||||
* @param string $queueName
|
||||
|
||||
@@ -29,7 +29,7 @@ class QytConfig
|
||||
/** 后台设置可覆盖的接入方级标量项 */
|
||||
const OVERRIDABLE = [
|
||||
'base_url', 'app_id', 'no_prefix', 'voucher_name',
|
||||
'h5_base_url', 'h5_app_id', 'consume_notify_url',
|
||||
'h5_base_url', 'h5_app_id', 'consume_notify_url', 'waybill_notify_url',
|
||||
];
|
||||
|
||||
/** @var array|null */
|
||||
@@ -55,10 +55,13 @@ class QytConfig
|
||||
$conf[$k] = trim((string) $db[$k]);
|
||||
}
|
||||
}
|
||||
// 日志开关是布尔,0 是合法值,不能用非空判断
|
||||
// 布尔项 0 是合法值,不能用非空判断
|
||||
if (isset($db['log'])) {
|
||||
$conf['log'] = (bool) $db['log'];
|
||||
}
|
||||
if (isset($db['waybill_notify'])) {
|
||||
$conf['waybill_notify'] = (bool) $db['waybill_notify'];
|
||||
}
|
||||
|
||||
self::$cache = $conf;
|
||||
self::$cacheAt = time();
|
||||
|
||||
@@ -550,13 +550,13 @@ class QytOil extends BaseService
|
||||
return bcdiv((string) (int) $fen, '100', 2);
|
||||
}
|
||||
|
||||
/** 记录日志到 runtime/storelog */
|
||||
/** 记录日志到 runtime/storelog(store_log_path 保证各进程写同一处,见 app/common.php) */
|
||||
public function saveLog($data): void
|
||||
{
|
||||
if (empty($this->conf['log'])) {
|
||||
return;
|
||||
}
|
||||
$dir = runtime_path('storelog/') . date('Ym') . '/' . date('Ymd');
|
||||
$dir = store_log_path() . date('Ym') . '/' . date('Ymd');
|
||||
is_dir($dir) || @mkdir($dir, 0777, true);
|
||||
@file_put_contents(
|
||||
$dir . '/qyt_' . date('YmdH') . '.txt',
|
||||
|
||||
@@ -403,10 +403,11 @@ class QytRefund
|
||||
'response' => self::dumpRes($res),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
// 只有状态跃迁胜出者才回补合作商余额,保证只退一次
|
||||
// 只有状态跃迁胜出者才原路退款,保证只退一次
|
||||
// · 合作商单 -> 回补 partners.balance
|
||||
// · 商城单 -> 退回下单用户余额(按实付折算)
|
||||
if ($won) {
|
||||
PartnersBalance::refund((int) $order['partners_id'], $amount, (string) $order['partners_order_no'],
|
||||
(int) $order['order_id'], (int) $order['store_id']);
|
||||
MallOil::giveBack($order, $amount, '订单退款');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -422,7 +423,8 @@ class QytRefund
|
||||
'optType' => 'refund',
|
||||
'retry_count' => 0,
|
||||
], 320));
|
||||
(new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已回补合作商余额");
|
||||
MallOil::syncGoods($order, 'refund');
|
||||
(new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已原路退款");
|
||||
}
|
||||
$order['refund_status'] = self::SUCCESS;
|
||||
$order['refund_amount'] = $amount;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企油通 · 运单油费「发放结果」状态机
|
||||
// +----------------------------------------------------------------------
|
||||
// | 7.7 运单油费下发是【异步】接口,受理成功不等于发放成功。拿到最终结果有两条路:
|
||||
// | · 推:3.10 发放结果通知(一个批次一条,整批处理完后企油通主动推给我们)
|
||||
// | · 拉:7.8 waybillBatch 按 batch_no 查询(兜底,见下)
|
||||
// |
|
||||
// | 两条路得出的结论必须完全一致,所以判定与状态跃迁都收敛在本类,
|
||||
// | QytNotify(推)与 QytStatusQueue(拉)只负责把数据喂进来。
|
||||
// |
|
||||
// | 为什么有了推送还要留着拉:
|
||||
// | 1. 未在企油通后台配置接收地址 -> 根本不推(且不视为失败、不补推)
|
||||
// | 2. 推送失败重试 5 次后放弃(文档建议此时用 7.8 查)
|
||||
// | 3. 我们自己的接收端宕机 / 发版重启窗口期
|
||||
// | 所以轮询保留为安全网,只是间隔放长(见 QytStatusQueue::intervalSql)
|
||||
// |
|
||||
// | 并发/幂等:
|
||||
// | 状态跃迁是条件 UPDATE(... WHERE oil_status_zsy=1),affected=1 才退款/回调,
|
||||
// | 因此「同一批次重复推送」「推送与轮询同时到达」都只会生效一次。
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
class QytWaybill
|
||||
{
|
||||
// 明细级 handle_status(7.8 出参 / 3.10 通知 data.list[].handle_status)
|
||||
const HANDLE_PENDING = 1;
|
||||
const HANDLE_SUCCESS = 2;
|
||||
const HANDLE_FAILED = 3;
|
||||
|
||||
// 批次级 status(7.8 出参 data.status / 3.10 通知 data.status)
|
||||
const BATCH_PENDING = 1; // 待处理
|
||||
const BATCH_RUNNING = 2; // 处理中
|
||||
const BATCH_ALL_OK = 3; // 全部成功
|
||||
const BATCH_PART_OK = 4; // 部分成功
|
||||
const BATCH_FAILED = 5; // 处理失败
|
||||
|
||||
// judge() 的结论
|
||||
const R_SUCCESS = 'success';
|
||||
const R_FAILED = 'failed';
|
||||
const R_PENDING = 'pending';
|
||||
|
||||
/**
|
||||
* 纯函数:根据批次级 status 与本条明细,判定这一单的最终发放结果。
|
||||
*
|
||||
* 文档 3.10 明确:**以批次级 status 为准**——批次因「企业信息不存在」之类
|
||||
* 整体失败时,明细里可能仍然写着「待处理」。只看 handle_status 会让这种单
|
||||
* 永远停在发放中,钱也永远不退。
|
||||
*
|
||||
* @param int $batchStatus 批次级 status
|
||||
* @param array|null $row 本条明细 ['handle_status','error_msg','remark'],不存在传 null
|
||||
* @return array ['result' => success|failed|pending, 'text' => string]
|
||||
*/
|
||||
public static function judge(int $batchStatus, ?array $row): array
|
||||
{
|
||||
$handle = $row ? (int) ($row['handle_status'] ?? 0) : 0;
|
||||
|
||||
// 1) 整批处理失败:不管明细写的是什么,这一单都拿不到钱
|
||||
if ($batchStatus === self::BATCH_FAILED && $handle !== self::HANDLE_SUCCESS) {
|
||||
$msg = $row ? trim((string) ($row['error_msg'] ?? '')) : '';
|
||||
return ['result' => self::R_FAILED, 'text' => $msg !== '' ? $msg : '企油通返回整批处理失败'];
|
||||
}
|
||||
// 2) 明细里没有这一条:批次还在排队,继续等
|
||||
if (!$row) {
|
||||
return ['result' => self::R_PENDING, 'text' => ''];
|
||||
}
|
||||
if ($handle === self::HANDLE_SUCCESS) {
|
||||
return ['result' => self::R_SUCCESS, 'text' => trim((string) ($row['remark'] ?? '')) ?: '发放成功'];
|
||||
}
|
||||
if ($handle === self::HANDLE_FAILED) {
|
||||
return ['result' => self::R_FAILED, 'text' => trim((string) ($row['error_msg'] ?? '')) ?: '企油通发放失败'];
|
||||
}
|
||||
// 1 待处理,或未知值 -> 继续等
|
||||
return ['result' => self::R_PENDING, 'text' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 3.10 通知或 7.8 响应的 data.list 里挑出某条运单流水
|
||||
* @return array|null ['handle_status','error_msg','remark']
|
||||
*/
|
||||
public static function pick(array $data, string $waybillNoCode): ?array
|
||||
{
|
||||
return QytOil::pickWaybill($data, $waybillNoCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放成功:条件跃迁 1 -> 2,胜出者才回调下游 / 同步小程序端
|
||||
*
|
||||
* @param array $order partners_order 记录
|
||||
* @param string $text 备注(上游 remark)
|
||||
* @param string $from 结果来源,只用于日志:'3.10 通知' / '7.8 轮询'
|
||||
* @return bool 是否由本次调用完成跃迁
|
||||
*/
|
||||
public static function markSuccess(array $order, string $text, string $from = ''): bool
|
||||
{
|
||||
$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' => mb_substr($text, 0, 190),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if (!$affected) {
|
||||
return false;
|
||||
}
|
||||
MallOil::syncGoods($order, 'share_oil');
|
||||
(new QueueService())->push('notify_queue', json_encode([
|
||||
'order_id' => $order['order_id'],
|
||||
'type' => 'add',
|
||||
'optType' => 'share_oil',
|
||||
'retry_count' => 0,
|
||||
], 320));
|
||||
self::log("{$from} 确认发放成功 order={$order['order_id']} batch_no={$order['batch_no']}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放失败:条件跃迁 1 -> 3,胜出者才退款(保证只退一次)
|
||||
*
|
||||
* @return bool 是否由本次调用完成跃迁
|
||||
*/
|
||||
public static function markFailed(array $order, string $reason, string $from = ''): bool
|
||||
{
|
||||
$affected = Db::name('partners_order')
|
||||
->where('order_id', $order['order_id'])
|
||||
->where('oil_status_zsy', 1)
|
||||
->update([
|
||||
'oil_status_zsy' => 3,
|
||||
'oil_text' => mb_substr('发放失败:' . $reason, 0, 190),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if (!$affected) {
|
||||
return false;
|
||||
}
|
||||
// 原路退款:合作商单回补合作商余额,商城单退回下单用户余额
|
||||
MallOil::giveBack($order, (float) $order['total_price'], '发放失败');
|
||||
MallOil::syncGoods($order, 'share_oil');
|
||||
self::log("{$from} 确认发放失败 order={$order['order_id']} 原因={$reason},已原路退款 {$order['total_price']}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按判定结果落状态。已经不在「发放中」的单会被安全忽略,
|
||||
* 但「已判失败又被告知成功」属于资损风险,必须告警而不是静默翻转。
|
||||
*
|
||||
* @return string 实际发生的动作:success / failed / pending / skipped / conflict
|
||||
*/
|
||||
public static function apply(array $order, array $judged, string $from = ''): string
|
||||
{
|
||||
$current = (int) $order['oil_status_zsy'];
|
||||
|
||||
if ($judged['result'] === self::R_PENDING) {
|
||||
return self::R_PENDING;
|
||||
}
|
||||
|
||||
if ($current !== 1) {
|
||||
// 终态与上游结论不一致 -> 一律只告警、不自动翻转:
|
||||
// 3 -> 成功:我们已经退过款,钱却可能真发出去了(资损)
|
||||
// 2 -> 失败:我们已经通知下游发放成功、小程序端也显示可加油了
|
||||
// 两种都要人工到企油通后台核对,自动改状态只会把问题放大。
|
||||
$conflict = ($current === 3 && $judged['result'] === self::R_SUCCESS)
|
||||
|| ($current === 2 && $judged['result'] === self::R_FAILED);
|
||||
if ($conflict) {
|
||||
self::log("【告警】order={$order['order_id']} batch_no={$order['batch_no']} 本地发放态为「"
|
||||
. ($current === 3 ? '发放失败(已退款)' : '发放成功(已通知下游)')
|
||||
. "」,但{$from}返回「" . ($judged['result'] === self::R_SUCCESS ? '发放成功' : '发放失败')
|
||||
. "」:{$judged['text']}。请人工到企油通后台核对,系统不会自动翻转状态。");
|
||||
return 'conflict';
|
||||
}
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
if ($judged['result'] === self::R_SUCCESS) {
|
||||
return self::markSuccess($order, $judged['text'], $from) ? self::R_SUCCESS : 'skipped';
|
||||
}
|
||||
return self::markFailed($order, $judged['text'], $from) ? self::R_FAILED : 'skipped';
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录本次通知时间与次数,便于排查「这单到底推没推过、推了几次」。
|
||||
* 纯留痕,失败不能影响发放态跃迁(比如迁移脚本还没跑、少了这两列)。
|
||||
*/
|
||||
public static function touchNotify(int $orderId, int $notifyTime): void
|
||||
{
|
||||
try {
|
||||
Db::name('partners_order')->where('order_id', $orderId)->update([
|
||||
'qyt_notify_time' => $notifyTime ?: time(),
|
||||
'qyt_notify_num' => Db::raw('qyt_notify_num + 1'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
self::log('3.10 通知留痕失败 order=' . $orderId . ':' . $e->getMessage()
|
||||
. '(不影响发放态,确认 qyt_migration_waybill_notify.sql 是否已执行)');
|
||||
}
|
||||
}
|
||||
|
||||
protected static function log(string $text): void
|
||||
{
|
||||
(new QytOil())->saveLog($text);
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ class SybOil extends BaseService
|
||||
public function saveLogStore($data){
|
||||
$years = date('Ym');
|
||||
$day = date('Ymd');
|
||||
//设置路径目录信息
|
||||
$dir = runtime_path('storelog/').$years.'/'.$day;
|
||||
//设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
|
||||
$dir = store_log_path().$years.'/'.$day;
|
||||
//目录不存在就创建
|
||||
$this->mk_dir_store($dir);
|
||||
$url = $dir.'/'.date('YmdH').'.txt';
|
||||
|
||||
@@ -8,6 +8,8 @@ use think\console\Input;
|
||||
use think\console\Output;
|
||||
use app\store\model\order\Record as RecordModel;
|
||||
use app\common\service\SybOil;
|
||||
use app\common\service\QytWaybill;
|
||||
use app\common\lock\RedisLock;
|
||||
use think\facade\Db;
|
||||
|
||||
class CallbackQueue extends Command
|
||||
@@ -427,6 +429,58 @@ class CallbackQueue extends Command
|
||||
$SybOil->saveLogStore('企油通消费回调处理成功:' . json_encode($c, 320));
|
||||
}
|
||||
break;
|
||||
case "qyt_waybill_result":
|
||||
// 企油通 3.10「发放结果通知」:一个批次一条,整批明细都在 res.list 里。
|
||||
// 判定与状态跃迁跟 7.8 轮询共用 QytWaybill,保证两条路结论一致、互相幂等。
|
||||
$batchNo = (string)($res['batch_no'] ?? '');
|
||||
$batchStatus = (int)($res['status'] ?? 0);
|
||||
$notifyTime = !empty($res['notify_time']) ? (strtotime((string)$res['notify_time']) ?: time()) : time();
|
||||
$list = (array)($res['list'] ?? []);
|
||||
$stat = ['success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0, 'conflict' => 0, 'miss' => 0];
|
||||
|
||||
foreach ($list as $row) {
|
||||
$code = trim((string)($row['waybill_no_code'] ?? ''));
|
||||
if ($code === '') {
|
||||
$stat['miss']++;
|
||||
continue;
|
||||
}
|
||||
$order = Db::name('partners_order')->where('waybill_no_code', $code)->find();
|
||||
if (!$order) {
|
||||
// 同一个 appId 下可能还挂着别的接入系统提交的批次,匹配不到不算异常
|
||||
$stat['miss']++;
|
||||
continue;
|
||||
}
|
||||
// 留痕:这一单到底收没收到过推送、收了几次
|
||||
QytWaybill::touchNotify((int)$order['order_id'], $notifyTime);
|
||||
|
||||
// 每单串行,避免与 qyt:status 轮询同时跃迁
|
||||
$lock = new RedisLock('qyt:status:' . $order['order_id'], 60);
|
||||
if (!$lock->acquireLock()) {
|
||||
// 轮询正在处理这一单,跳过即可:两条路的结论一样
|
||||
$stat['skipped']++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// 锁内重读,拿到最新状态
|
||||
$fresh = Db::name('partners_order')->where('order_id', $order['order_id'])->find();
|
||||
$judged = QytWaybill::judge($batchStatus, [
|
||||
'handle_status' => (int)($row['handle_status'] ?? 0),
|
||||
'error_msg' => (string)($row['error_msg'] ?? ''),
|
||||
'remark' => (string)($row['remark'] ?? ''),
|
||||
]);
|
||||
$stat[QytWaybill::apply($fresh ?: $order, $judged, '3.10 通知')]++;
|
||||
} finally {
|
||||
$lock->releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
$SybOil->saveLogStore('3.10 发放结果通知处理完成 batch_no=' . $batchNo
|
||||
. ' 批次状态=' . $batchStatus . ' 明细=' . count($list)
|
||||
. ' 成功=' . $stat['success'] . ' 失败=' . $stat['failed']
|
||||
. ' 待处理=' . $stat['pending'] . ' 已是终态=' . $stat['skipped']
|
||||
. ' 未匹配=' . $stat['miss']
|
||||
. ($stat['conflict'] ? ' 【冲突=' . $stat['conflict'] . ',需人工核对】' : ''));
|
||||
break;
|
||||
default:
|
||||
$SybOil->saveLogStore('队列数据处理异常,未知:【'.$message.'】');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace app\console\command;
|
||||
|
||||
use app\common\service\MallOil;
|
||||
use app\common\service\PartnersOil;
|
||||
use app\common\service\QueueService;
|
||||
use think\console\Command;
|
||||
@@ -82,6 +83,17 @@ class NotifyQueue extends Command
|
||||
if(!$order){
|
||||
return false;
|
||||
}
|
||||
// 小程序(商城)单没有下游下单平台可回调:把状态回写到 yoshop_order_goods,
|
||||
// 小程序端的订单列表/详情、后台订单页的基础列都读那张表。直接算成功,不重试。
|
||||
if (MallOil::isMall($order)) {
|
||||
MallOil::syncGoods($order, (string)($data['optType'] ?? ''));
|
||||
// 逐笔标记已处理,否则后台「重推加油结果」会一直认为这些流水没通知过
|
||||
if (!empty($data['consume_id'])) {
|
||||
Db::name('qyt_consume')->where('id', (int)$data['consume_id'])
|
||||
->update(['notify_status' => 1, 'notify_time' => time()]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
$partners = partnersModel::detail($order['partners_id']);
|
||||
if(!$partners){
|
||||
return false;
|
||||
|
||||
@@ -22,7 +22,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\MallOil;
|
||||
use app\common\service\QueueService;
|
||||
use app\common\service\QytOil;
|
||||
use think\console\Command;
|
||||
@@ -48,12 +48,13 @@ class PartnersQueue extends Command
|
||||
{
|
||||
$this->queueService = new QueueService();
|
||||
while (true) {
|
||||
$message = $this->queueService->pop('partners_queue');
|
||||
// 阻塞式取队列:有单立刻开始下发,不用等轮询间隔。
|
||||
// 5 秒超时只是为了让空闲时也能跑到下面的兜底巡检(内部已限频 60s)。
|
||||
$message = $this->queueService->bpop('partners_queue', 5);
|
||||
if ($message) {
|
||||
$this->processMessage($message);
|
||||
} else {
|
||||
$this->reconcile();
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,18 +199,14 @@ class PartnersQueue extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放失败 -> 回补合作商余额(仅在状态跃迁胜出时调用,天然幂等)
|
||||
* 发放失败 -> 原路退款(仅在状态跃迁胜出时调用,天然幂等)
|
||||
* · 合作商单 -> 回补 partners.balance
|
||||
* · 商城单 -> 退回下单用户余额(按实付折算),并把失败状态回写 order_goods
|
||||
*/
|
||||
protected function refundBalance($order)
|
||||
{
|
||||
PartnersBalance::refund(
|
||||
(int) $order['partners_id'],
|
||||
(float) $order['total_price'],
|
||||
(string) $order['partners_order_no'],
|
||||
(int) $order['order_id'],
|
||||
(int) $order['store_id']
|
||||
);
|
||||
(new QytOil())->saveLog("发放失败已回补合作商余额 order={$order['order_id']} amount={$order['total_price']}");
|
||||
MallOil::giveBack($order, (float) $order['total_price'], '发放失败');
|
||||
MallOil::syncGoods($order);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企油通「运单发放结果」轮询 (7.8 waybillBatch)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 为什么需要它:7.7 运单油费下发是【异步】接口,返回成功只代表受理,
|
||||
// | 真实发放结果必须按 batch_no 调 7.8 查(《企油通接口对接文档.md》§5 第 7 条)。
|
||||
// | 为什么需要它:7.7 运单油费下发是【异步】接口,返回成功只代表受理。
|
||||
// |
|
||||
// | 拿最终结果有两条路,判定逻辑共用 app\common\service\QytWaybill:
|
||||
// | · 推:3.10 发放结果通知 -> public/qytwaybill.php -> callback:queue(主路径)
|
||||
// | · 拉:本命令按 batch_no 调 7.8 查询(安全网)
|
||||
// |
|
||||
// | 接上推送后本命令不会下线:上游未配置接收地址不推送、推送重试 5 次后放弃、
|
||||
// | 我方接收端宕机或发版重启,这三种情况都只能靠轮询兜底。此时退避间隔会自动放长,
|
||||
// | 见 intervalSql()。
|
||||
// |
|
||||
// | 状态流转(oil_status_zsy):
|
||||
// | 0 未提交 --PartnersQueue 调7.7受理--> 1 发放中 --本命令查7.8--> 2 成功 / 3 失败
|
||||
@@ -27,9 +34,9 @@ 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\QytConfig;
|
||||
use app\common\service\QytOil;
|
||||
use app\common\service\QytWaybill;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
@@ -40,15 +47,12 @@ class QytStatusQueue extends Command
|
||||
/** 每轮扫描的最大订单数 */
|
||||
const BATCH_LIMIT = 100;
|
||||
|
||||
/** 空转时的休眠秒数 */
|
||||
const IDLE_SLEEP = 5;
|
||||
/** 空转时的休眠秒数(只是一条轻量 SQL,不打上游,可以压到 1 秒) */
|
||||
const IDLE_SLEEP = 1;
|
||||
|
||||
/** 查询次数达到该值后仍未定论,视为异常滞留,写告警日志 */
|
||||
const STUCK_NUM = 100;
|
||||
|
||||
/** @var QueueService */
|
||||
protected $queueService;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qyt:status')
|
||||
@@ -57,7 +61,7 @@ class QytStatusQueue extends Command
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$this->queueService = new QueueService();
|
||||
// 通知下游 / 同步小程序端都在 QytWaybill 里做,这里只负责查结果
|
||||
while (true) {
|
||||
$handled = 0;
|
||||
try {
|
||||
@@ -94,11 +98,38 @@ class QytStatusQueue extends Command
|
||||
|
||||
/**
|
||||
* 退避间隔(秒)的 SQL 表达式:查得越久间隔越长,避免高频打上游
|
||||
* 前 10 次 30s,10~30 次 120s,之后 600s
|
||||
*
|
||||
* 为什么前几次要这么密:7.7 受理后第一次 7.8 几乎必然返回「待处理」,
|
||||
* 如果第二次要等 30 秒,哪怕企油通第 8 秒就发完了,用户也得等到第 35 秒。
|
||||
* 绝大多数单在前 20 秒内就有结果,所以把这一段压到 3 秒一次,
|
||||
* 代价是每单多打上游几次;拖久的单自动退到长间隔,总 QPS 不会失控。
|
||||
*
|
||||
* 注意:若已接入 3.10 发放结果通知(后台「企油通对接设置」里打开开关),
|
||||
* 走的是上面那条「安全网」曲线,不是下面这条抢时间的曲线。
|
||||
*
|
||||
* 次数 <6 -> 3s (覆盖 0~18s,正常单在这里出结果)
|
||||
* 次数 <16 -> 10s (覆盖至约 2 分钟)
|
||||
* 次数 <26 -> 30s (覆盖至约 7 分钟)
|
||||
* 次数 <46 -> 120s (覆盖至约 47 分钟)
|
||||
* 之后 -> 600s
|
||||
*/
|
||||
protected function intervalSql(): string
|
||||
{
|
||||
return 'CASE WHEN qyt_query_num < 10 THEN 30 WHEN qyt_query_num < 30 THEN 120 ELSE 600 END';
|
||||
// 接上 3.10 发放结果通知后,结果以推送为主,轮询退为安全网:
|
||||
// 只覆盖「上游没配接收地址 / 推送重试 5 次仍失败 / 我方接收端当时不可用」
|
||||
// 这三种情况,没必要再抢在推送前面把上游打一遍。
|
||||
if (!empty(QytConfig::get()['waybill_notify'])) {
|
||||
return 'CASE'
|
||||
. ' WHEN qyt_query_num < 3 THEN 60'
|
||||
. ' WHEN qyt_query_num < 8 THEN 300'
|
||||
. ' ELSE 900 END';
|
||||
}
|
||||
return 'CASE'
|
||||
. ' WHEN qyt_query_num < 6 THEN 3'
|
||||
. ' WHEN qyt_query_num < 16 THEN 10'
|
||||
. ' WHEN qyt_query_num < 26 THEN 30'
|
||||
. ' WHEN qyt_query_num < 46 THEN 120'
|
||||
. ' ELSE 600 END';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,31 +174,16 @@ class QytStatusQueue extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// 判定与状态跃迁跟 3.10 推送共用 QytWaybill,两条路结论必须一致
|
||||
$batchStatus = (int) ($res['data']['status'] ?? 0);
|
||||
$row = QytOil::pickWaybill($res['data'], (string) $order['waybill_no_code']);
|
||||
$row = QytWaybill::pick($res['data'], (string) $order['waybill_no_code']);
|
||||
$judged = QytWaybill::judge($batchStatus, $row);
|
||||
|
||||
// 整批处理失败,且明细里没有本条 -> 判失败
|
||||
if (!$row && $batchStatus === 5) {
|
||||
$this->markFailed($order, '企油通返回整批处理失败', $qyt);
|
||||
return;
|
||||
}
|
||||
// 明细里还没有本条:批次还在排队,继续等
|
||||
if (!$row) {
|
||||
if ($judged['result'] === QytWaybill::R_PENDING) {
|
||||
$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;
|
||||
}
|
||||
QytWaybill::apply($order, $judged, '7.8 轮询');
|
||||
} catch (\Throwable $e) {
|
||||
$this->log("order={$orderId} 轮询异常:" . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
} finally {
|
||||
@@ -175,60 +191,6 @@ class QytStatusQueue extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放成功:条件跃迁 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']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 长期拿不到结果时告警。只写日志,不改状态、不退款
|
||||
*/
|
||||
|
||||
@@ -153,8 +153,10 @@ class Order extends Controller
|
||||
*/
|
||||
public function cancelShare(): Json
|
||||
{
|
||||
// 企油通改造:本功能依赖已下线的上游(智慧油客/定聚),现已停用。
|
||||
return $this->renderError('小程序油品链路已停用');
|
||||
// 企油通改造:小程序订单的发放/加油/退款统一走 yoshop_partners_order,
|
||||
// 后台操作接口复用 app/store/controller/partners/Order.php(/partners.order/*),
|
||||
// 前端订单页传的是商品行上挂的 qyt.order_id。本接口保留仅为兼容老前端。
|
||||
return $this->renderError('该入口已迁移,请使用 /partners.order/ 下的同名接口');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,8 +166,10 @@ class Order extends Controller
|
||||
*/
|
||||
public function qrCode(): Json
|
||||
{
|
||||
// 企油通改造:本功能依赖已下线的上游(智慧油客/定聚),现已停用。
|
||||
return $this->renderError('小程序油品链路已停用');
|
||||
// 企油通改造:小程序订单的发放/加油/退款统一走 yoshop_partners_order,
|
||||
// 后台操作接口复用 app/store/controller/partners/Order.php(/partners.order/*),
|
||||
// 前端订单页传的是商品行上挂的 qyt.order_id。本接口保留仅为兼容老前端。
|
||||
return $this->renderError('该入口已迁移,请使用 /partners.order/ 下的同名接口');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,7 +254,9 @@ class Order extends Controller
|
||||
*/
|
||||
public function share(): Json
|
||||
{
|
||||
// 企油通改造:本功能依赖已下线的上游(智慧油客/定聚),现已停用。
|
||||
return $this->renderError('小程序油品链路已停用');
|
||||
// 企油通改造:小程序订单的发放/加油/退款统一走 yoshop_partners_order,
|
||||
// 后台操作接口复用 app/store/controller/partners/Order.php(/partners.order/*),
|
||||
// 前端订单页传的是商品行上挂的 qyt.order_id。本接口保留仅为兼容老前端。
|
||||
return $this->renderError('该入口已迁移,请使用 /partners.order/ 下的同名接口');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ declare (strict_types=1);
|
||||
|
||||
namespace app\store\controller\partners;
|
||||
|
||||
use app\common\service\PartnersBalance;
|
||||
use app\common\service\MallOil;
|
||||
use app\common\service\QueueService;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use app\common\service\QytInvoice;
|
||||
@@ -119,16 +119,10 @@ class Order extends Controller
|
||||
if ($order['use_status'] != 0) {
|
||||
return $this->renderError('该单已加油核销,不可重发');
|
||||
}
|
||||
// 发放失败时余额已回补,重发需重新扣减合作商余额
|
||||
$logId = PartnersBalance::deduct(
|
||||
(int) $order['partners_id'],
|
||||
(float) $order['total_price'],
|
||||
(string) $order['partners_order_no'],
|
||||
$orderId,
|
||||
(int) $order['store_id']
|
||||
);
|
||||
if ($logId === false) {
|
||||
return $this->renderError('合作商余额不足,请先充值后再重发');
|
||||
// 发放失败时额度已原路退回,重发需重新扣减:
|
||||
// 合作商单扣合作商余额,小程序单扣下单用户余额
|
||||
if (!MallOil::take($order, (float) $order['total_price'])) {
|
||||
return $this->renderError(MallOil::notEnoughText($order));
|
||||
}
|
||||
// 仅允许从「发放失败(3)」重置为「未提交(0)」,避免与「发放中(1)」的单冲突。
|
||||
// 同时清空编号并把重发次数 +1:企油通按 batch_no 幂等,沿用老批次的话
|
||||
@@ -148,17 +142,13 @@ class Order extends Controller
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if (!$reset) {
|
||||
// 状态已被其它进程改变 -> 回补刚扣的余额
|
||||
PartnersBalance::refund(
|
||||
(int) $order['partners_id'],
|
||||
(float) $order['total_price'],
|
||||
(string) $order['partners_order_no'],
|
||||
$orderId,
|
||||
(int) $order['store_id']
|
||||
);
|
||||
// 状态已被其它进程改变 -> 回补刚扣的额度
|
||||
MallOil::giveBack($order, (float) $order['total_price'], '重发撤销');
|
||||
return $this->renderError('当前状态不允许重发(仅「发放失败」的订单可重发)');
|
||||
}
|
||||
(new QueueService())->push('partners_queue', json_encode(['order_id' => $orderId], 320));
|
||||
// 小程序单把「重新发放」状态同步回订单商品行,用户端立刻能看到
|
||||
MallOil::syncGoods($order);
|
||||
return $this->renderSuccess('已提交重新发放,请稍后刷新查看结果');
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use think\facade\Request;
|
||||
use app\common\model\Goods as GoodsModel;
|
||||
use app\common\model\Order as OrderModel;
|
||||
use app\common\service\Order as OrderService;
|
||||
use app\common\service\QytOil;
|
||||
use app\common\service\Message as MessageService;
|
||||
use app\common\service\order\Refund as RefundService;
|
||||
use app\common\enum\order\OrderStatus as OrderStatusEnum;
|
||||
@@ -121,6 +122,8 @@ class Order extends OrderModel
|
||||
})
|
||||
->order(['order.create_time' => 'desc'])
|
||||
->paginate(10);
|
||||
// 给每个油券商品行挂上对应的企油通发放单(后台订单页的企油通列与操作按钮用)
|
||||
$this->setQytData($list);
|
||||
// 获取数据列表
|
||||
return [
|
||||
'list' => $list,
|
||||
@@ -128,6 +131,55 @@ class Order extends OrderModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 给订单商品行挂上企油通发放单数据(字段名 qyt)
|
||||
*
|
||||
* 小程序订单在支付成功后由 MallOil::create 为每个油券商品行建了一条
|
||||
* yoshop_partners_order(source='mall',mall_order_goods_id 指回商品行),
|
||||
* 企油通的发放/加油/退款/发票全挂在那张表上。后台订单页要展示这些信息、
|
||||
* 并复用 /partners.order/* 的操作接口,所以这里按商品行把发放单带出来。
|
||||
*
|
||||
* @param \think\Paginator|\think\Collection $list
|
||||
*/
|
||||
public function setQytData($list): void
|
||||
{
|
||||
$goodsIds = [];
|
||||
foreach ($list as $order) {
|
||||
foreach ($order['goods'] as $goods) {
|
||||
$goodsIds[] = (int)$goods['order_goods_id'];
|
||||
}
|
||||
}
|
||||
if (!$goodsIds) {
|
||||
return;
|
||||
}
|
||||
$rows = Db::name('partners_order')
|
||||
->where('source', '=', 'mall')
|
||||
->whereIn('mall_order_goods_id', $goodsIds)
|
||||
->select()->toArray();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
// 身份证号脱敏(后台展示层)
|
||||
$row['id_card_mask'] = QytOil::maskIdCard((string)$row['id_card']);
|
||||
unset($row['id_card']);
|
||||
// 前端用的是严格相等(item.oil_status_zsy !== 2),这里显式转型,
|
||||
// 不依赖 PDO 是否把 int 列取成字符串
|
||||
foreach (['order_id', 'oil_status', 'oil_status_zsy', 'use_status', 'refund_status',
|
||||
'used_num', 'oil_callback', 'use_callback', 'qyt_enterprise_id',
|
||||
'mall_order_id', 'mall_order_goods_id', 'user_id'] as $k) {
|
||||
$row[$k] = (int)$row[$k];
|
||||
}
|
||||
// 剩余可退金额 = 发放金额 - 已加油 - 已退款
|
||||
$row['remain_amount'] = number_format(max(0, (float)$row['total_price']
|
||||
- (float)$row['used_amount'] - (float)$row['refund_amount']), 2, '.', '');
|
||||
$map[(int)$row['mall_order_goods_id']] = $row;
|
||||
}
|
||||
foreach ($list as $order) {
|
||||
foreach ($order['goods'] as $goods) {
|
||||
$goods['qyt'] = $map[(int)$goods['order_goods_id']] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 订单列表(全部)
|
||||
@@ -173,6 +225,8 @@ class Order extends OrderModel
|
||||
'pay_status' => -1, // 支付状态
|
||||
'oil_status' => -1, // 分油状态
|
||||
'use_status' => -1, // 使用状态
|
||||
'oil_status_zsy' => -1, // 企油通发放态 0未提交 1发放中 2成功 3失败
|
||||
'refund_status' => -1, // 退款状态 0未退款 1退款中 2已退款 3退款失败 4待人工确认
|
||||
'deliveryType' => -1, // 配送方式
|
||||
'betweenTime' => [], // 起止时间
|
||||
'userId' => 0, // 会员ID
|
||||
@@ -233,6 +287,22 @@ class Order extends OrderModel
|
||||
->column('order_id'); // 获取所有符合条件的 order_id 数组
|
||||
$filter[] = ['order.order_id', 'in', $subQuery];
|
||||
}
|
||||
// 企油通发放态(挂在 partners_order 上,按 mall_order_id 反查)
|
||||
if($params['oil_status_zsy'] > -1){
|
||||
$subQuery = Db::name('partners_order')
|
||||
->where('source', '=', 'mall')
|
||||
->where('oil_status_zsy', '=', (int)$params['oil_status_zsy'])
|
||||
->column('mall_order_id');
|
||||
$filter[] = ['order.order_id', 'in', $subQuery];
|
||||
}
|
||||
// 退款状态(同上)
|
||||
if($params['refund_status'] > -1){
|
||||
$subQuery = Db::name('partners_order')
|
||||
->where('source', '=', 'mall')
|
||||
->where('refund_status', '=', (int)$params['refund_status'])
|
||||
->column('mall_order_id');
|
||||
$filter[] = ['order.order_id', 'in', $subQuery];
|
||||
}
|
||||
// 配送方式
|
||||
$params['deliveryType'] > -1 && $filter[] = ['delivery_type', '=', (int)$params['deliveryType']];
|
||||
// 用户id
|
||||
|
||||
@@ -81,10 +81,13 @@ class Order extends OrderModel
|
||||
'use_status' => -1, // 加油核销状态
|
||||
'refund_status' => -1, // 退款状态 0未退款 1退款中 2已退款 3退款失败 4待人工确认
|
||||
'id_card' => '', // 司机身份证号(从司机管理跳转过来时使用)
|
||||
'source' => 'partner', // 订单来源 partner=下单平台 mall=小程序商城 all=全部
|
||||
'betweenTime' => [] // 起止时间
|
||||
]);
|
||||
// 检索查询条件
|
||||
$filter = [];
|
||||
// 来源:本页只管下单平台的单,小程序单在「订单管理」里看,默认过滤掉
|
||||
$params['source'] !== 'all' && $filter[] = ['order.source', '=', (string)$params['source']];
|
||||
// 合作商ID
|
||||
$params['partners_id'] > 0 && $filter[] = ['order.partners_id', '=', $params['partners_id']];
|
||||
// 司机手机号
|
||||
@@ -150,8 +153,9 @@ class Order extends OrderModel
|
||||
if(!empty($partners)) {
|
||||
$filter[] = ['partners_id', '=', $partners['partners_id']];
|
||||
}
|
||||
// 总销售额
|
||||
// 总销售额(只统计下单平台的单,小程序单不计入合作商口径)
|
||||
return $query->where('oil_status_zsy', '=', 2)
|
||||
->where('source', '=', 'partner')
|
||||
->where($filter)
|
||||
->sum('total_price');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user