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.

488 lines
18 KiB

3 days ago
<?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)
);
$gradeRule = Rule::exists('dict_items', 'item_value')->where(
fn ($q) => $q->where('dict_type', 'study_tour_grade_level')->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'],
'grade_levels' => ['nullable', 'array'],
'grade_levels.*' => ['string', 'max:50', $gradeRule],
'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', '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('grade_levels', $data)) {
$data['grade_levels'] = array_values($data['grade_levels'] ?? []);
}
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
{
3 days ago
$raw = str_replace(['—', '', ''], '-', $raw);
3 days ago
$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('grade_level')) {
$grade = trim((string) $request->input('grade_level'));
if ($grade !== '') {
$q->whereJsonContains('grade_levels', $grade);
}
}
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 ?? []),
'grade_levels' => array_values($row->grade_levels ?? []),
'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();
$gradeLabels = collect($row->grade_levels ?? [])
->map(fn ($v) => DictItem::labelFor('study_tour_grade_level', (string) $v) ?? (string) $v)
->filter()
->values()
->all();
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,
'grade_levels' => array_values($row->grade_levels ?? []),
'grade_level_labels' => $gradeLabels,
'duration' => $row->duration,
'contact_person' => $row->contact_person,
'contact_phones' => $row->contact_phones,
'contact_phone_list' => self::contactPhoneList($row->contact_phones),
'image' => $row->cover_image,
'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'],
];
}
}