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

640 lines
25 KiB
PHP
Raw Normal View History

<?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 规格维度名(顺序固定)
*/
const SPEC_DIMS = ['$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-场次' => [],
];
// 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' => [
$val_venue,
$val_section,
$val_seat,
$sessionStr,
],
];
// 收集唯一维度值(保持首次出现顺序)
if (!in_array($val_venue, $dimUniqueValues['$vr-场馆'])) {
$dimUniqueValues['$vr-场馆'][] = $val_venue;
}
if (!in_array($val_section, $dimUniqueValues['$vr-分区'])) {
$dimUniqueValues['$vr-分区'][] = $val_section;
}
if (!in_array($val_seat, $dimUniqueValues['$vr-座位号'])) {
$dimUniqueValues['$vr-座位号'][] = $val_seat;
}
if (!in_array($sessionStr, $dimUniqueValues['$vr-场次'])) {
$dimUniqueValues['$vr-场次'][] = $sessionStr;
}
}
}
}
}
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})");
}
// 4 条 GoodsSpecValue每条对应一个维度按 SPEC_DIMS 顺序)
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;
}
}
// ========== 新增:构建 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_id前端用 seatSpecMap
'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,
'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. 查询每个 spec_base_id 对应的 GoodsSpecValue
$specBaseIds = array_column($specs, 'id');
$specValues = \think\facade\Db::name('GoodsSpecValue')
->whereIn('goods_spec_base_id', $specBaseIds)
->order('id', 'asc') // 按插入顺序,确保维度顺序一致
->select()
->toArray();
// 3. 按 spec_base_id 分组,按 SPEC_DIMS 顺序映射维度
// 不依赖 type 字段,而是按插入顺序匹配 SPEC_DIMS
$specByBaseId = [];
foreach ($specValues as $sv) {
$baseId = $sv['goods_spec_base_id'];
if (!isset($specByBaseId[$baseId])) {
$specByBaseId[$baseId] = [];
}
$idx = count($specByBaseId[$baseId]); // 当前维度索引
$dimName = self::SPEC_DIMS[$idx] ?? ('dim_' . $idx);
$specByBaseId[$baseId][] = [
'type' => $dimName, // 使用 SPEC_DIMS 顺序推断维度名
'value' => $sv['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]);
// 提取场馆名(从 $vr-场馆 维度)
$venueName = '';
$sectionName = '';
$seatName = '';
$sessionName = '';
foreach ($specByBaseId[$spec['id']] ?? [] as $specItem) {
$specType = $specItem['type'] ?? '';
$specVal = $specItem['value'] ?? '';
switch ($specType) {
case '$vr-场馆':
$venueName = $specVal;
break;
case '$vr-分区':
$sectionName = $specVal;
break;
case '$vr-座位号':
$seatName = $specVal;
break;
case '$vr-场次':
$sessionName = $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,
'section' => $seatMeta['section'],
'venueName' => $venueName,
'sectionName' => $sectionName,
'seatName' => $seatName,
'sessionName' => $sessionName,
];
}
return $seatSpecMap;
}
}