|
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
|
|
use App\Models\DictItem;
|
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
|
use App\Models\Venue;
|
|
|
|
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
|
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
|
|
|
|
|
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
|
|
|
|
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
|
|
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
|
|
|
|
|
|
|
|
|
|
class VenueImportService
|
|
|
|
|
|
{
|
|
|
|
|
|
/** @var array<string, string> 中文主题名 => item_value */
|
|
|
|
|
|
public const THEME_LABEL_TO_VALUE = [
|
|
|
|
|
|
'自然生态' => 'theme_nature_ecology',
|
|
|
|
|
|
'科技产业' => 'theme_tech_industry',
|
|
|
|
|
|
'苏工苏艺' => 'theme_su_craft',
|
|
|
|
|
|
'生命健康' => 'theme_life_health',
|
|
|
|
|
|
'弘扬科学家精神' => 'theme_scientist_spirit',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
private const TICKET_TYPE_LABEL = [
|
|
|
|
|
|
'免费' => 'free',
|
|
|
|
|
|
'收费' => 'paid',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
private const APPOINTMENT_LABEL = [
|
|
|
|
|
|
'仅团队' => 'team_only',
|
|
|
|
|
|
'个人团队均可' => 'individual_and_team',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
private const OPEN_MODE_LABEL = [
|
|
|
|
|
|
'常态化全时开放' => 'fulltime',
|
|
|
|
|
|
'常态化定时开放' => 'scheduled',
|
|
|
|
|
|
'预约开放' => 'appointment',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
public function buildTemplateSpreadsheet(): Spreadsheet
|
|
|
|
|
|
{
|
|
|
|
|
|
$spreadsheet = new Spreadsheet();
|
|
|
|
|
|
$sheet = $spreadsheet->getActiveSheet();
|
|
|
|
|
|
$sheet->setTitle('导入数据');
|
|
|
|
|
|
|
|
|
|
|
|
// 列顺序与后台「新增/编辑场馆」表单一致(不含封面、轮播、地图选点)
|
|
|
|
|
|
$headers = [
|
|
|
|
|
|
'场馆名称*',
|
|
|
|
|
|
'主题*',
|
|
|
|
|
|
'行政区*',
|
|
|
|
|
|
'预约类型',
|
|
|
|
|
|
'门票类型',
|
|
|
|
|
|
'开放模式',
|
|
|
|
|
|
'所属单位',
|
|
|
|
|
|
'预约方式',
|
|
|
|
|
|
'参观形式',
|
|
|
|
|
|
'开放时间',
|
|
|
|
|
|
'咨询预约时间',
|
|
|
|
|
|
'咨询预约联系电话',
|
|
|
|
|
|
'排序',
|
|
|
|
|
|
'启用',
|
|
|
|
|
|
'场馆地址',
|
|
|
|
|
|
'经度',
|
|
|
|
|
|
'纬度',
|
|
|
|
|
|
'门票说明',
|
|
|
|
|
|
'场馆详情',
|
|
|
|
|
|
'预约须知',
|
|
|
|
|
|
];
|
|
|
|
|
|
foreach (range(1, count($headers)) as $i) {
|
|
|
|
|
|
$col = Coordinate::stringFromColumnIndex($i);
|
|
|
|
|
|
$sheet->setCellValue($col . '1', $headers[$i - 1]);
|
|
|
|
|
|
}
|
|
|
|
|
|
$sheet->setCellValue(
|
|
|
|
|
|
'A2',
|
|
|
|
|
|
'说明:主题多个请用英文逗号分隔;启用填「是」或「否」;经纬度可留空,导入后请在列表编辑中通过「地图选点」填写;经度与纬度须成对填写或同时留空。'
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
$opt = $spreadsheet->createSheet();
|
|
|
|
|
|
$opt->setTitle('选项');
|
|
|
|
|
|
|
|
|
|
|
|
$districts = DictItem::query()
|
|
|
|
|
|
->where('dict_type', 'district')
|
|
|
|
|
|
->where('is_active', true)
|
|
|
|
|
|
->orderBy('sort')
|
|
|
|
|
|
->orderBy('id')
|
|
|
|
|
|
->pluck('item_label')
|
|
|
|
|
|
->all();
|
|
|
|
|
|
$opt->setCellValue('A1', '行政区');
|
|
|
|
|
|
$r = 2;
|
|
|
|
|
|
foreach ($districts as $d) {
|
|
|
|
|
|
$opt->setCellValue('A' . $r, $d);
|
|
|
|
|
|
$r++;
|
|
|
|
|
|
}
|
|
|
|
|
|
$lastD = max(2, $r - 1);
|
|
|
|
|
|
|
|
|
|
|
|
$opt->setCellValue('B1', '主题');
|
|
|
|
|
|
$tr = 2;
|
|
|
|
|
|
foreach (array_keys(self::THEME_LABEL_TO_VALUE) as $label) {
|
|
|
|
|
|
$opt->setCellValue('B' . $tr, $label);
|
|
|
|
|
|
$tr++;
|
|
|
|
|
|
}
|
|
|
|
|
|
$lastT = max(2, $tr - 1);
|
|
|
|
|
|
|
|
|
|
|
|
$opt->setCellValue('C1', '门票类型');
|
|
|
|
|
|
$opt->setCellValue('C2', '免费');
|
|
|
|
|
|
$opt->setCellValue('C3', '收费');
|
|
|
|
|
|
|
|
|
|
|
|
$opt->setCellValue('D1', '预约类型');
|
|
|
|
|
|
$opt->setCellValue('D2', '仅团队');
|
|
|
|
|
|
$opt->setCellValue('D3', '个人团队均可');
|
|
|
|
|
|
|
|
|
|
|
|
$opt->setCellValue('E1', '开放模式');
|
|
|
|
|
|
$opt->setCellValue('E2', '常态化全时开放');
|
|
|
|
|
|
$opt->setCellValue('E3', '常态化定时开放');
|
|
|
|
|
|
$opt->setCellValue('E4', '预约开放');
|
|
|
|
|
|
|
|
|
|
|
|
// B=主题 C=行政区 D=预约类型 E=门票类型 F=开放模式(与表头列位一致)
|
|
|
|
|
|
$this->applyListValidation($sheet, 'B2:B5000', '=选项!$B$2:$B$' . $lastT);
|
|
|
|
|
|
$this->applyListValidation($sheet, 'C2:C5000', '=选项!$A$2:$A$' . $lastD);
|
|
|
|
|
|
$this->applyListValidation($sheet, 'D2:D5000', '=选项!$D$2:$D$3');
|
|
|
|
|
|
$this->applyListValidation($sheet, 'E2:E5000', '=选项!$C$2:$C$3');
|
|
|
|
|
|
$this->applyListValidation($sheet, 'F2:F5000', '=选项!$E$2:$E$4');
|
|
|
|
|
|
|
|
|
|
|
|
$spreadsheet->setActiveSheetIndex(0);
|
|
|
|
|
|
|
|
|
|
|
|
return $spreadsheet;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function applyListValidation(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, string $range, string $formula): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$validation = new DataValidation();
|
|
|
|
|
|
$validation->setType(DataValidation::TYPE_LIST);
|
|
|
|
|
|
$validation->setErrorStyle(DataValidation::STYLE_INFORMATION);
|
|
|
|
|
|
$validation->setAllowBlank(true);
|
|
|
|
|
|
$validation->setShowInputMessage(true);
|
|
|
|
|
|
$validation->setShowErrorMessage(true);
|
|
|
|
|
|
$validation->setShowDropDown(true);
|
|
|
|
|
|
$validation->setFormula1($formula);
|
|
|
|
|
|
$sheet->setDataValidation($range, $validation);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @return array{rows: array<int, array<string, mixed>>, summary: array{total: int, valid: int, invalid: int}}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function previewFromPath(string $path): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$spreadsheet = IOFactory::load($path);
|
|
|
|
|
|
$sheet = $spreadsheet->getSheet(0);
|
|
|
|
|
|
$highestRow = (int) $sheet->getHighestRow();
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
$valid = 0;
|
|
|
|
|
|
$invalid = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for ($row = 2; $row <= $highestRow; $row++) {
|
|
|
|
|
|
$cells = [];
|
|
|
|
|
|
for ($col = 1; $col <= 20; $col++) {
|
|
|
|
|
|
$colLetter = Coordinate::stringFromColumnIndex($col);
|
|
|
|
|
|
$cells[] = trim((string) $sheet->getCell($colLetter . $row)->getValue());
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($this->rowIsEmpty($cells)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$parsed = $this->normalizeRowFromCells($cells);
|
|
|
|
|
|
$errors = $this->validatePayload($parsed);
|
|
|
|
|
|
$ok = $errors === [];
|
|
|
|
|
|
if ($ok) {
|
|
|
|
|
|
$valid++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$invalid++;
|
|
|
|
|
|
}
|
|
|
|
|
|
$out[] = [
|
|
|
|
|
|
'row_index' => $row,
|
|
|
|
|
|
'ok' => $ok,
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'payload' => $parsed,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'rows' => $out,
|
|
|
|
|
|
'summary' => [
|
|
|
|
|
|
'total' => count($out),
|
|
|
|
|
|
'valid' => $valid,
|
|
|
|
|
|
'invalid' => $invalid,
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param array<int, string> $cells
|
|
|
|
|
|
* @return array<string, mixed>
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function normalizeRowFromCells(array $cells): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$g = fn (int $i) => isset($cells[$i]) ? trim($cells[$i]) : '';
|
|
|
|
|
|
|
|
|
|
|
|
$themeRaw = str_replace(',', ',', $g(1));
|
|
|
|
|
|
$themeParts = array_filter(array_map('trim', explode(',', $themeRaw)), fn ($s) => $s !== '');
|
|
|
|
|
|
$venueTypes = [];
|
|
|
|
|
|
foreach ($themeParts as $part) {
|
|
|
|
|
|
if (isset(self::THEME_LABEL_TO_VALUE[$part])) {
|
|
|
|
|
|
$venueTypes[] = self::THEME_LABEL_TO_VALUE[$part];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 与表头:3 预约类型、4 门票类型、5 开放模式 一致(从 0 起算列索引)
|
|
|
|
|
|
$rawApp = $g(3);
|
|
|
|
|
|
$rawTicket = $g(4);
|
|
|
|
|
|
$rawOpen = $g(5);
|
|
|
|
|
|
$ticketType = $rawTicket === '' ? null : (self::TICKET_TYPE_LABEL[$rawTicket] ?? null);
|
|
|
|
|
|
$appointmentType = $rawApp === '' ? null : (self::APPOINTMENT_LABEL[$rawApp] ?? null);
|
|
|
|
|
|
$openMode = $rawOpen === '' ? null : (self::OPEN_MODE_LABEL[$rawOpen] ?? null);
|
|
|
|
|
|
|
|
|
|
|
|
$lng = $this->parseFloat($g(15));
|
|
|
|
|
|
$lat = $this->parseFloat($g(16));
|
|
|
|
|
|
$sort = $g(12) === '' ? 0 : (int) $g(12);
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'name' => $g(0),
|
|
|
|
|
|
'venue_types' => $venueTypes,
|
|
|
|
|
|
'district' => $g(2),
|
|
|
|
|
|
'appointment_type' => $appointmentType,
|
|
|
|
|
|
'ticket_type' => $ticketType,
|
|
|
|
|
|
'open_mode' => $openMode,
|
|
|
|
|
|
'unit_name' => $g(6) === '' ? null : $g(6),
|
|
|
|
|
|
'booking_method' => $g(7) === '' ? null : $g(7),
|
|
|
|
|
|
'visit_form' => $g(8) === '' ? null : $g(8),
|
|
|
|
|
|
'open_time' => $g(9) === '' ? null : $g(9),
|
|
|
|
|
|
'consultation_hours' => $g(10) === '' ? null : $g(10),
|
|
|
|
|
|
'contact_phone' => $g(11) === '' ? null : $g(11),
|
|
|
|
|
|
'sort' => $sort,
|
|
|
|
|
|
'is_active' => $this->parseBool($g(13)),
|
|
|
|
|
|
'address' => $g(14) === '' ? null : $g(14),
|
|
|
|
|
|
'lng' => $lng,
|
|
|
|
|
|
'lat' => $lat,
|
|
|
|
|
|
'ticket_content' => $g(17) === '' ? null : $g(17),
|
|
|
|
|
|
'detail_html' => $g(18) === '' ? null : $g(18),
|
|
|
|
|
|
'reservation_notice' => $g(19) === '' ? null : $g(19),
|
|
|
|
|
|
'_raw_appointment_label' => $rawApp,
|
|
|
|
|
|
'_raw_open_mode_label' => $rawOpen,
|
|
|
|
|
|
'_raw_ticket_type_label' => $rawTicket,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
|
* @return list<string>
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function validatePayload(array $data): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
if (trim((string) ($data['name'] ?? '')) === '') {
|
|
|
|
|
|
$errors[] = '场馆名称不能为空';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (empty($data['venue_types']) || ! is_array($data['venue_types'])) {
|
|
|
|
|
|
$errors[] = '主题无效或为空(须为模板「选项」表中的主题名称,多个用英文逗号分隔)';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (trim((string) ($data['district'] ?? '')) === '') {
|
|
|
|
|
|
$errors[] = '行政区不能为空';
|
|
|
|
|
|
}
|
|
|
|
|
|
$lng = $data['lng'] ?? null;
|
|
|
|
|
|
$lat = $data['lat'] ?? null;
|
|
|
|
|
|
$hasPartialCoord = ($lng === null) !== ($lat === null);
|
|
|
|
|
|
if ($hasPartialCoord) {
|
|
|
|
|
|
$errors[] = '经度与纬度须同时填写或同时留空(留空时请在列表编辑中通过地图选点补充)';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (($data['_raw_ticket_type_label'] ?? '') !== '' && ($data['ticket_type'] ?? null) === null) {
|
|
|
|
|
|
$errors[] = '门票类型须为「免费」或「收费」或留空';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (($data['_raw_appointment_label'] ?? '') !== '' && ($data['appointment_type'] ?? null) === null) {
|
|
|
|
|
|
$errors[] = '预约类型须为「仅团队」「个人团队均可」或留空';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (($data['_raw_open_mode_label'] ?? '') !== '' && ($data['open_mode'] ?? null) === null) {
|
|
|
|
|
|
$errors[] = '开放模式须为三种选项之一或留空';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$v = Validator::make($this->stripInternalKeys($data), [
|
|
|
|
|
|
'name' => ['required', 'string', 'max:120'],
|
|
|
|
|
|
'venue_types' => ['required', 'array', 'min:1'],
|
|
|
|
|
|
'venue_types.*' => ['string', 'max:80'],
|
|
|
|
|
|
'unit_name' => ['nullable', 'string', 'max:120'],
|
|
|
|
|
|
'district' => ['required', 'string', 'max:80'],
|
|
|
|
|
|
'ticket_type' => ['nullable', 'string', 'max:80'],
|
|
|
|
|
|
'appointment_type' => ['nullable', 'string', 'max:40'],
|
|
|
|
|
|
'open_mode' => ['nullable', 'string', 'max:40'],
|
|
|
|
|
|
'open_time' => ['nullable', 'string', 'max:120'],
|
|
|
|
|
|
'ticket_content' => ['nullable', 'string'],
|
|
|
|
|
|
'booking_method' => ['nullable', 'string'],
|
|
|
|
|
|
'visit_form' => ['nullable', 'string'],
|
|
|
|
|
|
'consultation_hours' => ['nullable', 'string'],
|
|
|
|
|
|
'detail_html' => ['nullable', 'string'],
|
|
|
|
|
|
'reservation_notice' => ['nullable', 'string'],
|
|
|
|
|
|
'address' => ['nullable', 'string', 'max:255'],
|
|
|
|
|
|
'contact_phone' => ['nullable', 'string', 'max:20'],
|
|
|
|
|
|
'lat' => ['nullable', 'numeric'],
|
|
|
|
|
|
'lng' => ['nullable', 'numeric'],
|
|
|
|
|
|
'sort' => ['nullable', 'integer', 'min:0'],
|
|
|
|
|
|
'is_active' => ['boolean'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
if ($v->fails()) {
|
|
|
|
|
|
foreach ($v->errors()->all() as $msg) {
|
|
|
|
|
|
$errors[] = $msg;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return array_values(array_unique($errors));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
|
* @return array<string, mixed>
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function stripInternalKeys(array $data): array
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (array_keys($data) as $k) {
|
|
|
|
|
|
if (is_string($k) && str_starts_with($k, '_')) {
|
|
|
|
|
|
unset($data[$k]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param array<string, mixed> $payload
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function createVenueFromPayload(array $payload, User $user): Venue
|
|
|
|
|
|
{
|
|
|
|
|
|
$clean = $this->stripInternalKeys($payload);
|
|
|
|
|
|
$auditStatus = $user->isSuperAdmin() ? Venue::AUDIT_APPROVED : Venue::AUDIT_PENDING;
|
|
|
|
|
|
$venue = Venue::create($clean + [
|
|
|
|
|
|
'cover_image' => null,
|
|
|
|
|
|
'gallery_media' => [],
|
|
|
|
|
|
'detail_html' => $clean['detail_html'] ?? null,
|
|
|
|
|
|
'live_people_count' => 0,
|
|
|
|
|
|
'audit_status' => $auditStatus,
|
|
|
|
|
|
'audit_remark' => null,
|
|
|
|
|
|
'last_approved_snapshot' => null,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
if (! $user->isSuperAdmin()) {
|
|
|
|
|
|
$user->venues()->syncWithoutDetaching([$venue->id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $venue->fresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function rowIsEmpty(array $cells): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach ($cells as $c) {
|
|
|
|
|
|
if (trim((string) $c) !== '') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function parseFloat(?string $s): ?float
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($s === null || trim($s) === '') {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$n = (float) str_replace([',', ' '], '', $s);
|
|
|
|
|
|
|
|
|
|
|
|
return is_finite($n) ? $n : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function parseBool(string $s): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
$t = mb_strtolower(trim($s));
|
|
|
|
|
|
if ($t === '' || in_array($t, ['1', '是', 'y', 'yes', 'true', '启用'], true)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (in_array($t, ['0', '否', 'n', 'no', 'false', '禁用'], true)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|