599 lines
19 KiB
PHP
599 lines
19 KiB
PHP
<?php
|
||
/**
|
||
* VR Tree API - 查询管理器 V2
|
||
*
|
||
* 新设计思路:
|
||
* 1. 根据 group_by 顺序动态构建层级树骨架
|
||
* 2. 遍历 seatSpecMap,将座位数据直接嵌入到 group_by 最后一层的 seats 属性
|
||
* 3. 自底向上统计:最深层统计 seats 数量,累加到父节点
|
||
* 4. 无需维护 flat_inventory 单独数组(已嵌入到树中)
|
||
*
|
||
* @package vr_ticket\service
|
||
*/
|
||
|
||
namespace app\plugins\vr_ticket\service;
|
||
|
||
use think\facade\Db;
|
||
|
||
use app\plugins\vr_ticket\service\BaseService;
|
||
|
||
class QueryManager
|
||
{
|
||
/**
|
||
* spec_key 前缀映射
|
||
*/
|
||
const SPEC_DIM_PREFIX = [
|
||
'venue' => '$vr-场馆=',
|
||
'session' => '$vr-场次=',
|
||
'room' => '$vr-演播室=',
|
||
'section' => '$vr-分区=',
|
||
'seat' => '$vr-座位号=',
|
||
];
|
||
|
||
/**
|
||
* 缓存 key 前缀
|
||
*/
|
||
const CACHE_KEY_PREFIX = 'vr_tree_v3_';
|
||
|
||
/**
|
||
* 缓存 TTL(秒)
|
||
*/
|
||
const CACHE_TTL = 60;
|
||
|
||
/**
|
||
* 主入口:生成层级树 V2
|
||
*
|
||
* 新算法:
|
||
* 1. 根据 group_by 动态构建树骨架
|
||
* 2. 遍历 seatSpecMap,将座位数据嵌入到最后一层级的 seats 属性
|
||
* 3. 自底向上统计 inventory/min_price/max_price
|
||
*
|
||
* @param int $goodsId 商品ID
|
||
* @param array $groupBy 分组维度,如 ['venue', 'session', 'room', 'section']
|
||
* @param array $seatSpecMap seatSpecMap 数据
|
||
* @return array [
|
||
* 'tree' => [...],
|
||
* 'template_keys' => [...],
|
||
* ]
|
||
*/
|
||
public static function buildTree(int $goodsId, array $groupBy, array $seatSpecMap): array
|
||
{
|
||
if (empty($groupBy) || empty($seatSpecMap)) {
|
||
return ['tree' => [], 'template_keys' => []];
|
||
}
|
||
|
||
// 1. 构建空树骨架(根据 group_by 顺序)
|
||
$tree = self::buildEmptyTreeSkeleton($groupBy);
|
||
|
||
// 2. 遍历 seatSpecMap,填充座位数据
|
||
$templateKeys = [];
|
||
$lastDim = end($groupBy); // group_by 的最后一个维度
|
||
|
||
foreach ($seatSpecMap as $specKey => $specData) {
|
||
$dims = self::parseSpecKey($specKey);
|
||
|
||
// 跳过无效数据
|
||
if (empty($dims['seat'])) {
|
||
continue;
|
||
}
|
||
|
||
// 按 group_by 顺序导航到对应节点
|
||
$node = & $tree;
|
||
foreach ($groupBy as $dim) {
|
||
$dimValue = $dims[$dim] ?? '';
|
||
if ($dimValue === '') {
|
||
// 维度为空,跳过
|
||
continue 2; // 跳出外层 foreach
|
||
}
|
||
|
||
$containerKey = $dim . 's'; // venue -> venues
|
||
|
||
// 确保容器存在
|
||
if (!isset($node[$containerKey])) {
|
||
$node[$containerKey] = [];
|
||
}
|
||
|
||
// 确保节点存在
|
||
if (!isset($node[$containerKey][$dimValue])) {
|
||
// 创建新节点(带下一层级容器)
|
||
$nextContainerKey = self::getNextContainerKey($groupBy, $dim);
|
||
$node[$containerKey][$dimValue] = [
|
||
'name' => $dimValue,
|
||
];
|
||
if ($nextContainerKey) {
|
||
$node[$containerKey][$dimValue][$nextContainerKey] = [];
|
||
}
|
||
}
|
||
|
||
$node = & $node[$containerKey][$dimValue];
|
||
}
|
||
|
||
// 到达最后一层级,嵌入座位数据到 seats 属性
|
||
if (!isset($node['seats'])) {
|
||
$node['seats'] = [];
|
||
}
|
||
|
||
// 添加座位信息(完整数据)
|
||
$node['seats'][$dims['seat']] = [
|
||
'spec_key' => $specKey,
|
||
'venue' => $dims['venue'] ?? '',
|
||
'session' => $dims['session'] ?? '',
|
||
'room' => $dims['room'] ?? '',
|
||
'section' => $dims['section'] ?? '',
|
||
'seat' => $dims['seat'] ?? '',
|
||
'price' => floatval($specData['price'] ?? 0),
|
||
'inventory' => intval($specData['inventory'] ?? 0),
|
||
'original_price' => floatval($specData['original_price'] ?? 0),
|
||
];
|
||
|
||
// 记录 template_key(用于去重)
|
||
if (!empty($dims['venue']) && !empty($dims['room']) && !empty($dims['section'])) {
|
||
$templateKey = $dims['venue'] . '_' . $dims['room'] . '_' . $dims['section'];
|
||
$templateKeys[$templateKey] = true;
|
||
}
|
||
}
|
||
|
||
// 3. 自底向上统计(从最后一层向上)
|
||
self::computeStatsRecursive($tree, $groupBy, 0);
|
||
|
||
// 统计总座位数
|
||
$seatCount = 0;
|
||
foreach ($seatSpecMap as $specData) {
|
||
$seatCount += intval($specData['inventory'] ?? 0);
|
||
}
|
||
|
||
return [
|
||
'tree' => $tree,
|
||
'template_keys' => array_keys($templateKeys),
|
||
'seat_count' => $seatCount,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 构建空树骨架(根据 group_by 顺序)
|
||
*
|
||
* group_by = [venue, session, room, section]
|
||
* 结果:{ venues: {...}, sessions: {...}, rooms: {...}, sections: {...} }
|
||
*
|
||
* group_by = [section, venue, session, room]
|
||
* 结果:{ sections: {...}, venues: {...}, sessions: {...}, rooms: {...} }
|
||
*
|
||
* @param array $groupBy
|
||
* @return array
|
||
*/
|
||
private static function buildEmptyTreeSkeleton(array $groupBy): array
|
||
{
|
||
$skeleton = [];
|
||
|
||
foreach ($groupBy as $dim) {
|
||
$containerKey = $dim . 's'; // venue -> venues
|
||
$skeleton[$containerKey] = [];
|
||
}
|
||
|
||
return $skeleton;
|
||
}
|
||
|
||
/**
|
||
* 获取下一个层级的容器 key
|
||
*
|
||
* @param array $groupBy 分组维度
|
||
* @param string $currentDim 当前维度
|
||
* @return string|null
|
||
*/
|
||
private static function getNextContainerKey(array $groupBy, string $currentDim): ?string
|
||
{
|
||
$foundCurrent = false;
|
||
foreach ($groupBy as $dim) {
|
||
if ($foundCurrent) {
|
||
return $dim . 's';
|
||
}
|
||
if ($dim === $currentDim) {
|
||
$foundCurrent = true;
|
||
}
|
||
}
|
||
return null; // 当前维度是最后一个
|
||
}
|
||
|
||
/**
|
||
* 递归计算统计信息(自底向上)
|
||
*
|
||
* @param array $node 当前节点(引用)
|
||
* @param array $groupBy 分组维度
|
||
* @param int $depth 当前深度
|
||
*/
|
||
private static function computeStatsRecursive(array & $node, array $groupBy, int $depth): void
|
||
{
|
||
// 确定当前层级的容器 key
|
||
$containerKey = ($groupBy[$depth] ?? '') . 's';
|
||
|
||
if (empty($containerKey) || !isset($node[$containerKey])) {
|
||
return;
|
||
}
|
||
|
||
// 遍历当前层级的所有节点
|
||
foreach ($node[$containerKey] as $key => & $childNode) {
|
||
$nextDepth = $depth + 1;
|
||
|
||
// 如果还有下一层级,先递归计算子节点
|
||
if ($nextDepth < count($groupBy)) {
|
||
self::computeStatsRecursive($childNode, $groupBy, $nextDepth);
|
||
}
|
||
|
||
// 计算当前节点的统计信息
|
||
self::computeNodeStats($childNode, $groupBy, $depth);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算单个节点的统计信息
|
||
*
|
||
* 规则:
|
||
* - 如果有 seats 属性,直接统计 seats
|
||
* - 否则汇总子节点的统计
|
||
*
|
||
* @param array &$node 当前节点(引用)
|
||
* @param array $groupBy 分组维度
|
||
* @param int $depth 当前深度
|
||
*/
|
||
private static function computeNodeStats(array & $node, array $groupBy, int $depth): void
|
||
{
|
||
// 如果有 seats 属性(最后一层级),直接统计
|
||
if (isset($node['seats']) && is_array($node['seats'])) {
|
||
$seatCount = count($node['seats']);
|
||
$totalInventory = 0;
|
||
$prices = [];
|
||
$hasAvailable = false;
|
||
|
||
foreach ($node['seats'] as $seatKey => $seatData) {
|
||
$inv = $seatData['inventory'] ?? 0;
|
||
$price = $seatData['price'] ?? 0;
|
||
|
||
if ($inv > 0) {
|
||
$totalInventory++;
|
||
$hasAvailable = true;
|
||
}
|
||
|
||
if ($price > 0) {
|
||
$prices[] = $price;
|
||
}
|
||
}
|
||
|
||
$node['inventory'] = $totalInventory;
|
||
$node['has_available'] = $hasAvailable;
|
||
|
||
if (!empty($prices)) {
|
||
$node['min_price'] = min($prices);
|
||
$node['max_price'] = max($prices);
|
||
} else {
|
||
$node['min_price'] = 0;
|
||
$node['max_price'] = 0;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// 否则汇总子节点
|
||
$nextDepth = $depth + 1;
|
||
$nextContainerKey = ($groupBy[$nextDepth] ?? '') . 's';
|
||
|
||
if (empty($nextContainerKey) || !isset($node[$nextContainerKey])) {
|
||
// 没有子节点
|
||
$node['inventory'] = 0;
|
||
$node['has_available'] = false;
|
||
$node['min_price'] = 0;
|
||
$node['max_price'] = 0;
|
||
return;
|
||
}
|
||
|
||
// 汇总所有子节点的统计
|
||
$totalInventory = 0;
|
||
$minPrices = []; // 收集所有子节点的 min_price
|
||
$maxPrices = []; // 只收集非零的 max_price
|
||
$hasAvailable = false;
|
||
|
||
foreach ($node[$nextContainerKey] as $childNode) {
|
||
$totalInventory += intval($childNode['inventory'] ?? 0);
|
||
|
||
if (($childNode['has_available'] ?? false) === true) {
|
||
$hasAvailable = true;
|
||
}
|
||
|
||
// 收集所有子节点的 min_price(含 0)
|
||
$minPrices[] = $childNode['min_price'] ?? 0;
|
||
|
||
// 只收集非零的 max_price
|
||
$maxP = $childNode['max_price'] ?? 0;
|
||
if ($maxP > 0) {
|
||
$maxPrices[] = $maxP;
|
||
}
|
||
}
|
||
|
||
$node['inventory'] = $totalInventory;
|
||
$node['has_available'] = $hasAvailable;
|
||
$node['min_price'] = !empty($minPrices) ? min($minPrices) : 0;
|
||
$node['max_price'] = !empty($maxPrices) ? max($maxPrices) : 0;
|
||
}
|
||
|
||
/**
|
||
* 解析 spec_key,提取各维度值
|
||
*
|
||
* @param string $specKey 格式:$vr-场次=X|$vr-场馆=X|$vr-演播室=X|$vr-分区=X|$vr-座位号=X
|
||
* @return array [venue => 'xxx', session => 'xxx', room => 'xxx', section => 'xxx', seat => 'xxx']
|
||
*/
|
||
public static function parseSpecKey(string $specKey): array
|
||
{
|
||
$dims = [
|
||
'venue' => '',
|
||
'session' => '',
|
||
'room' => '',
|
||
'section' => '',
|
||
'seat' => '',
|
||
];
|
||
|
||
$parts = explode('|', $specKey);
|
||
foreach ($parts as $part) {
|
||
$part = trim($part);
|
||
if (empty($part)) {
|
||
continue;
|
||
}
|
||
|
||
foreach (self::SPEC_DIM_PREFIX as $dim => $prefix) {
|
||
if (strpos($part, $prefix) === 0) {
|
||
$dims[$dim] = substr($part, strlen($prefix));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $dims;
|
||
}
|
||
|
||
/**
|
||
* 构建扁平 SKU 列表(兼容旧接口)
|
||
*
|
||
* @param array $seatSpecMap seatSpecMap 数据
|
||
* @return array flat_inventory
|
||
*/
|
||
public static function buildFlatInventory(array $seatSpecMap): array
|
||
{
|
||
$flat = [];
|
||
|
||
foreach ($seatSpecMap as $specKey => $specData) {
|
||
$dims = self::parseSpecKey($specKey);
|
||
if (empty($dims['seat'])) {
|
||
continue;
|
||
}
|
||
|
||
$flat[] = [
|
||
'spec_key' => $specKey,
|
||
'venue' => $dims['venue'] ?? '',
|
||
'session' => $dims['session'] ?? '',
|
||
'room' => $dims['room'] ?? '',
|
||
'section' => $dims['section'] ?? '',
|
||
'seat' => $dims['seat'] ?? '',
|
||
'price' => floatval($specData['price'] ?? 0),
|
||
'inventory' => intval($specData['inventory'] ?? 0),
|
||
'original_price' => floatval($specData['original_price'] ?? 0),
|
||
];
|
||
}
|
||
|
||
return $flat;
|
||
}
|
||
|
||
/**
|
||
* 构建模板去重池
|
||
*
|
||
* @param int $goodsId 商品ID
|
||
* @param array $templateKeys template_key 列表
|
||
* @return array seat_templates_flat
|
||
*/
|
||
public static function buildTemplatePool(int $goodsId, array $templateKeys): array
|
||
{
|
||
if (empty($templateKeys) || $goodsId <= 0) {
|
||
return [];
|
||
}
|
||
|
||
$uniqueKeys = array_unique($templateKeys);
|
||
|
||
// 从 vr_goods_config 获取 template_id
|
||
$vrConfigRaw = Db::name('Goods')
|
||
->where('id', $goodsId)
|
||
->value('vr_goods_config');
|
||
|
||
$configs = json_decode($vrConfigRaw ?? '', true);
|
||
if (empty($configs) || !is_array($configs)) {
|
||
return [];
|
||
}
|
||
|
||
// 预加载所有 venue 的 seatMap
|
||
$venueSeatMaps = [];
|
||
foreach ($configs as $config) {
|
||
$templateId = intval($config['template_id'] ?? 0);
|
||
if ($templateId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$template = Db::name(BaseService::table('seat_templates'))
|
||
->where('id', $templateId)
|
||
->find();
|
||
|
||
if (empty($template)) {
|
||
continue;
|
||
}
|
||
|
||
// SeatSkuService::BatchGenerate 使用了 template['name'] 作为维度名
|
||
$venueName = $template['name'] ?? '未命名场馆';
|
||
|
||
$seatMap = [];
|
||
// 优先使用 template_snapshot
|
||
if (!empty($config['template_snapshot']['venue'])) {
|
||
$seatMap = $config['template_snapshot'];
|
||
} else {
|
||
// 降级使用 DB 中的 json
|
||
$seatMap = json_decode($template['seat_map'] ?? '{}', true);
|
||
}
|
||
|
||
if (!empty($seatMap) && $venueName !== '') {
|
||
$venueSeatMaps[$venueName] = $seatMap;
|
||
}
|
||
}
|
||
|
||
// 构建模板池
|
||
$result = [];
|
||
$seenTemplates = [];
|
||
|
||
foreach ($uniqueKeys as $key) {
|
||
$parts = explode('_', $key);
|
||
if (count($parts) < 3) {
|
||
continue;
|
||
}
|
||
$venueName = $parts[0];
|
||
$roomName = $parts[1];
|
||
$sectionName = $parts[2];
|
||
|
||
// 匹配对应的场馆模板
|
||
$seatMap = $venueSeatMaps[$venueName] ?? [];
|
||
if (empty($seatMap)) {
|
||
// 如果实在匹配不到,兜底取第一个
|
||
if (!empty($venueSeatMaps)) {
|
||
$seatMap = reset($venueSeatMaps);
|
||
} else {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$rooms = $seatMap['rooms'] ?? [];
|
||
|
||
// 去重 (使用 key 本身已经能保证去重,这里只保留原本签名逻辑以防万一,但加入 venueName)
|
||
$templateSignature = json_encode(['venue' => $venueName, 'rooms' => $rooms, 'sections' => $sectionName]);
|
||
if (isset($seenTemplates[$templateSignature])) {
|
||
continue;
|
||
}
|
||
$seenTemplates[$templateSignature] = true;
|
||
|
||
$result[] = [
|
||
'template_key' => $key,
|
||
'name' => $venueName,
|
||
'room_name' => $roomName,
|
||
'section_name' => $sectionName,
|
||
'seat_map' => $seatMap,
|
||
'rooms' => $rooms,
|
||
'layout_cols' => $seatMap['cols'] ?? 10,
|
||
'layout_rows' => $seatMap['rows'] ?? 10,
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 生成缓存 key
|
||
*
|
||
* @param int $goodsId
|
||
* @param array $groupBy
|
||
* @return string
|
||
*/
|
||
public static function makeCacheKey(int $goodsId, array $groupBy): string
|
||
{
|
||
return self::CACHE_KEY_PREFIX . $goodsId . '_' . md5(implode(',', $groupBy));
|
||
}
|
||
|
||
/**
|
||
* 清除 tree 缓存
|
||
*
|
||
* @param int $goodsId
|
||
*/
|
||
public static function clearCache(int $goodsId): void
|
||
{
|
||
\think\facade\Cache::tag('vr_tree_' . $goodsId, null);
|
||
}
|
||
|
||
/**
|
||
* 将模板列表转换为键值对格式
|
||
*
|
||
* 输入: ['测试场馆_老展厅 1_A', '测试场馆_老展厅 1_B']
|
||
* 输出: {
|
||
* '测试场馆_老展厅 1_A': { 模板详情 },
|
||
* '测试场馆_老展厅 1_B': { 模板详情 }
|
||
* }
|
||
*
|
||
* @param array $templates 模板数组(原始列表格式)
|
||
* @return array 键值对格式的模板对象
|
||
*/
|
||
/**
|
||
* 获取同场次关联商品列表(peer goods)
|
||
*
|
||
* 通过 coding(商品编码)字段将同一演出不同日期的票务商品关联起来,
|
||
* 供前端渲染多日期切换导航控件。
|
||
*
|
||
* @param int $goodsId 当前商品ID
|
||
* @return array [['id' => int, 'title' => string, 'date' => string], ...]
|
||
*/
|
||
public static function getPeerGoods(int $goodsId): array
|
||
{
|
||
// 1. 获取当前商品的 coding(商品编码)
|
||
$coding = Db::name('Goods')
|
||
->where('id', $goodsId)
|
||
->value('coding');
|
||
|
||
if (empty($coding)) {
|
||
return [];
|
||
}
|
||
|
||
// 2. 查询所有共享同一 coding 的商品(排除自身)
|
||
// batch_number_expire 存的是 Unix 时间戳(int),转为日期字符串用于展示
|
||
$peers = Db::name('Goods')
|
||
->where('coding', $coding)
|
||
->where('id', '<>', $goodsId)
|
||
->where('is_shelves', 1)
|
||
->where('is_delete_time', 0)
|
||
->field('id, title, batch_number_expire')
|
||
->order('batch_number_expire', 'asc')
|
||
->select()
|
||
->toArray();
|
||
|
||
if (empty($peers)) {
|
||
return [];
|
||
}
|
||
|
||
// 3. 格式化输出(batch_number_expire 是 int 时间戳,转 Y-m-d)
|
||
$result = [];
|
||
foreach ($peers as $peer) {
|
||
$dateTs = intval($peer['batch_number_expire'] ?? 0);
|
||
$result[] = [
|
||
'id' => (int)$peer['id'],
|
||
'title' => $peer['title'],
|
||
'date' => $dateTs > 0 ? date('Y-m-d', $dateTs) : '',
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 将模板列表转换为键值对格式
|
||
*
|
||
* 输入: ['测试场馆_老展厅 1_A', '测试场馆_老展厅 1_B']
|
||
* 输出: {
|
||
* '测试场馆_老展厅 1_A': { 模板详情 },
|
||
* '测试场馆_老展厅 1_B': { 模板详情 }
|
||
* }
|
||
*
|
||
* @param array $templates 模板数组(原始列表格式)
|
||
* @return array 键值对格式的模板对象
|
||
*/
|
||
public static function transformTemplatePool(array $templates): array
|
||
{
|
||
$result = [];
|
||
foreach ($templates as $template) {
|
||
if (!empty($template) && is_array($template)) {
|
||
$key = $template['template_key'] ?? null;
|
||
if ($key) {
|
||
$result[$key] = $template;
|
||
}
|
||
}
|
||
}
|
||
return $result;
|
||
}
|
||
}
|