Compare commits

...

3 Commits

Author SHA1 Message Date
Council 5314d0caff 新增核销后自动收货的逻辑 2026-06-15 20:50:34 +08:00
Council 3340016969 fix(ticket): 修复旧缓存不含goods字段导致的JS报错
- 缓存命中时补充构建goods字段
- 初始化和构建树数据时同步添加goods字段
2026-06-13 10:57:07 +08:00
Council 39230958a0 fix(ticket): 修复票务勾选时下拉框不刷新的 JS 报错 2026-06-12 13:51:24 +08:00
7 changed files with 319 additions and 5 deletions

View File

@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **vr-shopxo-plugin** (26423 symbols, 57331 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **vr-shopxo-plugin** (26564 symbols, 57490 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **vr-shopxo-plugin** (26423 symbols, 57331 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **vr-shopxo-plugin** (26564 symbols, 57490 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@ -0,0 +1,165 @@
# 核销自动确认收货 — 设计与验证
> 文档类型:功能归档 | 日期2026-06-12 | 关联 ticketTicketService 核销流程
---
## 背景
核销流程中,需要将确认收货步骤自动化:核销成功后自动触发确认收货。此前该功能作为 TODO 存在于代码中,本轮完成实现并验证。
---
## 原始需求
1. 核销后**自动确认收货**
2. 仅**待收货**状态status=3才触发确认收货
3. 如果已经是**确认收货**状态status=4幂等返回成功不报错
4. 如果确认收货失败,**回滚核销操作**(整个操作在事务内)
5. **并发安全**:使用悲观锁防并发
6. 短码核销(`verifyByShortCode`)复用同一逻辑
7. 已确认收货但未核销的情况核销流程正常走通autoConfirmOrder 返回 code=0 兜底)
---
## 技术方案
### 核心流程图
```
verifyTicket / verifyTicketById
├─ 悲观锁查票SELECT ... lock(true)
├─ 核销票(标记 status=1
├─ autoConfirmOrder()
│ ├─ 悲观锁查订单SELECT ... lock(true)
│ ├─ status==4 → return code=0幂等
│ ├─ status!=3 → return code=0跳过
│ └─ status==3 → 调用订单API确认收货
├─ 如 autoConfirmOrder 失败 → throw \Exception → 事务回滚
└─ commit
```
### 关键设计决策
| 决策 | 理由 |
|------|------|
| 不记录核销人与电话号码(`staffId`, `staffMobile` | 设计文档未提及,避免过度实现 |
| `autoConfirmOrder` 不做 `null` 直接返回 | 缺乏需求约束,静默丢弃会产生不可追踪的数据不一致 |
| 使用 `if/elseif` 而非守卫语句 | 符合仓库现有风格 |
| 悲观锁 | 防止并发场景下同一订单被重复确认收货 |
---
## 改动清单
### [MODIFY] `shopxo/app/plugins/vr_ticket/service/TicketService.php`
| 位置 | 改动 |
|------|------|
| `verifyTicket`L314附近 | 核销后调用 `autoConfirmOrder`,失败则抛异常回滚 |
| `verifyTicketById`L474附近 | 同上,在事务末尾调用 `autoConfirmOrder` |
| `autoConfirmOrder`L614-L657 | **新增方法**:悲观锁查单 → 状态判断 → 幂等/跳过/确认收货 |
#### `verifyTicket` 事务内调用
```php
// 在 verifyTicket 事务内,核销完成后
$auto_confirm_ret = $this->autoConfirmOrder($order_id);
if ($auto_confirm_ret['code'] != 0) {
throw new \Exception($auto_confirm_ret['msg']);
}
```
#### `autoConfirmOrder` 方法签名
```php
/**
* 核销后自动确认收货
* - 使用悲观锁防并发
* - status==4幂等返回成功
* - status!=3跳过
* @return array ['code'=>0/1, 'msg'=>...]
*/
private function autoConfirmOrder($order_id): array
```
### GitNexus 变更报告
| 指标 | 值 |
|------|-----|
| 变更符号数 | 4autoConfirmOrder 新增 + verifyTicket/verifyTicketById/verifyByShortCode 受影响) |
| 变更文件数 | 1TicketService.php |
| 受影响执行流程 | 0 |
| 风险级别 | **LOW** |
---
## 测试策略
### 单元测试(验证码核销 + 待收货订单)
**请求:**
```
POST /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=verifier&pluginsaction=verify
code=<验证码> status=<状态>
```
#### 用例1待收货订单核销核心路径
| 步骤 | 预期 |
|------|------|
| 1. 手机端下单票品 | 订单 status=3待发货/待收货) |
| 2. 使用核销码核销 | 票 status=1订单 status=4 |
| 3. 检查订单详情 | 确认收货时间已记录 |
#### 用例2已确认收货订单核销幂等
| 步骤 | 预期 |
|------|------|
| 1. 使用已确认收货订单的核销码核销 | 票正常核销,不报错 |
#### 用例3手动确认收货后再核销
| 步骤 | 预期 |
|------|------|
| 1. 手动确认收货status 3→4 | 成功 |
| 2. 使用核销码核销 | 票正常核销,`autoConfirmOrder` 幂等返回 |
#### 用例4并发核销
| 步骤 | 预期 |
|------|------|
| 1. 两个核销请求几乎同时到达 | 只有一个成功,另一个因悲观锁或票状态被拒绝 |
---
## 验证结果
| # | 需求 | 实现位置 | 状态 |
|---|------|----------|------|
| 1 | 核销后自动确认收货 | `verifyTicket` L314 + `verifyTicketById` L474 调用 `autoConfirmOrder` | ✅ |
| 2 | 仅待收货才处理 | `autoConfirmOrder` L635: `if ($order['status'] != 3)` → 跳过 | ✅ |
| 3 | 已确认收货幂等 | `autoConfirmOrder` L629: `if ($order['status'] == 4)` → 返回 success | ✅ |
| 4 | 确认失败回滚核销 | `$confirm_ret['code'] != 0``throw \Exception`,事务回滚 | ✅ |
| 5 | 悲观锁防并发 | 订单查询 `->lock(true)` (L620),票查询 `->lock(true)` (L257, L417) | ✅ |
| 6 | 短码核销继承 | `verifyByShortCode` → 委托 `verifyTicketById` | ✅ |
| 7 | 已收货+未核销正常走 | `autoConfirmOrder` 返回 `code=0`,核销不受影响 | ✅ |
---
## 未实现项(明确排除)
| 项 | 原因 |
|----|------|
| 核销时记录核销人staffId | 需求文档未提及 |
| 核销时记录电话号码staffMobile | 需求文档未提及 |
| `autoConfirmOrder``$order == null` 的静默处理 | 没有需求约束,不做猜测性实现 |
---
## 未来增强建议
1. **核销记录表**:如需追溯核销人,可扩展 `vr_ticket_verification` 表增加 `staff_id`/`staff_mobile` 字段
2. **异步确认收货**:如确认收货 API 耗时较长,可考虑队列化处理以提高核销吞吐
3. **核销回调钩子**:在 `verifyTicket` 完成后增加后置钩子(如发送核销通知),使扩展点更清晰

View File

@ -280,11 +280,15 @@ class Goods
} }
try { try {
// 缓存检查 // 缓存检查(注意:旧缓存不含 goods 字段,需要补充)
$cacheKey = 'vr_tree_v4_' . $goodsId . '_' . md5(implode(',', $groupBy)); $cacheKey = 'vr_tree_v4_' . $goodsId . '_' . md5(implode(',', $groupBy));
$cached = \think\facade\Cache::get($cacheKey); $cached = \think\facade\Cache::get($cacheKey);
if ($cached !== null) { if ($cached !== null) {
$cached['meta']['cache_hit'] = true; $cached['meta']['cache_hit'] = true;
// 旧缓存不含 goods补充构建
if (!isset($cached['goods'])) {
$cached['goods'] = self::buildGoodsField($goodsId);
}
return self::success($cached); return self::success($cached);
} }
@ -297,6 +301,7 @@ class Goods
'goods_id' => $goodsId, 'goods_id' => $goodsId,
'group_by' => $groupBy, 'group_by' => $groupBy,
'tree' => [], 'tree' => [],
'goods' => self::buildGoodsField($goodsId),
'seat_templates' => new \stdClass(), 'seat_templates' => new \stdClass(),
'meta' => [ 'meta' => [
'seat_count' => 0, 'seat_count' => 0,
@ -320,6 +325,7 @@ class Goods
'goods_id' => $goodsId, 'goods_id' => $goodsId,
'group_by' => $groupBy, 'group_by' => $groupBy,
'tree' => $treeData['tree'], 'tree' => $treeData['tree'],
'goods' => self::buildGoodsField($goodsId),
'seat_templates' => $seatTemplates, 'seat_templates' => $seatTemplates,
'session_meta' => $sessionMeta, 'session_meta' => $sessionMeta,
'peer_goods' => QueryManager::getPeerGoods($goodsId), 'peer_goods' => QueryManager::getPeerGoods($goodsId),
@ -340,6 +346,47 @@ class Goods
} }
} }
/**
* 构建 goods 字段(包含手机详情 content_app
*
* 复用 GoodsService::GoodsList传入 is_content_app=1 以包含手机端详情数据。
* 这使得 uniapp 可以仅调用 tree API 一次就获取完整数据,无需二次请求 goods/detail。
*
* @param int $goodsId
* @return array
*/
private static function buildGoodsField(int $goodsId): array
{
$isUseMobileDetail = intval(\MyC('common_app_is_use_mobile_detail', 0, true));
$params = [
'where' => [
['id', '=', $goodsId],
['is_delete_time', '=', 0],
],
'is_content_app' => $isUseMobileDetail,
'is_spec' => 1,
'is_params' => 1,
'is_favor' => 1,
'm' => 0,
'n' => 1,
];
$ret = GoodsService::GoodsList($params);
$goods = $ret['data'][0] ?? null;
if (empty($goods)) {
return [];
}
// 手机端详情模式下移除 PC 富文本,减少响应体积
if ($isUseMobileDetail == 1) {
unset($goods['content_web']);
}
return $goods;
}
/** /**
* 格式化商品列表数据 * 格式化商品列表数据
*/ */

