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

711 lines
28 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票务插件 - 座位 SKU 服务
*
* ShopXO 规格表结构:
* GoodsSpecType : id, goods_id, name, value(JSON数组), add_time
* GoodsSpecBase : id, goods_id, price, original_price, inventory, ...
* GoodsSpecValue : id, goods_id, goods_spec_base_id, value, md5_key, add_time
*
* 列对应关系由 GoodsEditSpecifications 决定:
* 它把每个 GoodsSpecValue.value 逐个在 GoodsSpecType.value(JSON) 的 name 字段中搜索,
* 匹配成功才显示在对应列。
* 因此GoodsSpecType.value JSON 里必须包含我们写入的所有 value 字符串。
*
* @package vr_ticket\service
*/
namespace app\plugins\vr_ticket\service;
use think\facade\Db;
class SeatSkuService extends BaseService
{
const BATCH_SIZE = 200;
/**
* VR 规格维度名顺序固定5维
* 注意:按选购流程顺序排列:场次 → 场馆 → 演播室 → 分区 → 座位号
*/
const SPEC_DIMS = ['$vr-场次', '$vr-场馆', '$vr-演播室', '$vr-分区', '$vr-座位号'];
/**
* 批量生成座位级 SKU
*
* @param int $goodsId
* @param int $seatTemplateId
* @param array $selectedRooms 要生成的厅 id 列表,空=全部
* @param array $selectedSections 按 roomId 为 key 的分区 char 数组,空=全部
* @param array $sessions 场次数组 e.g. [["start"=>"08:00","end"=>"23:59"]]
*/
public static function BatchGenerate(
int $goodsId,
int $seatTemplateId,
array $selectedRooms = [],
array $selectedSections = [],
array $sessions = []
): array {
if ($goodsId <= 0 || $seatTemplateId <= 0) {
return ['code' => -1, 'msg' => '参数错误goodsId 或 seatTemplateId 无效'];
}
// 1. 加载座位模板
$template = Db::name(self::table('seat_templates'))
->where('id', $seatTemplateId)
->find();
if (empty($template)) {
return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
}
// 2. 解析 seat_map
$seatMap = json_decode($template['seat_map'] ?? '{}', true);
$rooms = $seatMap['rooms'] ?? [];
if (empty($rooms)) {
return ['code' => -3, 'msg' => '座位模板 seat_map 无效rooms 为空)'];
}
// 使用模板表的短名称
$venueName = $template['name'] ?? '未命名场馆';
// 3. 场次处理(默认兜底)
if (empty($sessions)) {
$sessions = [['start' => '08:00', 'end' => '23:59']];
}
$sessionStrings = [];
foreach ($sessions as $s) {
$start = is_array($s) ? ($s['start'] ?? '08:00') : '08:00';
$end = is_array($s) ? ($s['end'] ?? '23:59') : '23:59';
$sessionStr = "{$start}-{$end}";
if (!in_array($sessionStr, $sessionStrings)) {
$sessionStrings[] = $sessionStr;
}
}
// 4. 确保 4 个 VR spec type 维度存在,同时收集要写入的所有唯一 value
// 我们先遍历座位图,收集全部规格值,再一次性写入 type.value JSON
$selectedRooms = array_values(array_filter($selectedRooms));
$selectedSections = array_filter($selectedSections);
// 按维度收集唯一值(用 有序列表 + 去重)
$dimUniqueValues = [
'$vr-场次' => [],
'$vr-场馆' => [],
'$vr-演播室' => [],
'$vr-分区' => [],
'$vr-座位号' => [],
];
// 5. 遍历地图,收集所有座位信息
$seatsToInsert = [];
foreach ($rooms as $rIdx => $room) {
// 与前端 PHP 预处理保持一致id 缺失时用 'room_{index}'
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
$roomName = $room['name'] ?? '默认放映室';
if (!empty($selectedRooms) && !in_array($roomId, $selectedRooms)) {
continue;
}
// char → price 映射
$sectionPrices = [];
foreach (($room['sections'] ?? []) as $sec) {
if (!empty($sec['char'])) {
$sectionPrices[$sec['char']] = floatval($sec['price'] ?? 0);
}
}
$map = $room['map'] ?? [];
$seatsData = $room['seats'] ?? [];
foreach ($map as $rowIndex => $rowStr) {
$rowLabel = chr(65 + $rowIndex);
$chars = mb_str_split($rowStr);
foreach ($chars as $colIndex => $char) {
if ($char === '_' || $char === '-' || !isset($seatsData[$char])) {
continue;
}
if (!empty($selectedSections[$roomId])
&& !in_array($char, $selectedSections[$roomId])) {
continue;
}
// 价格三级 fallback
$seatInfo = $seatsData[$char];
$seatPrice = floatval($seatInfo['price'] ?? 0);
if ($seatPrice == 0 && isset($sectionPrices[$char])) {
$seatPrice = $sectionPrices[$char];
}
if ($seatPrice == 0) {
foreach (($room['sections'] ?? []) as $sec) {
if (($sec['char'] ?? '') === $char) {
$seatPrice = floatval($sec['price'] ?? 0);
break;
}
}
}
$col = $colIndex + 1;
// 维度值字符串(确保非空)
$val_venue = $venueName;
$val_section = $venueName . '-' . $roomName . '-' . $char;
$val_seat = $venueName . '-' . $roomName . '-' . $char . '-' . $rowLabel . $col;
foreach ($sessionStrings as $sessionStr) {
$seatKey = $roomId . '_' . $rowLabel . '_' . $col;
$seatId = $seatKey . '_' . md5($sessionStr);
$seatsToInsert[$seatId] = [
'price' => $seatPrice,
'seat_key' => $seatKey, // ← 用于前端映射
'extends' => json_encode(['seat_key' => $seatKey], JSON_UNESCAPED_UNICODE),
'spec_values' => [
$sessionStr, // $vr-场次第1维
$val_venue, // $vr-场馆第2维
$roomName, // $vr-演播室第3维
$val_section, // $vr-分区第4维
$val_seat, // $vr-座位号第5维
],
];
// 收集唯一维度值(保持首次出现顺序,与 SPEC_DIMS 对应)
if (!in_array($sessionStr, $dimUniqueValues['$vr-场次'])) {
$dimUniqueValues['$vr-场次'][] = $sessionStr;
}
if (!in_array($val_venue, $dimUniqueValues['$vr-场馆'])) {
$dimUniqueValues['$vr-场馆'][] = $val_venue;
}
if (!in_array($roomName, $dimUniqueValues['$vr-演播室'])) {
$dimUniqueValues['$vr-演播室'][] = $roomName;
}
if (!in_array($val_section, $dimUniqueValues['$vr-分区'])) {
$dimUniqueValues['$vr-分区'][] = $val_section;
}
if (!in_array($val_seat, $dimUniqueValues['$vr-座位号'])) {
$dimUniqueValues['$vr-座位号'][] = $val_seat;
}
}
}
}
}
if (empty($seatsToInsert)) {
return ['code' => -4, 'msg' => '无有效座位可生成'];
}
// 6. 保证 4 个 VR spec type 存在,并把已收集的 value 写入 type.value JSON
// 这样 GoodsEditSpecifications 的 name 匹配才能命中每条 GoodsSpecValue
self::ensureAndFillVrSpecTypes($goodsId, $dimUniqueValues);
// 7. 写入 GoodsSpecBase + GoodsSpecValue
$now = time();
$generatedCount = 0;
$valueBatch = [];
foreach ($seatsToInsert as $seatId => $s) {
$baseId = Db::name('GoodsSpecBase')->insertGetId([
'goods_id' => $goodsId,
'price' => $s['price'],
'original_price' => 0,
'inventory' => 1,
'buy_min_number' => 1,
'buy_max_number' => 1,
'weight' => 0,
'volume' => 0,
'coding' => '',
'barcode' => '',
'add_time' => $now,
'extends' => $s['extends'] ?? null,
]);
if (!$baseId) {
throw new \Exception("GoodsSpecBase 写入失败 (seat: {$seatId})");
}
// 5 条 GoodsSpecValue每条对应一个维度按 SPEC_DIMS 顺序)
// 注意GoodsSpecValue 表没有 name 字段,只能通过 value 匹配关联维度
foreach ($s['spec_values'] as $specVal) {
$valueBatch[] = [
'goods_id' => $goodsId,
'goods_spec_base_id' => $baseId,
'value' => (string)$specVal,
'md5_key' => md5((string)$specVal),
'add_time' => $now,
];
}
$generatedCount++;
if (count($valueBatch) >= self::BATCH_SIZE) {
Db::name('GoodsSpecValue')->insertAll($valueBatch);
$valueBatch = [];
}
}
if (!empty($valueBatch)) {
Db::name('GoodsSpecValue')->insertAll($valueBatch);
}
return [
'code' => 0,
'msg' => '生成成功',
'data' => [
'total' => count($seatsToInsert),
'generated' => $generatedCount,
],
];
}
/**
* 幂等确保 4 个 VR 维度存在,并把本次收集的所有唯一值合并写入 type.value JSON
*
* 关键GoodsEditSpecifications 通过 type.value JSON 里的 name 做匹配,
* 所以每条 GoodsSpecValue.value 都必须在对应 type.value 的某个 name 里找到。
*
* @param int $goodsId
* @param array $dimUniqueValues ['$vr-场馆' => [...], '$vr-分区' => [...], ...]
*/
public static function ensureAndFillVrSpecTypes(int $goodsId, array $dimUniqueValues = []): void
{
$now = time();
// 读取已存在的 VR 维度(按顺序)
$existing = Db::name('GoodsSpecType')
->where('goods_id', $goodsId)
->whereIn('name', self::SPEC_DIMS)
->order('id', 'asc')
->select()
->toArray();
$existingByName = array_column($existing, null, 'name');
foreach (self::SPEC_DIMS as $dimName) {
// 构建该维度的 value JSON把所有唯一值合并含已有值
$newItems = [];
$existingItems = [];
if (isset($existingByName[$dimName])) {
$existingItems = json_decode($existingByName[$dimName]['value'] ?? '[]', true);
if (!is_array($existingItems)) $existingItems = [];
}
// 把现有 JSON 中的 name 提取出来
$existingNames = array_column($existingItems, 'name');
// 合并本次新增的值
$toAdd = $dimUniqueValues[$dimName] ?? [];
foreach ($toAdd as $val) {
if (!in_array($val, $existingNames)) {
$existingItems[] = ['name' => (string)$val, 'images' => ''];
$existingNames[] = $val;
}
}
$valueJson = json_encode($existingItems, JSON_UNESCAPED_UNICODE);
if (isset($existingByName[$dimName])) {
// 更新已有维度的 value JSON
Db::name('GoodsSpecType')
->where('id', $existingByName[$dimName]['id'])
->update(['value' => $valueJson]);
} else {
// 插入缺失维度(保持 SPEC_DIMS 顺序ID 递增)
Db::name('GoodsSpecType')->insert([
'goods_id' => $goodsId,
'name' => $dimName,
'value' => $valueJson,
'add_time' => $now,
]);
}
}
}
/**
* 重新计算商品基础信息(价格区间、总库存)
*/
public static function refreshGoodsBase(int $goodsId): array
{
$bases = Db::name('GoodsSpecBase')
->where('goods_id', $goodsId)
->select()
->toArray();
if (empty($bases)) {
return ['code' => -1, 'msg' => '无 GoodsSpecBase'];
}
$prices = array_column($bases, 'price');
$minPrice = min($prices);
$maxPrice = max($prices);
$inventory = array_sum(array_column($bases, 'inventory'));
$priceDisplay = ($minPrice != $maxPrice && $maxPrice > 0)
? $minPrice . '-' . $maxPrice : $minPrice;
Db::name('Goods')->where('id', $goodsId)->update([
'min_price' => $minPrice,
'max_price' => $maxPrice,
'price' => $priceDisplay,
'min_original_price' => 0,
'max_original_price' => 0,
'original_price' => 0,
'inventory' => $inventory,
'is_exist_many_spec' => 1,
'buy_min_number' => 1,
'buy_max_number' => 1,
'upd_time' => time(),
]);
return ['code' => 0];
}
/**
* 获取商品前端展示数据(供 ticket_detail.html 模板使用)
*
* @param int $goodsId
* @return array ['vr_seat_template' => [...], 'goods_spec_data' => [], 'seatSpecMap' => [...]]
*/
public static function GetGoodsViewData(int $goodsId): array
{
// 读取 vr_goods_config
$goods = \think\facade\Db::name('goods')->find($goodsId);
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
if (empty($vrGoodsConfig) || !is_array($vrGoodsConfig)) {
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
}
// 过滤有效配置块(多模板模式)
$validConfigs = [];
foreach ($vrGoodsConfig as $cfg) {
$tid = intval($cfg['template_id'] ?? 0);
if ($tid <= 0) continue;
$tpl = \think\facade\Db::name(self::table('seat_templates'))->where('id', $tid)->find();
if (!empty($tpl)) {
$validConfigs[] = $cfg;
}
}
if (empty($validConfigs)) {
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
}
// 取第一个有效配置块用于前端展示
$config = $validConfigs[0];
$templateId = intval($config['template_id'] ?? 0);
if ($templateId <= 0) {
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
}
// 读取座位模板(包含 seat_map 和 spec_base_id_map
$seatTemplate = \think\facade\Db::name(self::table('seat_templates'))
->where('id', $templateId)
->find();
// 模板不存在时(硬删除场景):清理无效配置块,写回有效配置
if (empty($seatTemplate)) {
$validConfigs = array_filter($validConfigs, function ($cfg) use ($templateId) {
$tid = intval($cfg['template_id'] ?? 0);
if ($tid <= 0 || $tid === $templateId) return false;
$tpl = \think\facade\Db::name(self::table('seat_templates'))->where('id', $tid)->find();
return !empty($tpl);
});
$validConfigs = array_values($validConfigs);
if (!empty($validConfigs)) {
\think\facade\Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
} else {
\think\facade\Db::name('Goods')->where('id', $goodsId)->update(['vr_goods_config' => '']);
}
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
}
// 解码 seat_map JSON存储时是 JSON 字符串)
if (!empty($seatTemplate['seat_map'])) {
$decoded = json_decode($seatTemplate['seat_map'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$seatTemplate['seat_map'] = $decoded;
}
}
// ========== 构建规格类型列表4维场馆、分区、座位号、场次==========
// 从 GoodsSpecType 读取所有维度定义
$specTypeList = [];
$specTypes = \think\facade\Db::name('GoodsSpecType')
->where('goods_id', $goodsId)
->order('id', 'asc')
->select()
->toArray();
foreach ($specTypes as $type) {
$dimName = $type['name'] ?? '';
$values = json_decode($type['value'] ?? '[]', true);
$options = [];
foreach ($values as $v) {
if (isset($v['name'])) {
$options[] = $v['name'];
}
}
if (!empty($dimName) && !empty($options)) {
$specTypeList[$dimName] = [
'name' => $dimName,
'options' => $options,
];
}
}
// ========== 构建 seatSpecMap ==========
$seatSpecMap = self::buildSeatSpecMap($goodsId, $seatTemplate);
// ========== 构建场次列表goods_spec_data==========
$sessions = $config['sessions'] ?? [];
$goodsSpecData = [];
foreach ($sessions as $session) {
$start = $session['start'] ?? '';
$end = $session['end'] ?? '';
$timeRange = $start && $end ? "{$start}-{$end}" : ($start ?: $end);
// 查找该场次对应的最低价格(从 seatSpecMap 中获取)
$sessionPrice = null;
foreach ($seatSpecMap as $seatKey => $info) {
foreach ($info['spec'] as $specItem) {
$specType = $specItem['type'] ?? '';
$specValue = $specItem['value'] ?? '';
if ($specType === '$vr-场次' && $specValue === $timeRange) {
if ($sessionPrice === null || $info['price'] < $sessionPrice) {
$sessionPrice = $info['price'];
}
break 2;
}
}
}
$goodsSpecData[] = [
'spec_id' => 0,
'spec_name' => $timeRange,
'price' => $sessionPrice ?? floatval($goods['price'] ?? 0),
'start' => $start,
'end' => $end,
];
}
// 如果没有从配置读取到场次,尝试从 seatSpecMap 提取唯一场次
if (empty($goodsSpecData)) {
$sessionMap = [];
foreach ($seatSpecMap as $info) {
foreach ($info['spec'] as $specItem) {
$specType = $specItem['type'] ?? '';
if ($specType === '$vr-场次') {
$sessionMap[$specItem['value'] ?? ''] = $specItem['value'] ?? '';
}
}
}
foreach ($sessionMap as $timeRange) {
$goodsSpecData[] = [
'spec_id' => 0,
'spec_name' => $timeRange,
'price' => floatval($goods['price'] ?? 0),
'start' => '',
'end' => '',
];
}
}
return [
'vr_seat_template' => $seatTemplate ?: null,
'goods_spec_data' => $goodsSpecData,
'seatSpecMap' => $seatSpecMap,
'specTypeList' => $specTypeList, // 4维规格类型列表
'goods_config' => $config,
];
}
/**
* 构建座位规格映射表seatSpecMap
*
* @param int $goodsId
* @param array $seatTemplate
* @return array seatSpecMap
*/
private static function buildSeatSpecMap(int $goodsId, array $seatTemplate): array
{
$seatSpecMap = [];
// 1. 查询当前商品所有 GoodsSpecBase含 extends.seat_key
$specs = \think\facade\Db::name('GoodsSpecBase')
->where('goods_id', $goodsId)
->where('inventory', '>', 0)
->select()
->toArray();
if (empty($specs)) {
return $seatSpecMap;
}
// 2. 查询 GoodsSpecType 获取维度映射name => index
$specTypes = \think\facade\Db::name('GoodsSpecType')
->where('goods_id', $goodsId)
->order('id', 'asc')
->select()
->toArray();
// 构建 name => index 映射
$dimIndexByName = [];
$dimValuesByName = []; // name => [value1, value2, ...]
foreach ($specTypes as $idx => $type) {
$dimName = $type['name'] ?? '';
if (!empty($dimName)) {
$dimIndexByName[$dimName] = $idx;
// 解析 value JSON 数组
$values = json_decode($type['value'] ?? '[]', true);
$dimValuesByName[$dimName] = [];
foreach ($values as $v) {
if (isset($v['name'])) {
$dimValuesByName[$dimName][] = $v['name'];
}
}
}
}
// 3. 查询每个 spec_base_id 对应的 GoodsSpecValue
$specBaseIds = array_column($specs, 'id');
$specValues = \think\facade\Db::name('GoodsSpecValue')
->whereIn('goods_spec_base_id', $specBaseIds)
->select()
->toArray();
// 4. 按 spec_base_id 分组,直接使用 GoodsSpecValue.name 字段确定维度名(更可靠)
$specByBaseId = [];
foreach ($specValues as $sv) {
$baseId = $sv['goods_spec_base_id'];
$value = $sv['value'] ?? '';
// 通过值匹配找到对应的维度名(依赖 GoodsSpecType.value JSON 中的 name
$dimName = '';
foreach ($dimValuesByName as $name => $values) {
if (in_array($value, $values)) {
$dimName = $name;
break;
}
}
if (!isset($specByBaseId[$baseId])) {
$specByBaseId[$baseId] = [];
}
$specByBaseId[$baseId][] = [
'type' => $dimName,
'value' => $value,
];
}
// 4. 解析座位模板中的 room 信息(用于提取 rowLabel, colNum 等)
$rooms = $seatTemplate['seat_map']['rooms'] ?? [];
$roomSeatInfo = []; // roomId => [rowLabel_colNum => ['rowLabel' => 'A', 'colNum' => 3, 'section' => [...]]]
foreach ($rooms as $rIdx => $room) {
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
$sections = $room['sections'] ?? [];
$map = $room['map'] ?? [];
$seatsData = $room['seats'] ?? [];
foreach ($map as $rowIndex => $rowStr) {
$rowLabel = chr(65 + $rowIndex);
$chars = mb_str_split($rowStr);
foreach ($chars as $colIndex => $char) {
if ($char === '_' || $char === '-' || !isset($seatsData[$char])) {
continue;
}
$colNum = $colIndex + 1;
// 查找分区信息
$sectionInfo = null;
foreach ($sections as $sec) {
if (($sec['char'] ?? '') === $char) {
$sectionInfo = $sec;
break;
}
}
$roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] = [
'rowLabel' => $rowLabel,
'colNum' => $colNum,
'section' => $sectionInfo,
'char' => $char,
];
}
}
}
// 5. 构建 seatSpecMapseat_key → 完整规格
foreach ($specs as $spec) {
$extends = json_decode($spec['extends'] ?? '{}', true);
$seatKey = $extends['seat_key'] ?? '';
if (empty($seatKey)) continue;
// 解析 seatKey 格式roomId_rowLabel_colNum
$parts = explode('_', $seatKey);
if (count($parts) < 3) continue;
$roomId = $parts[0];
$rowLabel = $parts[1];
$colNum = intval($parts[2]);
// 提取各维度值
$venueName = '';
$sectionName = '';
$seatName = '';
$sessionName = '';
$roomName = ''; // ← 演播室第3维
foreach ($specByBaseId[$spec['id']] ?? [] as $specItem) {
$specType = $specItem['type'] ?? '';
$specVal = $specItem['value'] ?? '';
switch ($specType) {
case '$vr-场次':
$sessionName = $specVal;
break;
case '$vr-场馆':
$venueName = $specVal;
break;
case '$vr-演播室':
$roomName = $specVal;
break;
case '$vr-分区':
$sectionName = $specVal;
break;
case '$vr-座位号':
$seatName = $specVal;
break;
}
}
// 获取座位元信息
$seatMeta = $roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] ?? [
'rowLabel' => $rowLabel,
'colNum' => $colNum,
'section' => null,
'char' => '',
];
$seatSpecMap[$seatKey] = [
'spec_base_id' => intval($spec['id']),
'price' => floatval($spec['price']),
'inventory' => intval($spec['inventory']),
'spec' => $specByBaseId[$spec['id']] ?? [],
'rowLabel' => $seatMeta['rowLabel'],
'colNum' => $seatMeta['colNum'],
'roomId' => $roomId,
'roomName' => $roomName, // ← 演播室名第3维
'section' => $seatMeta['section'],
'venueName' => $venueName,
'sectionName' => $sectionName,
'seatName' => $seatName,
'sessionName' => $sessionName,
];
}
return $seatSpecMap;
}
}