Files
oil/server-tp6/app/common/service/MallOil.php
T
vipnub fafd3e2c16 1、合作商余额查询
2、分油账户司机可用额度
2026-10-20 23:21:10 +08:00

391 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | 小程序(商城)订单 -> 企油通发放
// +----------------------------------------------------------------------
// | 设计:小程序订单不另起一套企油通链路,而是在支付成功后为每个油券商品行
// | 建一条 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);
}
}