View File

@ -82,7 +82,7 @@ class AdminGoodsIndex
// 触发chosen组件更新如果已初始化 // 触发chosen组件更新如果已初始化
if ($.fn.chosen && select.classList.contains('chosen-init-success')) { if ($.fn.chosen && select.classList.contains('chosen-init-success')) {
select.trigger('chosen:updated'); $(select).trigger('chosen:updated');
} }
return true; return true;

View File

@ -364,11 +364,19 @@ class AdminGoodsSave
}); });
}; };
// 备份原始的 produce_region 选项 HTML
let originalRegionOptionsHtml = '';
// ── 动态替换 produce_region 下拉选项为 level=2 城市 ── // ── 动态替换 produce_region 下拉选项为 level=2 城市 ──
const replaceCityOptions = () => { const replaceCityOptions = () => {
const select = document.querySelector('select[name="produce_region"]'); const select = document.querySelector('select[name="produce_region"]');
if (!select) return; if (!select) return;
// 备份原始选项 HTML仅在第一次被替换前备份
if (!originalRegionOptionsHtml) {
originalRegionOptionsHtml = select.innerHTML;
}
// 优先使用 PHP 传递的保存值(城市 ID这是最可靠的数据源 // 优先使用 PHP 传递的保存值(城市 ID这是最可靠的数据源
const savedValue = AppData.savedProduceRegion || 0; const savedValue = AppData.savedProduceRegion || 0;
@ -391,7 +399,18 @@ class AdminGoodsSave
// 触发chosen组件更新如果使用了chosen插件 // 触发chosen组件更新如果使用了chosen插件
if ($.fn.chosen) { if ($.fn.chosen) {
select.trigger('chosen:updated'); $(select).trigger('chosen:updated');
}
};
const restoreCityOptions = () => {
const select = document.querySelector('select[name="produce_region"]');
if (!select || !originalRegionOptionsHtml) return;
select.innerHTML = originalRegionOptionsHtml;
if ($.fn.chosen) {
$(select).trigger('chosen:updated');
} }
}; };
@ -400,6 +419,9 @@ class AdminGoodsSave
if (val) { if (val) {
// 票务商品模式:替换城市下拉选项 // 票务商品模式:替换城市下拉选项
replaceCityOptions(); replaceCityOptions();
} else {
// 恢复原始选项
restoreCityOptions();
} }
}, { immediate: true }); }, { immediate: true });

