391 lines
17 KiB
PHP
391 lines
17 KiB
PHP
<?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);
|
||
}
|
||
}
|