vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/QueryManager.php

599 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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;
}
}