```
feat(split): 添加分账手动指定接收方功能 - 实现SplitManualService服务类,支持手动覆盖分账明细 - 新增SplitManual和SplitManualLog数据模型 - 修改SplitCalculator优先使用手动覆盖数据进行分账计算 - 添加分账记录手动指定接收方的数据库表结构 - 集成手动分账到汇付分账流程中,支持失败重试机制 - 添加商品详情内容更新SQL脚本用于汇付审核 ```
这个提交包含在:
@@ -21,10 +21,22 @@ class SplitCalculator
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算订单分账明细
|
||||
* 计算订单分账明细(优先手动覆盖)
|
||||
* @return array [['role_type','role_id','receiver_id','receiver_account','receiver_type','receiver_name','amount','description']]
|
||||
*/
|
||||
public function calculate($orderId)
|
||||
{
|
||||
$manualItems = (new SplitManualService())->getCalculateItems($orderId);
|
||||
if (!empty($manualItems)) {
|
||||
return $manualItems;
|
||||
}
|
||||
return $this->calculateAuto($orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按佣金/规则自动计算分账明细
|
||||
*/
|
||||
public function calculateAuto($orderId)
|
||||
{
|
||||
$order = Order::get($orderId);
|
||||
if (!$order || !in_array($order['pay_type'], ['wechat', 'huifu_wechat'])) {
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
namespace addons\shopro\library\split;
|
||||
|
||||
use addons\shopro\model\Order;
|
||||
use addons\shopro\model\split\Receiver;
|
||||
use addons\shopro\model\split\SplitManual;
|
||||
use addons\shopro\model\split\SplitManualLog;
|
||||
use addons\shopro\model\split\SplitOrder;
|
||||
use think\Db;
|
||||
|
||||
class SplitManualService
|
||||
{
|
||||
protected $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new Config();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否存在手动覆盖
|
||||
*/
|
||||
public function hasOverride($orderId)
|
||||
{
|
||||
return (bool) SplitManual::where('order_id', intval($orderId))->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 供 SplitCalculator 使用的明细
|
||||
* @return array
|
||||
*/
|
||||
public function getCalculateItems($orderId)
|
||||
{
|
||||
$manual = SplitManual::where('order_id', intval($orderId))->find();
|
||||
if (!$manual) {
|
||||
return [];
|
||||
}
|
||||
$items = $manual['items'] ?? [];
|
||||
if (!is_array($items) || empty($items)) {
|
||||
return [];
|
||||
}
|
||||
return $this->normalizeCalculateItems($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存手动覆盖
|
||||
* @param int $orderId
|
||||
* @param array $items [{receiver_id, role_type, amount, description?}]
|
||||
* @param int $adminId
|
||||
* @param string $adminName
|
||||
* @param string $remark
|
||||
* @return SplitManual
|
||||
*/
|
||||
public function save($orderId, array $items, $adminId = 0, $adminName = '', $remark = '')
|
||||
{
|
||||
$orderId = intval($orderId);
|
||||
$this->assertEditableSplitOrder($orderId);
|
||||
$normalized = $this->validateAndNormalizeItems($orderId, $items);
|
||||
|
||||
$existing = SplitManual::where('order_id', $orderId)->find();
|
||||
$beforeItems = $existing ? ($existing['items'] ?? []) : [];
|
||||
$action = $existing ? 'update' : 'create';
|
||||
|
||||
$originalItems = [];
|
||||
if (!$existing) {
|
||||
$calculator = new SplitCalculator();
|
||||
$originalItems = $calculator->calculateAuto($orderId);
|
||||
$originalItems = $this->enrichItemsForStorage($originalItems);
|
||||
} else {
|
||||
$originalItems = $existing['original_items'] ?? [];
|
||||
}
|
||||
|
||||
$storageItems = $this->enrichItemsForStorage($normalized);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($existing) {
|
||||
$existing->items = $storageItems;
|
||||
$existing->admin_id = intval($adminId);
|
||||
$existing->remark = $remark;
|
||||
$existing->save();
|
||||
$manual = $existing;
|
||||
} else {
|
||||
$manual = SplitManual::create([
|
||||
'order_id' => $orderId,
|
||||
'items' => $storageItems,
|
||||
'original_items' => $originalItems,
|
||||
'admin_id' => intval($adminId),
|
||||
'remark' => $remark,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->appendLog($orderId, $action, $beforeItems, $storageItems, $adminId, $adminName, $remark);
|
||||
Db::commit();
|
||||
return $manual;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除手动覆盖
|
||||
*/
|
||||
public function clear($orderId, $adminId = 0, $adminName = '', $remark = '')
|
||||
{
|
||||
$orderId = intval($orderId);
|
||||
$this->assertEditableSplitOrder($orderId);
|
||||
|
||||
$existing = SplitManual::where('order_id', $orderId)->find();
|
||||
if (!$existing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$beforeItems = $existing['items'] ?? [];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$existing->delete();
|
||||
$this->appendLog($orderId, 'clear', $beforeItems, [], $adminId, $adminName, $remark);
|
||||
Db::commit();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存并重试前标记 pending 日志
|
||||
* @return int log id
|
||||
*/
|
||||
public function markPendingRetryLog($orderId, $adminId = 0, $adminName = '', $remark = '')
|
||||
{
|
||||
$orderId = intval($orderId);
|
||||
$manual = SplitManual::where('order_id', $orderId)->find();
|
||||
if (!$manual) {
|
||||
throw new \RuntimeException('请先保存手动指定接收方');
|
||||
}
|
||||
|
||||
$order = Order::get($orderId);
|
||||
$orderSn = $order ? (string) $order['order_sn'] : '';
|
||||
|
||||
$log = SplitManualLog::create([
|
||||
'order_id' => $orderId,
|
||||
'order_sn' => $orderSn,
|
||||
'action' => 'retry',
|
||||
'before_items' => $manual['items'] ?? [],
|
||||
'after_items' => $manual['items'] ?? [],
|
||||
'admin_id' => intval($adminId),
|
||||
'admin_name' => $adminName,
|
||||
'remark' => $remark,
|
||||
'retry_result' => 'pending',
|
||||
'createtime' => time(),
|
||||
]);
|
||||
|
||||
return intval($log->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试完成后更新日志
|
||||
*/
|
||||
public function finishPendingRetryLog($orderId, $success, $message = '', $splitOrderId = null)
|
||||
{
|
||||
$orderId = intval($orderId);
|
||||
$log = SplitManualLog::where('order_id', $orderId)
|
||||
->where('action', 'retry')
|
||||
->where('retry_result', 'pending')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$log) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log->retry_result = $success ? 'success' : 'fail';
|
||||
$log->retry_message = $message;
|
||||
if ($splitOrderId) {
|
||||
$log->retry_split_order_id = intval($splitOrderId);
|
||||
} else {
|
||||
$latest = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->order('id', 'desc')->find();
|
||||
if ($latest) {
|
||||
$log->retry_split_order_id = intval($latest['id']);
|
||||
}
|
||||
}
|
||||
$log->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更历史
|
||||
*/
|
||||
public function getHistory($orderId, $offset = 0, $limit = 20)
|
||||
{
|
||||
$orderId = intval($orderId);
|
||||
$query = SplitManualLog::where('order_id', $orderId);
|
||||
$total = $query->count();
|
||||
$rows = SplitManualLog::where('order_id', $orderId)
|
||||
->order('id', 'desc')
|
||||
->limit($offset, $limit)
|
||||
->select();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = is_array($row) ? $row : $row->toArray();
|
||||
$data['action_text'] = $this->actionLabel($data['action'] ?? '');
|
||||
$data['retry_result_text'] = $this->retryResultLabel($data['retry_result'] ?? '');
|
||||
$data['createtime_text'] = !empty($data['createtime'])
|
||||
? date('Y-m-d H:i:s', $data['createtime'])
|
||||
: '-';
|
||||
$list[] = $data;
|
||||
}
|
||||
|
||||
return ['total' => $total, 'rows' => $list];
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇付接收方下拉选项
|
||||
*/
|
||||
public function listHuifuReceiverOptions($keyword = '')
|
||||
{
|
||||
$query = Receiver::where('status', 'normal')
|
||||
->where(function ($q) {
|
||||
$q->where('channel', 'huifu')->whereOr('channel', null)->whereOr('channel', '');
|
||||
})
|
||||
->where('receiver_account', '<>', '');
|
||||
|
||||
$keyword = trim((string) $keyword);
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->where('receiver_name', 'like', $like)
|
||||
->whereOr('receiver_account', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $query->order('id', 'desc')->limit(200)->select();
|
||||
$options = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = is_array($row) ? $row : $row->toArray();
|
||||
$account = trim((string) ($data['receiver_account'] ?? ''));
|
||||
if ($account === '') {
|
||||
continue;
|
||||
}
|
||||
$options[] = [
|
||||
'id' => intval($data['id']),
|
||||
'role_type' => $data['role_type'] ?? '',
|
||||
'role_id' => intval($data['role_id'] ?? 0),
|
||||
'receiver_account' => $account,
|
||||
'receiver_name' => trim((string) ($data['receiver_name'] ?? '')),
|
||||
'receiver_type' => $data['receiver_type'] ?? 'HUIFU_ID',
|
||||
'label' => $this->formatReceiverOptionLabel($data),
|
||||
];
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定失败单的编辑上下文
|
||||
*/
|
||||
public function getManualContext($splitOrderId)
|
||||
{
|
||||
$splitOrder = SplitOrder::get(intval($splitOrderId));
|
||||
if (!$splitOrder) {
|
||||
throw new \RuntimeException('分账记录不存在');
|
||||
}
|
||||
$this->assertEditableSplitRow($splitOrder);
|
||||
|
||||
$orderId = intval($splitOrder['order_id']);
|
||||
$order = Order::get($orderId);
|
||||
$manual = SplitManual::where('order_id', $orderId)->find();
|
||||
|
||||
$calculator = new SplitCalculator();
|
||||
$autoItems = $calculator->calculateAuto($orderId);
|
||||
$autoItems = $this->enrichItemsForStorage($autoItems);
|
||||
|
||||
$currentItems = $manual ? ($manual['items'] ?? []) : $autoItems;
|
||||
$originalItems = $manual ? ($manual['original_items'] ?? []) : $autoItems;
|
||||
|
||||
$confirmBase = null;
|
||||
if (class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService') && $order) {
|
||||
try {
|
||||
$svc = new \addons\shopro_huifu\library\HuifuSplitService();
|
||||
$confirmBase = $svc->previewConfirmPayFee($order['order_sn'], $order['pay_fee']);
|
||||
} catch (\Throwable $e) {
|
||||
$confirmBase = floatval($order['pay_fee'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'split_order_id' => intval($splitOrder['id']),
|
||||
'order_id' => $orderId,
|
||||
'order_sn' => $splitOrder['order_sn'] ?? ($order['order_sn'] ?? ''),
|
||||
'pay_fee' => floatval($order['pay_fee'] ?? 0),
|
||||
'confirm_base' => floatval($confirmBase ?? ($order['pay_fee'] ?? 0)),
|
||||
'max_rate' => $this->config->getMaxRate(),
|
||||
'max_split_amount'=> round(floatval($order['pay_fee'] ?? 0) * $this->config->getMaxRate() / 100, 2),
|
||||
'has_manual' => (bool) $manual,
|
||||
'current_items' => $currentItems,
|
||||
'original_items' => $originalItems,
|
||||
'auto_items' => $autoItems,
|
||||
'receiver_options'=> $this->listHuifuReceiverOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function appendLog($orderId, $action, $beforeItems, $afterItems, $adminId, $adminName, $remark)
|
||||
{
|
||||
$order = Order::get($orderId);
|
||||
SplitManualLog::create([
|
||||
'order_id' => $orderId,
|
||||
'order_sn' => $order ? (string) $order['order_sn'] : '',
|
||||
'action' => $action,
|
||||
'before_items' => is_array($beforeItems) ? $beforeItems : [],
|
||||
'after_items' => is_array($afterItems) ? $afterItems : [],
|
||||
'admin_id' => intval($adminId),
|
||||
'admin_name' => $adminName,
|
||||
'remark' => $remark,
|
||||
'createtime' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function assertEditableSplitOrder($orderId)
|
||||
{
|
||||
$row = SplitOrder::where('order_id', intval($orderId))
|
||||
->where('channel', 'huifu')
|
||||
->where('status', 4)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$row) {
|
||||
throw new \RuntimeException('仅失败状态的汇付分账记录可指定接收方');
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertEditableSplitRow($splitOrder)
|
||||
{
|
||||
$data = is_array($splitOrder) ? $splitOrder : $splitOrder->toArray();
|
||||
if (($data['channel'] ?? '') !== 'huifu') {
|
||||
throw new \RuntimeException('仅汇付通道分账记录可指定接收方');
|
||||
}
|
||||
if (intval($data['status'] ?? 0) !== 4) {
|
||||
throw new \RuntimeException('仅失败状态的分账记录可指定接收方');
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateAndNormalizeItems($orderId, array $items)
|
||||
{
|
||||
if (empty($items)) {
|
||||
throw new \RuntimeException('请至少添加一行分账明细');
|
||||
}
|
||||
|
||||
$order = Order::get($orderId);
|
||||
if (!$order) {
|
||||
throw new \RuntimeException('订单不存在');
|
||||
}
|
||||
|
||||
$payFee = floatval($order['pay_fee']);
|
||||
$maxSplit = round($payFee * $this->config->getMaxRate() / 100, 2);
|
||||
$total = 0.0;
|
||||
$normalized = [];
|
||||
$paymentHuifuId = $this->getPaymentHuifuId();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$receiverId = intval($item['receiver_id'] ?? 0);
|
||||
$amount = round(floatval($item['amount'] ?? 0), 2);
|
||||
if ($receiverId <= 0) {
|
||||
throw new \RuntimeException('请选择有效的分账接收方');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw new \RuntimeException('分账金额须大于 0');
|
||||
}
|
||||
|
||||
$receiver = Receiver::where('id', $receiverId)->where('status', 'normal')->find();
|
||||
if (!$receiver) {
|
||||
throw new \RuntimeException('接收方不存在或已禁用');
|
||||
}
|
||||
$account = trim((string) ($receiver['receiver_account'] ?? ''));
|
||||
if ($account === '') {
|
||||
throw new \RuntimeException('接收方未配置 huifu_id');
|
||||
}
|
||||
if ($paymentHuifuId !== '' && $account === $paymentHuifuId) {
|
||||
throw new \RuntimeException('不得仅分给收款主商户(' . $paymentHuifuId . ')');
|
||||
}
|
||||
|
||||
$total += $amount;
|
||||
$normalized[] = [
|
||||
'receiver_id' => $receiverId,
|
||||
'role_type' => $item['role_type'] ?? ($receiver['role_type'] ?? 'promoter'),
|
||||
'role_id' => intval($receiver['role_id'] ?? 0),
|
||||
'receiver_account' => $account,
|
||||
'receiver_type' => $receiver['receiver_type'] ?? 'HUIFU_ID',
|
||||
'receiver_name' => trim((string) ($receiver['receiver_name'] ?? '')),
|
||||
'amount' => $amount,
|
||||
'description' => trim((string) ($item['description'] ?? '手动指定')),
|
||||
];
|
||||
}
|
||||
|
||||
if ($total > $maxSplit + 0.001) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'分账合计 ¥%s 超过上限 ¥%s(订单金额 × %s%%)',
|
||||
number_format($total, 2, '.', ''),
|
||||
number_format($maxSplit, 2, '.', ''),
|
||||
$this->config->getMaxRate()
|
||||
));
|
||||
}
|
||||
|
||||
$confirmBase = $payFee;
|
||||
if (class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService')) {
|
||||
try {
|
||||
$svc = new \addons\shopro_huifu\library\HuifuSplitService();
|
||||
$confirmBase = floatval($svc->previewConfirmPayFee($order['order_sn'], $payFee));
|
||||
} catch (\Throwable $e) {
|
||||
// use pay_fee
|
||||
}
|
||||
}
|
||||
if ($total > $confirmBase + 0.001) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'分账合计 ¥%s 超过汇付确认基数 ¥%s',
|
||||
number_format($total, 2, '.', ''),
|
||||
number_format($confirmBase, 2, '.', '')
|
||||
));
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
protected function normalizeCalculateItems(array $items)
|
||||
{
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$amount = round(floatval($item['amount'] ?? 0), 2);
|
||||
if ($amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
$account = trim((string) ($item['receiver_account'] ?? ''));
|
||||
if ($account === '') {
|
||||
$receiverId = intval($item['receiver_id'] ?? 0);
|
||||
if ($receiverId > 0) {
|
||||
$receiver = Receiver::get($receiverId);
|
||||
if ($receiver) {
|
||||
$account = trim((string) ($receiver['receiver_account'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($account === '') {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'role_type' => $item['role_type'] ?? 'promoter',
|
||||
'role_id' => intval($item['role_id'] ?? 0),
|
||||
'receiver_id' => intval($item['receiver_id'] ?? 0),
|
||||
'receiver_account' => $account,
|
||||
'receiver_type' => $item['receiver_type'] ?? 'HUIFU_ID',
|
||||
'receiver_name' => trim((string) ($item['receiver_name'] ?? '')),
|
||||
'amount' => $amount,
|
||||
'description' => trim((string) ($item['description'] ?? '手动指定')),
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function enrichItemsForStorage(array $items)
|
||||
{
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$receiverId = intval($item['receiver_id'] ?? 0);
|
||||
$account = trim((string) ($item['receiver_account'] ?? ''));
|
||||
$name = trim((string) ($item['receiver_name'] ?? ''));
|
||||
$roleType = $item['role_type'] ?? 'promoter';
|
||||
$roleId = intval($item['role_id'] ?? 0);
|
||||
|
||||
if ($receiverId > 0) {
|
||||
$receiver = Receiver::get($receiverId);
|
||||
if ($receiver) {
|
||||
if ($account === '') {
|
||||
$account = trim((string) ($receiver['receiver_account'] ?? ''));
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = trim((string) ($receiver['receiver_name'] ?? ''));
|
||||
}
|
||||
if (!$roleType) {
|
||||
$roleType = $receiver['role_type'] ?? 'promoter';
|
||||
}
|
||||
if (!$roleId) {
|
||||
$roleId = intval($receiver['role_id'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'receiver_id' => $receiverId,
|
||||
'role_type' => $roleType,
|
||||
'role_id' => $roleId,
|
||||
'receiver_account' => $account,
|
||||
'receiver_type' => $item['receiver_type'] ?? 'HUIFU_ID',
|
||||
'receiver_name' => $name,
|
||||
'amount' => round(floatval($item['amount'] ?? 0), 2),
|
||||
'description' => trim((string) ($item['description'] ?? '')),
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function getPaymentHuifuId()
|
||||
{
|
||||
if (!class_exists('\\addons\\shopro_huifu\\library\\HuifuConfig')) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return trim((string) \addons\shopro_huifu\library\HuifuConfig::getInstance()->get('huifu_id'));
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatReceiverOptionLabel(array $data)
|
||||
{
|
||||
$name = trim((string) ($data['receiver_name'] ?? ''));
|
||||
$account = trim((string) ($data['receiver_account'] ?? ''));
|
||||
$roleType = $data['role_type'] ?? '';
|
||||
$roleMap = [
|
||||
'platform' => '平台',
|
||||
'promoter' => '推广员',
|
||||
'regional_agent' => '区域公司',
|
||||
];
|
||||
$roleLabel = $roleMap[$roleType] ?? $roleType;
|
||||
$parts = array_filter([$roleLabel, $name ?: ('#' . ($data['role_id'] ?? 0)), $account]);
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
protected function actionLabel($action)
|
||||
{
|
||||
$map = [
|
||||
'create' => '首次指定',
|
||||
'update' => '修改',
|
||||
'clear' => '清除',
|
||||
'retry' => '保存并重试',
|
||||
];
|
||||
return $map[$action] ?? $action;
|
||||
}
|
||||
|
||||
protected function retryResultLabel($result)
|
||||
{
|
||||
if ($result === null || $result === '') {
|
||||
return '';
|
||||
}
|
||||
$map = [
|
||||
'pending' => '重试中',
|
||||
'success' => '成功',
|
||||
'fail' => '失败',
|
||||
];
|
||||
return $map[$result] ?? $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace addons\shopro\model\split;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class SplitManual extends Model
|
||||
{
|
||||
protected $name = 'shopro_split_manual';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'createtime';
|
||||
protected $updateTime = 'updatetime';
|
||||
|
||||
public function getItemsAttr($value)
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public function setItemsAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function getOriginalItemsAttr($value)
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public function setOriginalItemsAttr($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace addons\shopro\model\split;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class SplitManualLog extends Model
|
||||
{
|
||||
protected $name = 'shopro_split_manual_log';
|
||||
protected $autoWriteTimestamp = false;
|
||||
protected $createTime = 'createtime';
|
||||
protected $updateTime = false;
|
||||
|
||||
public function getBeforeItemsAttr($value)
|
||||
{
|
||||
return $this->decodeItems($value);
|
||||
}
|
||||
|
||||
public function setBeforeItemsAttr($value)
|
||||
{
|
||||
return $this->encodeItems($value);
|
||||
}
|
||||
|
||||
public function getAfterItemsAttr($value)
|
||||
{
|
||||
return $this->decodeItems($value);
|
||||
}
|
||||
|
||||
public function setAfterItemsAttr($value)
|
||||
{
|
||||
return $this->encodeItems($value);
|
||||
}
|
||||
|
||||
protected function decodeItems($value)
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
protected function encodeItems($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
-- 汇付运营媒介审核:商品详情补充文字说明(保留原有图文,仅在 content 前追加)
|
||||
-- 执行前请备份 fa_shopro_goods、fa_shopro_richtext、fa_shopro_config
|
||||
-- 执行后:清除 Shopro 缓存 + 小程序重新发布
|
||||
|
||||
SET @now = UNIX_TIMESTAMP();
|
||||
|
||||
-- ========== 1. 退换货政策富文本(设置页可查看) ==========
|
||||
INSERT INTO `fa_shopro_richtext` (`title`, `content`, `createtime`, `updatetime`, `deletetime`)
|
||||
SELECT
|
||||
'退换货政策',
|
||||
'<p><strong>适用范围</strong></p><p>本政策适用于「雄戈小酒」微信小程序商城购买的实体商品(酒类)。</p><p><strong>签收与验货</strong></p><p>请您在签收时当面验货。如外包装严重破损、漏液或数量不符,请当场拍照/录像并拒收或立即联系客服,我们将协助处理。</p><p><strong>退换货条件</strong></p><p>1. 未开封、不影响二次销售,且在签收后 7 日内可申请退货(具体以订单状态及商品属性为准);</p><p>2. 因运输造成的破损、漏液,请保留完整外包装及物流面单,联系客服办理补发或退款;</p><p>3. 定制类、已开封或已饮用的酒类商品,除质量问题外不支持无理由退换。</p><p><strong>售后申请路径(小程序)</strong></p><p>① 底部「我的」→ 订单卡片「售后」→ 售后列表;</p><p>② 「我的」→「我的订单」→ 进入订单详情 →「申请售后」;</p><p>③ 「我的」→「设置」→「退换货政策」(本页)。</p><p><strong>处理时效</strong></p><p>客服会在 1 个工作日内响应;审核通过后,退款按原支付渠道退回,到账时间以支付机构为准。</p><p><strong>客服</strong></p><p>商品页「客服」按钮或「我的」联系在线客服,服务时间 9:00–21:00。</p>',
|
||||
@now,
|
||||
@now,
|
||||
NULL
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `fa_shopro_richtext`
|
||||
WHERE `title` = '退换货政策' AND `deletetime` IS NULL
|
||||
);
|
||||
|
||||
SET @return_policy_id = (
|
||||
SELECT `id` FROM `fa_shopro_richtext`
|
||||
WHERE `title` = '退换货政策' AND `deletetime` IS NULL
|
||||
ORDER BY `id` DESC LIMIT 1
|
||||
);
|
||||
|
||||
UPDATE `fa_shopro_config`
|
||||
SET `value` = JSON_SET(CAST(`value` AS JSON), '$.return_policy', CAST(@return_policy_id AS UNSIGNED))
|
||||
WHERE `name` = 'shopro' AND `group` = 'basic' AND JSON_VALID(`value`) = 1 AND @return_policy_id IS NOT NULL;
|
||||
|
||||
-- ========== 2. 详情顶部公共说明(不含重复标记) ==========
|
||||
SET @intro_tail = '<p><strong>发货说明</strong></p><p>下单后 48 小时内安排发货(节假日顺延),默认快递配送。签收前请验货,如有破损请保留外包装并联系客服。</p><p><strong>退换货与售后查看路径</strong></p><p>① 小程序底部「我的」→ 订单区「售后」;② 「我的」→「我的订单」→ 订单详情 →「申请售后」;③ 「我的」→「设置」→「退换货政策」查看全文。</p><p><strong>客服咨询</strong></p><p>商品页点击「客服」,或在「我的」联系在线客服(9:00–21:00)。</p><p><strong>温馨提示</strong></p><p>饮酒有害健康,请勿酒后驾驶;未成年人请勿购买及饮酒。</p><p>——以下为商品原有图文详情——</p>';
|
||||
|
||||
-- ========== 3. 196 雄戈小酒 礼盒装 ==========
|
||||
UPDATE `fa_shopro_goods`
|
||||
SET `content` = CONCAT(
|
||||
'<!-- huifu-goods-intro -->',
|
||||
'<p><strong>商品介绍</strong></p><p>「雄戈小酒」礼盒装,贵州雄戈酒业有限公司出品,适合商务馈赠、亲友聚会与节日送礼。礼盒内含多瓶小规格酱香型白酒,包装精致,便于携带与陈列。</p>',
|
||||
'<p><strong>产品规格</strong></p><p>净含量:120ml×5;酒精度:49%vol;香型:酱香型白酒;产地:贵州省。</p>',
|
||||
'<p><strong>饮用建议</strong></p><p>开瓶后请尽快饮用;常温避光保存。建议小杯慢饮,搭配合适餐食,切勿过量饮酒。</p>',
|
||||
@intro_tail,
|
||||
IFNULL(`content`, '')
|
||||
),
|
||||
`updatetime` = @now
|
||||
WHERE `id` = 196
|
||||
AND `deletetime` IS NULL
|
||||
AND (`content` IS NULL OR `content` NOT LIKE '%huifu-goods-intro%');
|
||||
|
||||
-- ========== 4. 199 雄戈小酒 商务宴请装 ==========
|
||||
UPDATE `fa_shopro_goods`
|
||||
SET `content` = CONCAT(
|
||||
'<!-- huifu-goods-intro -->',
|
||||
'<p><strong>商品介绍</strong></p><p>「雄戈小酒」商务宴请装,面向聚会、宴请、团队答谢等场景。手提礼袋包装,单份数量充足,方便席面分饮与携带。</p>',
|
||||
'<p><strong>产品规格</strong></p><p>净含量:120ml×10;酒精度:49%vol;香型:酱香型白酒;产地:贵州省。</p>',
|
||||
'<p><strong>饮用建议</strong></p><p>开瓶后请尽快饮用;常温避光保存。理性饮酒,请勿酒后驾车。</p>',
|
||||
@intro_tail,
|
||||
IFNULL(`content`, '')
|
||||
),
|
||||
`updatetime` = @now
|
||||
WHERE `id` = 199
|
||||
AND `deletetime` IS NULL
|
||||
AND (`content` IS NULL OR `content` NOT LIKE '%huifu-goods-intro%');
|
||||
|
||||
-- ========== 5. 201 销售礼盒装(原 content 为空,仅追加文字,不插入新图片) ==========
|
||||
UPDATE `fa_shopro_goods`
|
||||
SET `content` = CONCAT(
|
||||
'<!-- huifu-goods-intro -->',
|
||||
'<p><strong>商品介绍</strong></p><p>「雄戈小酒」销售礼盒装,以自然之味、养自然之身为理念,选用优质酱香型白酒小瓶组合,适合渠道团购、企业福利与高端礼赠。详情页下方轮播图可查看礼盒外观与实物展示。</p>',
|
||||
'<p><strong>产品说明</strong></p><p>具体净含量、酒精度及礼盒配置以页面标题、规格及客服说明为准;下单前如有疑问请联系在线客服确认。</p>',
|
||||
'<p><strong>适用场景</strong></p><p>商务拜访、节日送礼、客户答谢、品鉴分享等。</p>',
|
||||
@intro_tail,
|
||||
IFNULL(`content`, '')
|
||||
),
|
||||
`updatetime` = @now
|
||||
WHERE `id` = 201
|
||||
AND `deletetime` IS NULL
|
||||
AND (`content` IS NULL OR `content` NOT LIKE '%huifu-goods-intro%');
|
||||
|
||||
-- 可选:197/198 若仍展示可取消注释
|
||||
-- UPDATE `fa_shopro_goods` SET `content` = CONCAT('<!-- huifu-goods-intro -->', ... , @intro_tail, IFNULL(`content`, '')) WHERE `id` IN (197, 198) AND ...
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 失败分账手动指定接收方
|
||||
-- 执行前请将 fa_ 替换为实际表前缀
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `fa_shopro_split_manual` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`order_id` int(11) NOT NULL COMMENT '订单ID',
|
||||
`items` text NOT NULL COMMENT '当前手动明细JSON',
|
||||
`original_items` text COMMENT '首次改人时自动计算快照JSON',
|
||||
`admin_id` int(11) NOT NULL DEFAULT 0 COMMENT '最后操作管理员',
|
||||
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
|
||||
`createtime` int(10) NOT NULL,
|
||||
`updatetime` int(10) NOT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `order_id` (`order_id`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分账手动覆盖(当前值)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `fa_shopro_split_manual_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`order_id` int(11) NOT NULL COMMENT '订单ID',
|
||||
`order_sn` varchar(50) NOT NULL DEFAULT '' COMMENT '订单号',
|
||||
`action` enum('create','update','clear','retry') NOT NULL DEFAULT 'update' COMMENT '操作类型',
|
||||
`before_items` text COMMENT '变更前明细JSON',
|
||||
`after_items` text COMMENT '变更后明细JSON',
|
||||
`admin_id` int(11) NOT NULL DEFAULT 0,
|
||||
`admin_name` varchar(64) DEFAULT NULL COMMENT '操作人姓名',
|
||||
`remark` varchar(255) DEFAULT NULL,
|
||||
`retry_split_order_id` int(11) DEFAULT NULL COMMENT '关联分账单ID',
|
||||
`retry_result` enum('pending','success','fail') DEFAULT NULL COMMENT '重试结果',
|
||||
`retry_message` text COMMENT '重试失败原因',
|
||||
`createtime` int(10) NOT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `order_id` (`order_id`) USING BTREE,
|
||||
KEY `createtime` (`createtime`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分账手动覆盖变更历史';
|
||||
|
||||
ALTER TABLE `fa_shopro_split_order`
|
||||
ADD COLUMN `split_source` varchar(16) NOT NULL DEFAULT 'auto' COMMENT 'auto=自动计算,manual=手动指定' AFTER `status`;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 分账记录:手动指定接收方权限节点
|
||||
SET @now = UNIX_TIMESTAMP();
|
||||
SET @ord = (SELECT id FROM `fa_auth_rule` WHERE `name` = 'shopro/split/order' LIMIT 1);
|
||||
|
||||
INSERT IGNORE INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`url`,`condition`,`remark`,`ismenu`,`extend`,`createtime`,`updatetime`,`weigh`,`status`) VALUES
|
||||
('file', @ord, 'shopro/split/order/manualReceivers', '指定接收方-加载', 'fa fa-circle-o', '', '', '', 0, '', @now, @now, 0, 'normal'),
|
||||
('file', @ord, 'shopro/split/order/saveManual', '指定接收方-保存', 'fa fa-circle-o', '', '', '', 0, '', @now, @now, 0, 'normal'),
|
||||
('file', @ord, 'shopro/split/order/saveManualAndRetry', '指定接收方-保存并重试', 'fa fa-circle-o', '', '', '', 0, '', @now, @now, 0, 'normal'),
|
||||
('file', @ord, 'shopro/split/order/clearManual', '指定接收方-清除', 'fa fa-circle-o', '', '', '', 0, '', @now, @now, 0, 'normal'),
|
||||
('file', @ord, 'shopro/split/order/manualHistory', '指定接收方-变更历史', 'fa fa-circle-o', '', '', '', 0, '', @now, @now, 0, 'normal');
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace addons\shopro_huifu\library;
|
||||
|
||||
use addons\shopro\library\split\SplitCalculator;
|
||||
use addons\shopro\library\split\SplitManualService;
|
||||
use addons\shopro\library\split\SplitOrderHelper;
|
||||
use addons\shopro\library\split\Config as SplitConfig;
|
||||
use addons\shopro\model\Order;
|
||||
@@ -34,6 +35,9 @@ class HuifuSplitService
|
||||
}
|
||||
SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->where('status', 'in', [1, 4])->delete();
|
||||
|
||||
$manualService = new SplitManualService();
|
||||
$isManual = $manualService->hasOverride($orderId);
|
||||
|
||||
$calculator = new SplitCalculator();
|
||||
$items = $calculator->calculate($orderId);
|
||||
if (empty($items)) {
|
||||
@@ -44,6 +48,7 @@ class HuifuSplitService
|
||||
}
|
||||
$msg = $calculator->formatEmptySplitFailureMessage($orderId);
|
||||
$this->markFailed($orderId, 'HFPS' . date('YmdHis') . $orderId, $order, [], $msg);
|
||||
$manualService->finishPendingRetryLog($orderId, false, $msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,6 +76,7 @@ class HuifuSplitService
|
||||
$blockReason = $this->getDelayTransBlockReason($order['order_sn'], $orderId);
|
||||
if ($blockReason !== null) {
|
||||
$this->markFailed($orderId, $outOrderNo, $order, $items, $blockReason);
|
||||
$manualService->finishPendingRetryLog($orderId, false, $blockReason);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -85,6 +91,7 @@ class HuifuSplitService
|
||||
'total_amount' => $order['pay_fee'],
|
||||
'split_amount' => $splitAmount,
|
||||
'status' => 1,
|
||||
'split_source' => $isManual ? 'manual' : 'auto',
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
@@ -111,10 +118,12 @@ class HuifuSplitService
|
||||
'confirm_status' => 'S',
|
||||
]);
|
||||
Db::commit();
|
||||
$manualService->finishPendingRetryLog($orderId, true, '', $splitOrder->id);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->markFailed($orderId, $outOrderNo, $order, $items, $e->getMessage());
|
||||
$manualService->finishPendingRetryLog($orderId, false, $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -506,6 +515,7 @@ class HuifuSplitService
|
||||
{
|
||||
$splitOrder = SplitOrder::where('order_id', $orderId)->where('channel', 'huifu')->find();
|
||||
$splitConfig = new SplitConfig();
|
||||
$isManual = (new SplitManualService())->hasOverride($orderId);
|
||||
if (!$splitOrder) {
|
||||
$splitOrder = SplitOrder::create([
|
||||
'order_id' => $orderId,
|
||||
@@ -516,6 +526,7 @@ class HuifuSplitService
|
||||
'total_amount' => $order['pay_fee'],
|
||||
'split_amount' => array_sum(array_column($items, 'amount')),
|
||||
'status' => 4,
|
||||
'split_source' => $isManual ? 'manual' : 'auto',
|
||||
'wx_result' => $message,
|
||||
'retry_count' => 1,
|
||||
'next_retry_time'=> time() + $splitConfig->getRetryInterval(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use app\admin\controller\shopro\Base;
|
||||
use addons\shopro\library\split\Config as SplitConfig;
|
||||
use addons\shopro\library\split\SplitCalculator;
|
||||
use addons\shopro\library\split\SplitExecutor;
|
||||
use addons\shopro\library\split\SplitManualService;
|
||||
use addons\shopro\library\split\ProfitSharingService;
|
||||
use app\admin\model\shopro\order\Order as ShopOrder;
|
||||
use app\admin\model\shopro\user\User as ShopUser;
|
||||
@@ -183,12 +184,18 @@ class Order extends Base
|
||||
$data['fail_reason_full'] = $this->enrichFailReasonWithDiagnose($data['fail_reason_full'], $status, $diag);
|
||||
$data['fail_reason'] = $this->summarizeFailReason($data['fail_reason_full'], $diag, $status);
|
||||
|
||||
$data['split_source'] = $diag['final_source'];
|
||||
$data['split_source_text'] = $this->formatSplitSourceText($diag);
|
||||
$data['calc_source'] = $diag['final_source'];
|
||||
$data['calc_source_text'] = $this->formatSplitSourceText($diag);
|
||||
$data['split_diagnose'] = $diag['issues'];
|
||||
$data['commission_total'] = $diag['commission_total'];
|
||||
$data['rule_total'] = $diag['rule_total'];
|
||||
|
||||
$orderSplitSource = $data['split_source'] ?? 'auto';
|
||||
$data['split_source'] = $orderSplitSource;
|
||||
$data['split_source_text'] = $orderSplitSource === 'manual' ? '手动指定' : $data['calc_source_text'];
|
||||
$data['has_manual_override'] = (new SplitManualService())->hasOverride($orderId);
|
||||
$data['can_edit_manual'] = ($status === 4 && $channel === 'huifu');
|
||||
|
||||
unset($data['order'], $data['receivers']);
|
||||
$rows[] = $data;
|
||||
}
|
||||
@@ -468,6 +475,143 @@ class Order extends Base
|
||||
return $map[$type] ?? ($type ?: '接收方');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定接收方弹窗数据
|
||||
*/
|
||||
public function manualReceivers()
|
||||
{
|
||||
$splitOrderId = intval($this->request->get('split_order_id', 0));
|
||||
if ($splitOrderId <= 0) {
|
||||
$this->error('参数错误');
|
||||
}
|
||||
try {
|
||||
$context = (new SplitManualService())->getManualContext($splitOrderId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
$this->success('操作成功', null, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存手动覆盖(不重试)
|
||||
*/
|
||||
public function saveManual()
|
||||
{
|
||||
$splitOrderId = intval($this->request->post('split_order_id', 0));
|
||||
$items = $this->request->post('items/a', []);
|
||||
$remark = trim((string) $this->request->post('remark', ''));
|
||||
|
||||
$row = $this->model->get($splitOrderId);
|
||||
if (!$row) {
|
||||
$this->error('分账记录不存在');
|
||||
}
|
||||
try {
|
||||
(new SplitManualService())->save(
|
||||
intval($row['order_id']),
|
||||
$items,
|
||||
intval($this->auth->id),
|
||||
(string) ($this->auth->nickname ?? $this->auth->username ?? ''),
|
||||
$remark
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
$this->success('保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存手动覆盖并重新分账
|
||||
*/
|
||||
public function saveManualAndRetry()
|
||||
{
|
||||
$splitOrderId = intval($this->request->post('split_order_id', 0));
|
||||
$items = $this->request->post('items/a', []);
|
||||
$remark = trim((string) $this->request->post('remark', ''));
|
||||
|
||||
$row = $this->model->get($splitOrderId);
|
||||
if (!$row) {
|
||||
$this->error('分账记录不存在');
|
||||
}
|
||||
$orderId = intval($row['order_id']);
|
||||
$adminId = intval($this->auth->id);
|
||||
$adminName = (string) ($this->auth->nickname ?? $this->auth->username ?? '');
|
||||
|
||||
$service = new SplitManualService();
|
||||
try {
|
||||
$service->save($orderId, $items, $adminId, $adminName, $remark);
|
||||
$service->markPendingRetryLog($orderId, $adminId, $adminName, $remark);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
if (class_exists('\\addons\\shopro_huifu\\library\\HuifuSplitService')) {
|
||||
$result = (new \addons\shopro_huifu\library\HuifuSplitService())->executeByOrderId($orderId);
|
||||
} else {
|
||||
$result = (new SplitExecutor())->retry($splitOrderId);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$service->finishPendingRetryLog($orderId, false, $e->getMessage());
|
||||
$this->error('重试失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
$this->success('保存并重试成功');
|
||||
}
|
||||
$hint = $this->formatSplitWxResultHint($orderId, 'huifu');
|
||||
$this->error('保存成功但分账失败' . ($hint ?: ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除手动覆盖
|
||||
*/
|
||||
public function clearManual()
|
||||
{
|
||||
$splitOrderId = intval($this->request->post('split_order_id', 0));
|
||||
$remark = trim((string) $this->request->post('remark', ''));
|
||||
|
||||
$row = $this->model->get($splitOrderId);
|
||||
if (!$row) {
|
||||
$this->error('分账记录不存在');
|
||||
}
|
||||
try {
|
||||
(new SplitManualService())->clear(
|
||||
intval($row['order_id']),
|
||||
intval($this->auth->id),
|
||||
(string) ($this->auth->nickname ?? $this->auth->username ?? ''),
|
||||
$remark
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
$this->success('已清除手动指定');
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更历史
|
||||
*/
|
||||
public function manualHistory()
|
||||
{
|
||||
$orderId = intval($this->request->get('order_id', 0));
|
||||
if ($orderId <= 0) {
|
||||
$splitOrderId = intval($this->request->get('split_order_id', 0));
|
||||
if ($splitOrderId > 0) {
|
||||
$row = $this->model->get($splitOrderId);
|
||||
$orderId = $row ? intval($row['order_id']) : 0;
|
||||
}
|
||||
}
|
||||
if ($orderId <= 0) {
|
||||
$this->error('参数错误');
|
||||
}
|
||||
|
||||
$offset = intval($this->request->get('offset', 0));
|
||||
$limit = intval($this->request->get('limit', 20));
|
||||
$limit = max(1, min(100, $limit));
|
||||
|
||||
$result = (new SplitManualService())->getHistory($orderId, $offset, $limit);
|
||||
$this->success('操作成功', null, $result);
|
||||
}
|
||||
|
||||
public function retry($ids = null)
|
||||
{
|
||||
$id = $ids ?: $this->request->post('id');
|
||||
|
||||
@@ -108,6 +108,18 @@
|
||||
}
|
||||
.source-tag.commission { background: #ecf5ff; color: #409eff; }
|
||||
.source-tag.rule { background: #fdf6ec; color: #e6a23c; }
|
||||
.source-tag.manual { background: #f0ebfa; color: #7438D5; }
|
||||
.manual-badge {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
border-radius: 3px;
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.source-detail-btn {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
@@ -187,6 +199,57 @@
|
||||
.diag-pop li { margin-bottom: 6px; }
|
||||
.diag-pop li:last-child { margin-bottom: 0; }
|
||||
.op-hint { font-size: 12px; color: #909399; margin-top: 10px; }
|
||||
.manual-dialog .section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.manual-dialog .section-block {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.manual-dialog .orig-item {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.7;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed #ebeef5;
|
||||
}
|
||||
.manual-dialog .orig-item:last-child { border-bottom: none; }
|
||||
.manual-dialog .sum-row {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
color: #303133;
|
||||
}
|
||||
.manual-dialog .sum-row strong { color: #e1212b; }
|
||||
.history-drawer .history-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.history-drawer .history-item:last-child { border-bottom: none; }
|
||||
.history-drawer .history-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.history-drawer .history-action {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
}
|
||||
.history-drawer .history-time { font-size: 12px; color: #909399; }
|
||||
.history-drawer .history-meta { font-size: 12px; color: #606266; margin-bottom: 6px; }
|
||||
.history-drawer .items-diff { font-size: 12px; line-height: 1.65; color: #606266; }
|
||||
.history-drawer .items-diff .arrow { color: #909399; margin: 0 4px; }
|
||||
.history-drawer .retry-result { margin-top: 6px; font-size: 12px; }
|
||||
.history-drawer .retry-result.success { color: #67c23a; }
|
||||
.history-drawer .retry-result.fail { color: #f56c6c; }
|
||||
.history-drawer .retry-result.pending { color: #409eff; }
|
||||
</style>
|
||||
<div id="splitOrderIndex" v-cloak>
|
||||
<div class="page-card">
|
||||
@@ -249,14 +312,15 @@
|
||||
<span class="money-num highlight">¥{{ formatMoney(s.row.split_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分账来源" class-name="col-source" width="118" align="center">
|
||||
<el-table-column label="分账来源" class-name="col-source" width="130" align="center">
|
||||
<template slot-scope="s">
|
||||
<div class="source-cell" v-if="s.row.split_source">
|
||||
<span :class="'source-tag ' + (s.row.split_source === 'commission' ? 'commission' : 'rule')">
|
||||
{{ s.row.split_source === 'commission' ? '按佣金' : '规则兜底' }}
|
||||
<div class="source-cell" v-if="s.row.split_source === 'manual' || s.row.has_manual_override || s.row.calc_source">
|
||||
<span class="source-tag manual" v-if="s.row.split_source === 'manual' || s.row.has_manual_override">手动</span>
|
||||
<span v-else-if="s.row.calc_source" :class="'source-tag ' + (s.row.calc_source === 'commission' ? 'commission' : 'rule')">
|
||||
{{ s.row.calc_source === 'commission' ? '按佣金' : '规则兜底' }}
|
||||
</span>
|
||||
<el-popover
|
||||
v-if="s.row.split_source === 'rule' && s.row.split_diagnose && s.row.split_diagnose.length"
|
||||
v-if="s.row.calc_source === 'rule' && s.row.split_diagnose && s.row.split_diagnose.length && s.row.split_source !== 'manual'"
|
||||
placement="left"
|
||||
width="300"
|
||||
trigger="click">
|
||||
@@ -310,8 +374,10 @@
|
||||
<span v-else style="color:#909399;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="128" fixed="right">
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template slot-scope="s">
|
||||
<el-button type="text" size="small" v-if="s.row.can_edit_manual" @click="openManualDialog(s.row)">指定接收方</el-button>
|
||||
<el-button type="text" size="small" v-if="s.row.can_edit_manual && (s.row.has_manual_override || s.row.split_source === 'manual')" @click="openHistoryDrawer(s.row)">变更历史</el-button>
|
||||
<el-button type="text" size="small" v-if="s.row.status == 4" @click="retry(s.row)">重试</el-button>
|
||||
<el-button type="text" size="small" @click="query(s.row.id)">同步</el-button>
|
||||
</template>
|
||||
@@ -329,8 +395,98 @@
|
||||
@size-change="onSizeChange">
|
||||
</el-pagination>
|
||||
</div>
|
||||
<p class="op-hint">「重试」仅对失败记录向通道重新发起;「同步」只更新状态,不会重复扣款。</p>
|
||||
<p class="op-hint">失败汇付单可「指定接收方」改人改金额后重试;「同步」只更新状态,不会重复扣款。</p>
|
||||
</div>
|
||||
|
||||
<!-- 指定接收方弹窗 -->
|
||||
<el-dialog title="指定接收方" :visible.sync="manualDialogVisible" width="720px" :close-on-click-modal="false" custom-class="manual-dialog">
|
||||
<div v-loading="manualLoading">
|
||||
<p style="font-size:13px;color:#606266;margin:0 0 12px;">
|
||||
订单 <strong>{{ manualContext.order_sn }}</strong>
|
||||
· 确认基数 ¥{{ formatMoney(manualContext.confirm_base) }}
|
||||
· 上限 ¥{{ formatMoney(manualContext.max_split_amount) }}
|
||||
</p>
|
||||
<div class="section-block" v-if="manualContext.original_items && manualContext.original_items.length">
|
||||
<p class="section-title">原自动计算明细</p>
|
||||
<div class="orig-item" v-for="(it, idx) in manualContext.original_items" :key="'orig'+idx">
|
||||
{{ formatManualItemLine(it) }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="section-title">手动指定明细</p>
|
||||
<el-table :data="manualForm.items" border size="small" style="width:100%;margin-bottom:10px;">
|
||||
<el-table-column label="接收方" min-width="220">
|
||||
<template slot-scope="s">
|
||||
<el-select v-model="s.row.receiver_id" filterable placeholder="选择汇付接收方" size="small" style="width:100%;" @change="onReceiverChange(s.row)">
|
||||
<el-option v-for="opt in manualContext.receiver_options" :key="opt.id" :label="opt.label" :value="opt.id"></el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="100">
|
||||
<template slot-scope="s">
|
||||
<el-select v-model="s.row.role_type" size="small" style="width:100%;">
|
||||
<el-option label="推广员" value="promoter"></el-option>
|
||||
<el-option label="区域公司" value="regional_agent"></el-option>
|
||||
<el-option label="平台" value="platform"></el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="120">
|
||||
<template slot-scope="s">
|
||||
<el-input-number v-model="s.row.amount" :min="0.01" :precision="2" :step="0.01" size="small" controls-position="right" style="width:100%;"></el-input-number>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" width="120">
|
||||
<template slot-scope="s">
|
||||
<el-input v-model="s.row.description" size="small" placeholder="备注"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="56" align="center">
|
||||
<template slot-scope="s">
|
||||
<el-button type="text" size="small" style="color:#f56c6c;" @click="removeManualRow(s.$index)">删</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-button size="small" @click="addManualRow">+ 添加一行</el-button>
|
||||
<div class="sum-row">合计 <strong>¥{{ formatMoney(manualItemsTotal) }}</strong></div>
|
||||
<el-input v-model="manualForm.remark" type="textarea" :rows="2" placeholder="操作备注(可选)" style="margin-top:12px;"></el-input>
|
||||
</div>
|
||||
<span slot="footer">
|
||||
<el-button size="small" @click="manualDialogVisible = false">取消</el-button>
|
||||
<el-button size="small" v-if="manualContext.has_manual" @click="clearManualOverride">清除手动指定</el-button>
|
||||
<el-button size="small" type="primary" plain :loading="manualSaving" @click="saveManual(false)">仅保存</el-button>
|
||||
<el-button size="small" type="primary" :loading="manualSaving" @click="saveManual(true)">保存并重试</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 变更历史抽屉 -->
|
||||
<el-drawer title="变更历史" :visible.sync="historyDrawerVisible" size="480px" custom-class="history-drawer">
|
||||
<div v-loading="historyLoading" style="padding:0 20px 20px;">
|
||||
<div v-if="!historyList.length && !historyLoading" style="color:#909399;font-size:13px;text-align:center;padding:40px 0;">暂无变更记录</div>
|
||||
<div class="history-item" v-for="h in historyList" :key="h.id">
|
||||
<div class="history-head">
|
||||
<span class="history-action">{{ h.action_text }}</span>
|
||||
<span class="history-time">{{ h.createtime_text }}</span>
|
||||
</div>
|
||||
<div class="history-meta" v-if="h.admin_name">操作人:{{ h.admin_name }}</div>
|
||||
<div class="history-meta" v-if="h.remark">备注:{{ h.remark }}</div>
|
||||
<div class="items-diff">
|
||||
<div v-if="h.before_items && h.before_items.length">
|
||||
<div v-for="(it, i) in h.before_items" :key="'b'+i">{{ formatManualItemLine(it) }}</div>
|
||||
</div>
|
||||
<span v-else style="color:#c0c4cc;">(空)</span>
|
||||
<span class="arrow">→</span>
|
||||
<div v-if="h.after_items && h.after_items.length">
|
||||
<div v-for="(it, i) in h.after_items" :key="'a'+i">{{ formatManualItemLine(it) }}</div>
|
||||
</div>
|
||||
<span v-else style="color:#c0c4cc;">(空)</span>
|
||||
</div>
|
||||
<div v-if="h.retry_result" :class="'retry-result ' + h.retry_result">
|
||||
重试结果:{{ h.retry_result_text }}
|
||||
<span v-if="h.retry_message"> — {{ h.retry_message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
<script src="__CDN__/assets/addons/shopro/libs/vue.js"></script>
|
||||
<script src="__CDN__/assets/addons/shopro/libs/element/element.js"></script>
|
||||
|
||||
@@ -18,9 +18,38 @@ define(['jquery', 'bootstrap', 'backend', 'form'], function ($, undefined, Backe
|
||||
status: '',
|
||||
channel: ''
|
||||
},
|
||||
splitHuifuOnly: !!(typeof Config !== 'undefined' && Config.splitHuifuOnly)
|
||||
splitHuifuOnly: !!(typeof Config !== 'undefined' && Config.splitHuifuOnly),
|
||||
manualDialogVisible: false,
|
||||
manualLoading: false,
|
||||
manualSaving: false,
|
||||
manualRow: null,
|
||||
manualContext: {
|
||||
order_sn: '',
|
||||
confirm_base: 0,
|
||||
max_split_amount: 0,
|
||||
has_manual: false,
|
||||
original_items: [],
|
||||
receiver_options: []
|
||||
},
|
||||
manualForm: {
|
||||
items: [],
|
||||
remark: ''
|
||||
},
|
||||
historyDrawerVisible: false,
|
||||
historyLoading: false,
|
||||
historyList: [],
|
||||
historyRow: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
manualItemsTotal() {
|
||||
var sum = 0;
|
||||
(this.manualForm.items || []).forEach(function (it) {
|
||||
sum += parseFloat(it.amount) || 0;
|
||||
});
|
||||
return sum;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getList();
|
||||
},
|
||||
@@ -71,6 +100,18 @@ define(['jquery', 'bootstrap', 'backend', 'form'], function ($, undefined, Backe
|
||||
var n = parseFloat(v);
|
||||
return isNaN(n) ? '0.00' : n.toFixed(2);
|
||||
},
|
||||
roleTypeLabel(type) {
|
||||
var map = { platform: '平台', promoter: '推广员', regional_agent: '区域公司' };
|
||||
return map[type] || type || '接收方';
|
||||
},
|
||||
formatManualItemLine(it) {
|
||||
if (!it) return '—';
|
||||
var name = it.receiver_name || ('#' + (it.role_id || ''));
|
||||
var account = it.receiver_account || '';
|
||||
var amt = this.formatMoney(it.amount);
|
||||
var role = this.roleTypeLabel(it.role_type);
|
||||
return role + ' ' + name + (account ? ' (' + account + ')' : '') + ' ¥' + amt;
|
||||
},
|
||||
scrollCellWheel(e) {
|
||||
var el = e.currentTarget;
|
||||
if (!el || el.scrollWidth <= el.clientWidth) {
|
||||
@@ -85,6 +126,155 @@ define(['jquery', 'bootstrap', 'backend', 'form'], function ($, undefined, Backe
|
||||
if (!orderId) return;
|
||||
Fast.api.open('shopro/order/order/detail?id=' + orderId, '订单详情');
|
||||
},
|
||||
openManualDialog(row) {
|
||||
this.manualRow = row;
|
||||
this.manualDialogVisible = true;
|
||||
this.manualLoading = true;
|
||||
this.manualForm.remark = '';
|
||||
Fast.api.ajax({
|
||||
url: 'shopro/split/order/manualReceivers',
|
||||
type: 'GET',
|
||||
data: { split_order_id: row.id }
|
||||
}, (ret, res) => {
|
||||
var ctx = (res && res.data) ? res.data : {};
|
||||
this.manualContext = ctx;
|
||||
this.manualForm.items = this.cloneManualItems(ctx.current_items || []);
|
||||
if (!this.manualForm.items.length) {
|
||||
this.addManualRow();
|
||||
}
|
||||
this.manualLoading = false;
|
||||
return false;
|
||||
}, () => {
|
||||
this.manualLoading = false;
|
||||
this.manualDialogVisible = false;
|
||||
});
|
||||
},
|
||||
cloneManualItems(items) {
|
||||
return (items || []).map(function (it) {
|
||||
return {
|
||||
receiver_id: it.receiver_id || 0,
|
||||
role_type: it.role_type || 'promoter',
|
||||
amount: parseFloat(it.amount) || 0,
|
||||
description: it.description || '手动指定'
|
||||
};
|
||||
});
|
||||
},
|
||||
addManualRow() {
|
||||
this.manualForm.items.push({
|
||||
receiver_id: 0,
|
||||
role_type: 'promoter',
|
||||
amount: 0,
|
||||
description: '手动指定'
|
||||
});
|
||||
},
|
||||
removeManualRow(index) {
|
||||
this.manualForm.items.splice(index, 1);
|
||||
},
|
||||
onReceiverChange(row) {
|
||||
var opt = (this.manualContext.receiver_options || []).find(function (o) {
|
||||
return o.id === row.receiver_id;
|
||||
});
|
||||
if (opt) {
|
||||
row.role_type = opt.role_type || row.role_type;
|
||||
}
|
||||
},
|
||||
validateManualForm() {
|
||||
var items = this.manualForm.items || [];
|
||||
if (!items.length) {
|
||||
Layer.msg('请至少添加一行分账明细');
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (!items[i].receiver_id) {
|
||||
Layer.msg('第 ' + (i + 1) + ' 行请选择接收方');
|
||||
return false;
|
||||
}
|
||||
if (!items[i].amount || parseFloat(items[i].amount) <= 0) {
|
||||
Layer.msg('第 ' + (i + 1) + ' 行金额须大于 0');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
var max = parseFloat(this.manualContext.max_split_amount) || 0;
|
||||
if (max > 0 && this.manualItemsTotal > max + 0.001) {
|
||||
Layer.msg('合计超过上限 ¥' + this.formatMoney(max));
|
||||
return false;
|
||||
}
|
||||
var base = parseFloat(this.manualContext.confirm_base) || 0;
|
||||
if (base > 0 && this.manualItemsTotal > base + 0.001) {
|
||||
Layer.msg('合计超过确认基数 ¥' + this.formatMoney(base));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
saveManual(andRetry) {
|
||||
if (!this.manualRow || !this.validateManualForm()) {
|
||||
return;
|
||||
}
|
||||
var url = andRetry
|
||||
? 'shopro/split/order/saveManualAndRetry'
|
||||
: 'shopro/split/order/saveManual';
|
||||
var tip = andRetry
|
||||
? '保存后将向汇付重新发起分账,是否继续?'
|
||||
: '仅保存覆盖配置,不会立即重试,是否继续?';
|
||||
var self = this;
|
||||
Layer.confirm(tip, { title: '确认', btn: ['确定', '取消'] }, function () {
|
||||
self.manualSaving = true;
|
||||
Fast.api.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
data: {
|
||||
split_order_id: self.manualRow.id,
|
||||
items: self.manualForm.items,
|
||||
remark: self.manualForm.remark
|
||||
}
|
||||
}, () => {
|
||||
self.manualSaving = false;
|
||||
self.manualDialogVisible = false;
|
||||
self.getList();
|
||||
}, () => {
|
||||
self.manualSaving = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
clearManualOverride() {
|
||||
if (!this.manualRow) return;
|
||||
var self = this;
|
||||
Layer.confirm('清除后将恢复自动计算明细,是否继续?', { title: '清除手动指定', btn: ['清除', '取消'] }, function () {
|
||||
self.manualSaving = true;
|
||||
Fast.api.ajax({
|
||||
url: 'shopro/split/order/clearManual',
|
||||
type: 'POST',
|
||||
data: {
|
||||
split_order_id: self.manualRow.id,
|
||||
remark: self.manualForm.remark
|
||||
}
|
||||
}, () => {
|
||||
self.manualSaving = false;
|
||||
self.manualDialogVisible = false;
|
||||
self.getList();
|
||||
}, () => {
|
||||
self.manualSaving = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
openHistoryDrawer(row) {
|
||||
this.historyRow = row;
|
||||
this.historyDrawerVisible = true;
|
||||
this.historyLoading = true;
|
||||
this.historyList = [];
|
||||
Fast.api.ajax({
|
||||
url: 'shopro/split/order/manualHistory',
|
||||
type: 'GET',
|
||||
data: { order_id: row.order_id, limit: 50 }
|
||||
}, (ret, res) => {
|
||||
var data = (res && res.data) ? res.data : {};
|
||||
this.historyList = data.rows || [];
|
||||
this.historyLoading = false;
|
||||
return false;
|
||||
}, () => {
|
||||
this.historyLoading = false;
|
||||
});
|
||||
},
|
||||
retry(row) {
|
||||
var tip = this.splitHuifuOnly
|
||||
? '将向汇付重新发起该笔分账。若订单资金仍被冻结,请先发货/确认收货或按文档处理 FREEZE。'
|
||||
|
||||
在新工单中引用
屏蔽一个用户