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

20 hours ago
<?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;
}
}