From af08af8d47d2805d458490e2994eecfc2c28fc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E4=B9=90=E5=8A=A0=E5=86=B0?= <11869599+addie66@user.noreply.gitee.com> Date: Thu, 22 Oct 2026 22:38:35 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(split):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=88=86=E8=B4=A6=E6=89=8B=E5=8A=A8=E6=8C=87=E5=AE=9A=E6=8E=A5?= =?UTF-8?q?=E6=94=B6=E6=96=B9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现SplitManualService服务类,支持手动覆盖分账明细 - 新增SplitManual和SplitManualLog数据模型 - 修改SplitCalculator优先使用手动覆盖数据进行分账计算 - 添加分账记录手动指定接收方的数据库表结构 - 集成手动分账到汇付分账流程中,支持失败重试机制 - 添加商品详情内容更新SQL脚本用于汇付审核 ``` --- .../shopro/library/split/SplitCalculator.php | 14 +- .../library/split/SplitManualService.php | 553 ++++++++++++++++++ addons/shopro/model/split/SplitManual.php | 47 ++ addons/shopro/model/split/SplitManualLog.php | 50 ++ .../update_goods_content_huifu_audit.sql | 80 +++ addons/shopro/upgrade_split_manual.sql | 37 ++ addons/shopro/upgrade_split_manual_menu.sql | 10 + .../library/HuifuSplitService.php | 11 + .../admin/controller/shopro/split/Order.php | 148 ++++- .../admin/view/shopro/split/order/index.html | 170 +++++- .../assets/js/backend/shopro/split/order.js | 192 +++++- 11 files changed, 1301 insertions(+), 11 deletions(-) create mode 100644 addons/shopro/library/split/SplitManualService.php create mode 100644 addons/shopro/model/split/SplitManual.php create mode 100644 addons/shopro/model/split/SplitManualLog.php create mode 100644 addons/shopro/update_goods_content_huifu_audit.sql create mode 100644 addons/shopro/upgrade_split_manual.sql create mode 100644 addons/shopro/upgrade_split_manual_menu.sql diff --git a/addons/shopro/library/split/SplitCalculator.php b/addons/shopro/library/split/SplitCalculator.php index 66f818c..e6f668a 100644 --- a/addons/shopro/library/split/SplitCalculator.php +++ b/addons/shopro/library/split/SplitCalculator.php @@ -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'])) { diff --git a/addons/shopro/library/split/SplitManualService.php b/addons/shopro/library/split/SplitManualService.php new file mode 100644 index 0000000..b2d7a29 --- /dev/null +++ b/addons/shopro/library/split/SplitManualService.php @@ -0,0 +1,553 @@ +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; + } +} diff --git a/addons/shopro/model/split/SplitManual.php b/addons/shopro/model/split/SplitManual.php new file mode 100644 index 0000000..4eb85b7 --- /dev/null +++ b/addons/shopro/model/split/SplitManual.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/addons/shopro/update_goods_content_huifu_audit.sql b/addons/shopro/update_goods_content_huifu_audit.sql new file mode 100644 index 0000000..29ed207 --- /dev/null +++ b/addons/shopro/update_goods_content_huifu_audit.sql @@ -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 + '退换货政策', + '
适用范围
本政策适用于「雄戈小酒」微信小程序商城购买的实体商品(酒类)。
签收与验货
请您在签收时当面验货。如外包装严重破损、漏液或数量不符,请当场拍照/录像并拒收或立即联系客服,我们将协助处理。
退换货条件
1. 未开封、不影响二次销售,且在签收后 7 日内可申请退货(具体以订单状态及商品属性为准);
2. 因运输造成的破损、漏液,请保留完整外包装及物流面单,联系客服办理补发或退款;
3. 定制类、已开封或已饮用的酒类商品,除质量问题外不支持无理由退换。
售后申请路径(小程序)
① 底部「我的」→ 订单卡片「售后」→ 售后列表;
② 「我的」→「我的订单」→ 进入订单详情 →「申请售后」;
③ 「我的」→「设置」→「退换货政策」(本页)。
处理时效
客服会在 1 个工作日内响应;审核通过后,退款按原支付渠道退回,到账时间以支付机构为准。
客服
商品页「客服」按钮或「我的」联系在线客服,服务时间 9:00–21:00。
', + @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 = '发货说明
下单后 48 小时内安排发货(节假日顺延),默认快递配送。签收前请验货,如有破损请保留外包装并联系客服。
退换货与售后查看路径
① 小程序底部「我的」→ 订单区「售后」;② 「我的」→「我的订单」→ 订单详情 →「申请售后」;③ 「我的」→「设置」→「退换货政策」查看全文。
客服咨询
商品页点击「客服」,或在「我的」联系在线客服(9:00–21:00)。
温馨提示
饮酒有害健康,请勿酒后驾驶;未成年人请勿购买及饮酒。
——以下为商品原有图文详情——
'; + +-- ========== 3. 196 雄戈小酒 礼盒装 ========== +UPDATE `fa_shopro_goods` +SET `content` = CONCAT( + '', + '商品介绍
「雄戈小酒」礼盒装,贵州雄戈酒业有限公司出品,适合商务馈赠、亲友聚会与节日送礼。礼盒内含多瓶小规格酱香型白酒,包装精致,便于携带与陈列。
', + '产品规格
净含量:120ml×5;酒精度:49%vol;香型:酱香型白酒;产地:贵州省。
', + '饮用建议
开瓶后请尽快饮用;常温避光保存。建议小杯慢饮,搭配合适餐食,切勿过量饮酒。
', + @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( + '', + '商品介绍
「雄戈小酒」商务宴请装,面向聚会、宴请、团队答谢等场景。手提礼袋包装,单份数量充足,方便席面分饮与携带。
', + '产品规格
净含量:120ml×10;酒精度:49%vol;香型:酱香型白酒;产地:贵州省。
', + '饮用建议
开瓶后请尽快饮用;常温避光保存。理性饮酒,请勿酒后驾车。
', + @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( + '', + '商品介绍
「雄戈小酒」销售礼盒装,以自然之味、养自然之身为理念,选用优质酱香型白酒小瓶组合,适合渠道团购、企业福利与高端礼赠。详情页下方轮播图可查看礼盒外观与实物展示。
', + '产品说明
具体净含量、酒精度及礼盒配置以页面标题、规格及客服说明为准;下单前如有疑问请联系在线客服确认。
', + '适用场景
商务拜访、节日送礼、客户答谢、品鉴分享等。
', + @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('', ... , @intro_tail, IFNULL(`content`, '')) WHERE `id` IN (197, 198) AND ... diff --git a/addons/shopro/upgrade_split_manual.sql b/addons/shopro/upgrade_split_manual.sql new file mode 100644 index 0000000..0b5ce50 --- /dev/null +++ b/addons/shopro/upgrade_split_manual.sql @@ -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`; diff --git a/addons/shopro/upgrade_split_manual_menu.sql b/addons/shopro/upgrade_split_manual_menu.sql new file mode 100644 index 0000000..23f289f --- /dev/null +++ b/addons/shopro/upgrade_split_manual_menu.sql @@ -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'); diff --git a/addons/shopro_huifu/library/HuifuSplitService.php b/addons/shopro_huifu/library/HuifuSplitService.php index d2194bc..be91960 100644 --- a/addons/shopro_huifu/library/HuifuSplitService.php +++ b/addons/shopro_huifu/library/HuifuSplitService.php @@ -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(), diff --git a/application/admin/controller/shopro/split/Order.php b/application/admin/controller/shopro/split/Order.php index cb07931..2f841ff 100644 --- a/application/admin/controller/shopro/split/Order.php +++ b/application/admin/controller/shopro/split/Order.php @@ -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'); diff --git a/application/admin/view/shopro/split/order/index.html b/application/admin/view/shopro/split/order/index.html index 72eae3a..2498382 100644 --- a/application/admin/view/shopro/split/order/index.html +++ b/application/admin/view/shopro/split/order/index.html @@ -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; }「重试」仅对失败记录向通道重新发起;「同步」只更新状态,不会重复扣款。
+失败汇付单可「指定接收方」改人改金额后重试;「同步」只更新状态,不会重复扣款。
+ 订单 {{ manualContext.order_sn }} + · 确认基数 ¥{{ formatMoney(manualContext.confirm_base) }} + · 上限 ¥{{ formatMoney(manualContext.max_split_amount) }} +
+原自动计算明细
+手动指定明细
+