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

466 lines
18 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) {
$seatId = $roomId . '_' . $rowLabel . '_' . $col . '_' . md5($sessionStr);
$seatsToInsert[$seatId] = [
'price' => $seatPrice,
'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,
]);
if (!$baseId) {
throw new \Exception("GoodsSpecBase 写入失败 (seat: {$seatId})");
}
// 4 条 GoodsSpecValue每条对应一个维度
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' => [...]]
*/
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' => [], 'goods_config' => null];
}
// 取第一个配置块(单模板模式)
$config = $vrGoodsConfig[0];
$templateId = intval($config['template_id'] ?? 0);
if ($templateId <= 0) {
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
}
// 读取座位模板(包含 seat_map 和 spec_base_id_map
$seatTemplate = \think\facade\Db::name(self::table('seat_templates'))
->where('id', $templateId)
->find();
// 模板不存在时(硬删除场景):
// - 将 template_id 置 null让前端选单显示为空
// - 同时清掉 template_snapshot下次保存时整块 config 干净地失效
if (empty($seatTemplate)) {
$config['template_id'] = null;
$config['template_snapshot'] = null;
\think\facade\Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode([$config], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
return [
'vr_seat_template' => null,
'goods_spec_data' => [],
'goods_config' => $config,
];
}
// 解码 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;
}
}
// 解码 spec_base_id_map JSON
if (!empty($seatTemplate['spec_base_id_map'])) {
$decoded = json_decode($seatTemplate['spec_base_id_map'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$seatTemplate['spec_base_id_map'] = $decoded;
}
}
// 构建场次列表goods_spec_data
$sessions = $config['sessions'] ?? [];
$goodsSpecData = [];
foreach ($sessions as $session) {
$start = $session['start'] ?? '';
$end = $session['end'] ?? '';
$timeRange = $start && $end ? "{$start}-{$end}" : ($start ?: $end);
// 查找该场次对应的 spec_base_id
$specValue = \think\facade\Db::name('goods_spec_value')
->alias('sv')
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
->where('sv.goods_id', $goodsId)
->where('sv.value', $timeRange)
->where('sb.price', '>', 0)
->find();
$goodsSpecData[] = [
'spec_id' => $specValue['goods_spec_base_id'] ?? 0,
'spec_name' => $timeRange,
'price' => $specValue['price'] ?? floatval($goods['price'] ?? 0),
];
}
// 如果没有从配置读取到场次,尝试从数据库直接读取场次类规格值
if (empty($goodsSpecData)) {
$sessionValues = \think\facade\Db::name('goods_spec_value')
->alias('sv')
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
->field('sv.goods_spec_base_id as spec_id, sv.value as spec_name, sb.price')
->where('sv.goods_id', $goodsId)
->where('sb.price', '>', 0)
->order('sb.id asc')
->select()->toArray();
foreach ($sessionValues as $sv) {
if (preg_match('/^\d{2}:\d{2}-\d{2}:\d{2}$/', $sv['spec_name'])) {
$goodsSpecData[] = [
'spec_id' => $sv['spec_id'],
'spec_name' => $sv['spec_name'],
'price' => floatval($sv['price']),
];
}
}
}
return [
'vr_seat_template' => $seatTemplate ?: null,
'goods_spec_data' => $goodsSpecData,
'goods_config' => $config,
];
}
}