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.

481 lines
18 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\Support;
use App\Models\DictItem;
use App\Models\StudyTour;
use App\Models\Venue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class StudyTourPayload
{
public static function validationRules(bool $creating = false): array
{
$seasonRule = Rule::exists('dict_items', 'item_value')->where(
fn ($q) => $q->where('dict_type', 'study_tour_season')->where('is_active', true)
);
$required = $creating ? 'required' : 'sometimes';
return [
'name' => [$required, 'string', 'max:120'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:50'],
'venue_items' => [$required, 'array', 'min:1'],
'venue_items.*.type' => ['required', 'in:system,custom'],
'venue_items.*.venue_id' => ['required_if:venue_items.*.type,system', 'nullable', 'integer', 'exists:venues,id'],
'venue_items.*.name' => ['required_if:venue_items.*.type,custom', 'nullable', 'string', 'max:120'],
'venue_items.*.address' => ['nullable', 'string', 'max:255'],
'venue_ids' => ['nullable', 'array'],
'venue_ids.*' => ['integer', 'exists:venues,id'],
'org_name' => ['nullable', 'string', 'max:200'],
'seasons' => ['nullable', 'array'],
'seasons.*' => ['string', 'max:50', $seasonRule],
'suitable_count' => ['nullable', 'string', 'max:50'],
'suitable_audience' => ['nullable', 'string', 'max:200'],
'duration' => ['nullable', 'string', 'max:100'],
'contact_person' => ['nullable', 'string', 'max:50'],
'contact_phones' => ['nullable', 'string', 'max:200'],
'cover_image' => ['nullable', 'string', 'max:255'],
'gallery_media' => ['nullable', 'array'],
'gallery_media.*.type' => ['required_with:gallery_media', 'in:image,video'],
'gallery_media.*.url' => ['required_with:gallery_media', 'string', 'max:255'],
'intro_html' => ['nullable', 'string'],
'route_plans' => ['nullable', 'array'],
'route_plans.*.date_label' => ['nullable', 'string', 'max:120'],
'route_plans.*.items' => ['nullable', 'array'],
'route_plans.*.items.*.time' => ['nullable', 'string', 'max:80'],
'route_plans.*.items.*.activity' => ['nullable', 'string', 'max:500'],
'route_plans.*.items.*.location' => ['nullable', 'string', 'max:255'],
'courses' => ['nullable', 'array'],
'courses.*.sort' => ['nullable', 'integer', 'min:0'],
'courses.*.name' => ['nullable', 'string', 'max:200'],
'courses.*.content' => ['nullable', 'string', 'max:2000'],
'fee_html' => ['nullable', 'string'],
'implementation_html' => ['nullable', 'string'],
'sort' => ['nullable', 'integer', 'min:0'],
'is_on_shelf' => ['sometimes', 'boolean'],
];
}
public static function compactText(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
}
$text = preg_replace('/[\x{00A0}\x{2002}\x{2003}\x{3000}]/u', ' ', $text) ?? $text;
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return trim($text);
}
public static function compactMultilineText(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
}
$lines = preg_split('/\R/u', $text) ?: [];
$lines = array_values(array_filter(
array_map(fn ($line) => self::compactText((string) $line), $lines),
fn ($line) => $line !== ''
));
return implode("\n", $lines);
}
public static function normalizeIncoming(array $data): array
{
foreach (['name', 'org_name', 'suitable_count', 'suitable_audience', 'duration', 'contact_person'] as $key) {
if (array_key_exists($key, $data) && is_string($data[$key])) {
$data[$key] = self::compactText($data[$key]);
}
}
if (array_key_exists('tags', $data)) {
$data['tags'] = array_values($data['tags'] ?? []);
}
if (array_key_exists('seasons', $data)) {
$data['seasons'] = array_values($data['seasons'] ?? []);
}
if (array_key_exists('venue_items', $data)) {
$data['venue_items'] = self::normalizeVenueItems($data['venue_items'] ?? []);
$data['venue_ids'] = self::venueIdsFromItems($data['venue_items']);
} elseif (array_key_exists('venue_ids', $data)) {
$ids = array_values($data['venue_ids'] ?? []);
$data['venue_ids'] = $ids;
$data['venue_items'] = self::venueItemsFromIds($ids);
}
if (array_key_exists('route_plans', $data)) {
$data['route_plans'] = self::normalizeRoutePlans($data['route_plans'] ?? []);
}
if (array_key_exists('courses', $data)) {
$data['courses'] = self::normalizeCourses($data['courses'] ?? []);
}
if (array_key_exists('contact_phones', $data)) {
$data['contact_phones'] = self::normalizeContactPhones((string) ($data['contact_phones'] ?? ''));
}
if (array_key_exists('gallery_media', $data)) {
$data['gallery_media'] = array_values($data['gallery_media'] ?? []);
}
return $data;
}
public static function venueItemsForRecord(StudyTour $row): array
{
$items = $row->venue_items;
if (is_array($items) && $items !== []) {
return self::normalizeVenueItems($items);
}
return self::venueItemsFromIds($row->venue_ids ?? []);
}
public static function normalizeVenueItems(array $items): array
{
$out = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$type = (string) ($item['type'] ?? '');
if ($type === 'system') {
$venueId = (int) ($item['venue_id'] ?? 0);
if ($venueId > 0) {
$out[] = ['type' => 'system', 'venue_id' => $venueId];
}
continue;
}
if ($type === 'custom') {
$name = self::compactText((string) ($item['name'] ?? ''));
if ($name === '') {
continue;
}
$row = ['type' => 'custom', 'name' => $name];
$address = self::compactText((string) ($item['address'] ?? ''));
if ($address !== '') {
$row['address'] = $address;
}
$out[] = $row;
}
}
return $out;
}
public static function venueItemsFromIds(array $ids): array
{
return collect($ids)
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->unique()
->values()
->map(fn ($id) => ['type' => 'system', 'venue_id' => $id])
->all();
}
public static function venueIdsFromItems(array $items): array
{
return collect(self::normalizeVenueItems($items))
->filter(fn ($item) => ($item['type'] ?? '') === 'system')
->map(fn ($item) => (int) ($item['venue_id'] ?? 0))
->filter(fn ($id) => $id > 0)
->values()
->all();
}
public static function normalizeRoutePlans(array $plans): array
{
$out = [];
foreach ($plans as $plan) {
if (! is_array($plan)) {
continue;
}
$items = [];
foreach ($plan['items'] ?? [] as $item) {
if (! is_array($item)) {
continue;
}
$items[] = [
'time' => self::compactText((string) ($item['time'] ?? '')),
'activity' => self::compactText((string) ($item['activity'] ?? '')),
'location' => self::compactText((string) ($item['location'] ?? '')),
];
}
$out[] = [
'date_label' => self::compactText((string) ($plan['date_label'] ?? '')),
'items' => $items,
];
}
return $out;
}
public static function normalizeCourses(array $courses): array
{
$out = [];
$sort = 1;
foreach ($courses as $course) {
if (! is_array($course)) {
continue;
}
$name = self::compactText((string) ($course['name'] ?? ''));
$content = self::compactText((string) ($course['content'] ?? ''));
if ($name === '' && $content === '') {
continue;
}
$out[] = [
'sort' => (int) ($course['sort'] ?? $sort),
'name' => $name,
'content' => $content,
];
$sort++;
}
return $out;
}
public static function normalizeContactPhones(string $raw): string
{
$raw = str_replace(['—', '', ''], '-', $raw);
$raw = str_replace([',', '', ';', '', '/', '|'], '、', $raw);
$parts = preg_split('/[、\s]+/u', $raw) ?: [];
$parts = array_values(array_filter(array_map('trim', $parts), fn ($p) => $p !== ''));
return implode('、', $parts);
}
public static function contactPhoneList(?string $raw): array
{
$normalized = self::normalizeContactPhones((string) $raw);
return $normalized === '' ? [] : explode('、', $normalized);
}
public static function applyListFilters(Builder $q, Request $request): void
{
if ($request->filled('keyword')) {
$kw = trim((string) $request->input('keyword'));
if ($kw !== '') {
$q->where(function ($sub) use ($kw) {
$sub->where('name', 'like', '%'.$kw.'%')
->orWhere('org_name', 'like', '%'.$kw.'%')
->orWhere('venue_items', 'like', '%'.$kw.'%');
});
}
}
if ($request->filled('venue_id')) {
$vid = (int) $request->input('venue_id');
if ($vid > 0) {
$q->where(function ($sub) use ($vid) {
$sub->whereJsonContains('venue_ids', $vid)
->orWhere('venue_items', 'like', '%"venue_id":'.$vid.'%');
});
}
}
if ($request->has('is_on_shelf') && $request->input('is_on_shelf') !== '' && $request->input('is_on_shelf') !== null) {
$raw = $request->input('is_on_shelf');
$on = in_array($raw, [1, '1', true, 'true', 'on', 'yes'], true);
$off = in_array($raw, [0, '0', false, 'false', 'off', 'no'], true);
if ($on || $off) {
$q->where('is_on_shelf', $on);
}
}
if ($request->filled('season')) {
$season = trim((string) $request->input('season'));
if ($season !== '') {
$q->whereJsonContains('seasons', $season);
}
}
if ($request->filled('suitable_audience')) {
$audience = trim((string) $request->input('suitable_audience'));
if ($audience !== '') {
$q->where('suitable_audience', 'like', '%'.$audience.'%');
}
}
if ($request->filled('org_name')) {
$org = trim((string) $request->input('org_name'));
if ($org !== '') {
$q->where('org_name', 'like', '%'.$org.'%');
}
}
}
/**
* @return array{venue_items: array<int, array<string, mixed>>, map_venues: array<int, array<string, mixed>>}
*/
public static function resolveVenueItemsForH5(StudyTour $row, callable $venueTypeColorResolver): array
{
$items = self::venueItemsForRecord($row);
$systemIds = collect($items)
->filter(fn ($item) => ($item['type'] ?? '') === 'system')
->map(fn ($item) => (int) ($item['venue_id'] ?? 0))
->filter(fn ($id) => $id > 0)
->values();
$venueMap = Venue::query()
->whereIn('id', $systemIds->all())
->get(['id', 'name', 'district', 'address', 'cover_image', 'lat', 'lng', 'ticket_type', 'venue_type', 'venue_types'])
->keyBy('id');
$venueItems = [];
$mapVenues = [];
foreach ($items as $item) {
if (($item['type'] ?? '') === 'system') {
$vid = (int) ($item['venue_id'] ?? 0);
$v = $venueMap->get($vid);
if ($v === null) {
continue;
}
$arr = $v->toArray();
$arr['type'] = 'system';
$arr['venue_type_color'] = $venueTypeColorResolver($v->venue_type, is_array($v->venue_types) ? $v->venue_types : null);
if (is_array($arr['venue_types'] ?? null)) {
$arr['venue_types'] = array_values($arr['venue_types']);
}
$venueItems[] = $arr;
$lat = (float) ($arr['lat'] ?? 0);
$lng = (float) ($arr['lng'] ?? 0);
if ($lat && $lng) {
$mapVenues[] = $arr;
}
continue;
}
if (($item['type'] ?? '') === 'custom') {
$venueItems[] = [
'type' => 'custom',
'name' => (string) ($item['name'] ?? ''),
'address' => (string) ($item['address'] ?? ''),
];
}
}
return [
'venue_items' => $venueItems,
'map_venues' => $mapVenues,
];
}
public static function listVenueNames(StudyTour $row, $venueMap): array
{
return collect(self::venueItemsForRecord($row))
->map(function ($item) use ($venueMap) {
if (($item['type'] ?? '') === 'system') {
$id = (int) ($item['venue_id'] ?? 0);
$v = is_array($venueMap) ? ($venueMap[$id] ?? null) : $venueMap->get($id);
return $v?->name;
}
if (($item['type'] ?? '') === 'custom') {
return (string) ($item['name'] ?? '');
}
return null;
})
->filter()
->values()
->all();
}
public static function listCoverImage(StudyTour $row, $venueMap): ?string
{
$tourCover = trim((string) ($row->cover_image ?? ''));
if ($tourCover !== '') {
return $tourCover;
}
foreach (self::venueItemsForRecord($row) as $item) {
if (($item['type'] ?? '') !== 'system') {
continue;
}
$id = (int) ($item['venue_id'] ?? 0);
$v = is_array($venueMap) ? ($venueMap[$id] ?? null) : $venueMap->get($id);
$cover = trim((string) ($v?->cover_image ?? ''));
if ($cover !== '') {
return $cover;
}
}
return null;
}
public static function h5ListPayload(StudyTour $row, $venueMap): array
{
$items = self::venueItemsForRecord($row);
$firstSystemItem = collect($items)->first(fn ($item) => ($item['type'] ?? '') === 'system');
$firstSystemId = $firstSystemItem ? (int) ($firstSystemItem['venue_id'] ?? 0) : 0;
$firstVenue = $firstSystemId > 0
? (is_array($venueMap) ? ($venueMap[$firstSystemId] ?? null) : $venueMap->get($firstSystemId))
: null;
return [
'id' => $row->id,
'name' => $row->name,
'tags' => array_values($row->tags ?? []),
'org_name' => $row->org_name,
'seasons' => array_values($row->seasons ?? []),
'suitable_audience' => $row->suitable_audience,
'venue_names' => self::listVenueNames($row, $venueMap),
'cover_image' => self::listCoverImage($row, $venueMap),
'first_address' => $firstVenue?->address,
'first_district' => $firstVenue?->district,
'venue_count' => count($items),
];
}
public static function h5DetailPayload(StudyTour $row, callable $venueTypeColorResolver): array
{
$venues = self::resolveVenueItemsForH5($row, $venueTypeColorResolver);
$seasonLabels = collect($row->seasons ?? [])
->map(fn ($v) => DictItem::labelFor('study_tour_season', (string) $v) ?? (string) $v)
->filter()
->values()
->all();
$coverImage = self::listCoverImage($row, collect($venues['venue_items'])
->filter(fn ($item) => ($item['type'] ?? '') === 'system' && ! empty($item['id']))
->map(fn ($item) => (object) [
'id' => (int) $item['id'],
'cover_image' => $item['cover_image'] ?? null,
])
->keyBy('id'));
return [
'id' => $row->id,
'name' => $row->name,
'tags' => array_values($row->tags ?? []),
'org_name' => $row->org_name,
'seasons' => array_values($row->seasons ?? []),
'season_labels' => $seasonLabels,
'suitable_count' => $row->suitable_count,
'suitable_audience' => $row->suitable_audience,
'duration' => $row->duration,
'contact_person' => $row->contact_person,
'contact_phones' => $row->contact_phones,
'contact_phone_list' => self::contactPhoneList($row->contact_phones),
'cover_image' => $coverImage,
'image' => $coverImage,
'gallery_media' => $row->gallery_media ?? [],
'carousel' => ActivityH5View::buildGalleryCarousel($row),
'intro_html' => $row->intro_html,
'route_plans' => array_values($row->route_plans ?? []),
'courses' => array_values($row->courses ?? []),
'fee_html' => $row->fee_html,
'implementation_html' => $row->implementation_html,
'venue_items' => $venues['venue_items'],
'map_venues' => $venues['map_venues'],
'venues' => $venues['map_venues'],
];
}
}