You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

383 lines
14 KiB

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