View File

@ -11,6 +11,8 @@ namespace app\plugins\vr_ticket\service;
require_once __DIR__ . '/BaseService.php'; require_once __DIR__ . '/BaseService.php';
use app\service\OrderService;
class TicketService extends BaseService class TicketService extends BaseService
{ {
/** /**
@ -308,6 +310,17 @@ class TicketService extends BaseService
0 0
); );
// P1 核销成功后自动确认收货(失败则回滚整个核销事务)
$confirm_ret = self::autoConfirmOrder($ticket['order_id'], $verifier_id);
if ($confirm_ret['code'] != 0) {
BaseService::log('verifyTicket: auto_confirm_failed', [
'ticket_id' => $ticket['id'],
'order_id' => $ticket['order_id'],
'error' => $confirm_ret['msg'],
], 'error');
throw new \Exception('确认收货失败:' . $confirm_ret['msg']);
}
return [ return [
'code' => 0, 'code' => 0,
'msg' => '核销成功', 'msg' => '核销成功',
@ -457,6 +470,17 @@ class TicketService extends BaseService
0 0
); );
// P1 核销成功后自动确认收货(失败则回滚整个核销事务)
$confirm_ret = self::autoConfirmOrder($ticket['order_id'], $verifier_id);
if ($confirm_ret['code'] != 0) {
BaseService::log('verifyTicketById: auto_confirm_failed', [
'ticket_id' => $ticket_id,
'order_id' => $ticket['order_id'],
'error' => $confirm_ret['msg'],
], 'error');
throw new \Exception('确认收货失败:' . $confirm_ret['msg']);
}
return [ return [
'code' => 0, 'code' => 0,
'msg' => '核销成功', 'msg' => '核销成功',
@ -575,4 +599,60 @@ class TicketService extends BaseService
], ],
]; ];
} }
/**
* 核销成功后自动确认收货
*
* 仅针对订单状态=3(待收货)的票务订单
* 幂等:已是 status=4 则直接返回
* 调用商城统一 OrderCollectHandle 完成积分赠送、销量增加、消息推送等
*
* @param int $order_id 订单ID
* @param int $verifier_id 核销员ID作为 creator 记录)
* @return array [code, msg]
*/
private static function autoConfirmOrder($order_id, $verifier_id = 0)
{
// 完整查询订单OrderCollectHandle 需要 id,status,pay_status,user_id,order_model
$order = \think\facade\Db::name('Order')
->where('id', $order_id)
->field('id,status,pay_status,user_id,order_model')
->lock(true)
->find();
if (empty($order)) {
BaseService::log('autoConfirmOrder: order_not_found', ['order_id' => $order_id], 'warning');
return ['code' => -1, 'msg' => '订单不存在'];
}
// 幂等保护:已经是已完成状态
if ($order['status'] == 4) {
BaseService::log('autoConfirmOrder: already_completed', ['order_id' => $order_id], 'info');
return ['code' => 0, 'msg' => '已完成'];
}
// 仅自动确认待收货状态status=3的订单
if ($order['status'] != 3) {
BaseService::log('autoConfirmOrder: wrong_status', [
'order_id' => $order_id,
'status' => $order['status'],
], 'warning');
return ['code' => -2, 'msg' => '订单状态非待收货'];
}
// 调用商城统一收货处理
$params = [
'creator' => $verifier_id,
'creator_name' => '票务核销自动确认',
];
$ret = OrderService::OrderCollectHandle($order, $params);
BaseService::log('autoConfirmOrder: done', [
'order_id' => $order_id,
'result' => $ret['code'] == 0 ? 'success' : 'failed',
'msg' => $ret['msg'] ?? '',
], $ret['code'] == 0 ? 'info' : 'warning');
return $ret;
}
} }