|
|
<?php
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
use App\Models\Activity;
|
|
|
use App\Models\ActivityDay;
|
|
|
use App\Models\Reservation;
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
use Illuminate\Http\Request;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Str;
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
|
|
class H5ReservationController extends Controller
|
|
|
{
|
|
|
private const BOOKING_MODE_INDIVIDUAL = 'individual';
|
|
|
private const BOOKING_MODE_GROUP = 'group';
|
|
|
private const BOOKING_MODE_BOTH = 'both';
|
|
|
|
|
|
public function bookingInfo(int $activityId): JsonResponse
|
|
|
{
|
|
|
$activity = Activity::query()
|
|
|
->with(['venue:id,name,address', 'activityDays'])
|
|
|
->where('is_active', true)
|
|
|
->findOrFail($activityId);
|
|
|
|
|
|
$days = $activity->activityDays
|
|
|
->sortBy('activity_date')
|
|
|
->values()
|
|
|
->map(function (ActivityDay $d) {
|
|
|
$available = max(0, (int) $d->day_quota - (int) $d->booked_count);
|
|
|
$closesAt = $d->closes_at ?: $d->opens_at->copy()->endOfDay();
|
|
|
$now = now();
|
|
|
$isOpenWindow = $d->opens_at->lte($now) && $closesAt->gte($now);
|
|
|
$isBookable = $d->isCurrentlyBookable();
|
|
|
$unavailableReason = null;
|
|
|
if ($available <= 0) {
|
|
|
$unavailableReason = 'sold_out';
|
|
|
} elseif ($d->opens_at->gt($now)) {
|
|
|
$unavailableReason = 'not_started';
|
|
|
} elseif ($closesAt->lt($now)) {
|
|
|
$unavailableReason = 'closed';
|
|
|
}
|
|
|
return [
|
|
|
'id' => $d->id,
|
|
|
'activity_date' => $d->activity_date->format('Y-m-d'),
|
|
|
'opens_at' => $d->opens_at->format('Y-m-d H:i:s'),
|
|
|
'closes_at' => $closesAt->format('Y-m-d H:i:s'),
|
|
|
'day_quota' => (int) $d->day_quota,
|
|
|
'booked_count' => (int) $d->booked_count,
|
|
|
'available_count' => $available,
|
|
|
'is_open' => $isOpenWindow,
|
|
|
'is_bookable' => $isBookable,
|
|
|
'unavailable_reason' => $unavailableReason,
|
|
|
];
|
|
|
});
|
|
|
|
|
|
return response()->json([
|
|
|
'activity' => [
|
|
|
'id' => $activity->id,
|
|
|
'title' => $activity->title,
|
|
|
'image' => $activity->cover_image,
|
|
|
'booking_audience' => $activity->booking_audience ?: self::BOOKING_MODE_BOTH,
|
|
|
'min_people_per_order' => max(1, (int) ($activity->min_people_per_order ?? 1)),
|
|
|
'max_people_per_order' => max(1, (int) ($activity->max_people_per_order ?? 1)),
|
|
|
'booking_modes' => $this->bookingModesFor($activity->booking_audience ?: self::BOOKING_MODE_BOTH),
|
|
|
'reservation_notice' => $activity->reservation_notice,
|
|
|
'venue' => $activity->venue,
|
|
|
],
|
|
|
'days' => $days,
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
public function create(Request $request, int $activityId): JsonResponse
|
|
|
{
|
|
|
$activity = Activity::query()->where('is_active', true)->findOrFail($activityId);
|
|
|
$mode = $activity->booking_audience ?: self::BOOKING_MODE_BOTH;
|
|
|
$minPeople = max(1, (int) ($activity->min_people_per_order ?? 1));
|
|
|
$maxPeople = max(1, (int) ($activity->max_people_per_order ?? 1));
|
|
|
|
|
|
$data = $request->validate([
|
|
|
'activity_day_id' => ['required', 'integer', 'exists:activity_days,id'],
|
|
|
'visitor_name' => ['required', 'string', 'max:80'],
|
|
|
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
|
|
|
'id_card' => ['nullable', 'string', 'max:18'],
|
|
|
// 新版:显式人数 + 类型;兼容旧版 ticket_mode
|
|
|
'booking_type' => ['nullable', 'in:individual,group'],
|
|
|
'people_count' => ['nullable', 'integer', 'min:1'],
|
|
|
'ticket_mode' => ['nullable', 'in:single,pair'],
|
|
|
]);
|
|
|
|
|
|
if (!empty($data['id_card']) && !preg_match('/^\d{17}[\dXx]$/', $data['id_card'])) {
|
|
|
throw ValidationException::withMessages(['id_card' => ['身份证号格式不正确']]);
|
|
|
}
|
|
|
|
|
|
/** @var ActivityDay|null $day */
|
|
|
$day = ActivityDay::query()
|
|
|
->where('id', (int) $data['activity_day_id'])
|
|
|
->where('activity_id', $activity->id)
|
|
|
->first();
|
|
|
if (!$day) {
|
|
|
throw ValidationException::withMessages(['activity_day_id' => ['预约日期不存在或不属于该活动']]);
|
|
|
}
|
|
|
if ($day->opens_at->gt(now())) {
|
|
|
$closeText = ($day->closes_at ?: $day->opens_at->copy()->endOfDay())->format('Y-m-d H:i');
|
|
|
throw ValidationException::withMessages(['activity_day_id' => ['该日期尚未开放预约,可预约时段:'.$day->opens_at->format('Y-m-d H:i').' ~ '.$closeText]]);
|
|
|
}
|
|
|
$closesAt = $day->closes_at ?: $day->opens_at->copy()->endOfDay();
|
|
|
if ($closesAt->lt(now())) {
|
|
|
throw ValidationException::withMessages(['activity_day_id' => ['该日期预约已截止,可预约时段:'.$day->opens_at->format('Y-m-d H:i').' ~ '.$closesAt->format('Y-m-d H:i')]]);
|
|
|
}
|
|
|
|
|
|
[$bookingType, $peopleCount] = $this->resolveBookingTypeAndPeopleCount($mode, $data, $minPeople, $maxPeople);
|
|
|
|
|
|
$reservation = DB::transaction(function () use ($activity, $day, $data, $peopleCount, $bookingType) {
|
|
|
$day->refresh();
|
|
|
$available = (int) $day->day_quota - (int) $day->booked_count;
|
|
|
if ($available < $peopleCount) {
|
|
|
throw ValidationException::withMessages(['activity_day_id' => ['该日期余票不足']]);
|
|
|
}
|
|
|
|
|
|
// 去重规则:同活动同活动日同手机号只能预约一单(未取消)
|
|
|
$dupByPhone = Reservation::query()
|
|
|
->where('activity_id', $activity->id)
|
|
|
->where('activity_day_id', $day->id)
|
|
|
->where('visitor_phone', $data['visitor_phone'])
|
|
|
->where('status', '!=', 'cancelled')
|
|
|
->exists();
|
|
|
if ($dupByPhone) {
|
|
|
throw ValidationException::withMessages(['visitor_phone' => ['该手机号在该活动日期已预约过']]);
|
|
|
}
|
|
|
|
|
|
$row = Reservation::create([
|
|
|
'venue_id' => $activity->venue_id,
|
|
|
'activity_id' => $activity->id,
|
|
|
'activity_day_id' => $day->id,
|
|
|
'visitor_name' => $data['visitor_name'],
|
|
|
'visitor_phone' => $data['visitor_phone'],
|
|
|
'id_card' => $data['id_card'] ?? null,
|
|
|
'ticket_count' => $peopleCount,
|
|
|
'ticket_mode' => $data['ticket_mode'] ?? null,
|
|
|
'booking_type' => $bookingType,
|
|
|
'qr_token' => (string) Str::uuid(),
|
|
|
'status' => 'pending',
|
|
|
'reservation_source' => 'wechat_h5',
|
|
|
]);
|
|
|
|
|
|
$day->increment('booked_count', $peopleCount);
|
|
|
Activity::refreshRegisteredCountFromReservations($activity->id);
|
|
|
|
|
|
return $row;
|
|
|
});
|
|
|
|
|
|
return response()->json([
|
|
|
'message' => '预约成功',
|
|
|
'reservation' => $reservation->load([
|
|
|
'activity:id,title',
|
|
|
'venue:id,name,address',
|
|
|
'activityDay:id,activity_id,activity_date',
|
|
|
]),
|
|
|
], 201);
|
|
|
}
|
|
|
|
|
|
public function myReservations(Request $request): JsonResponse
|
|
|
{
|
|
|
$data = $request->validate([
|
|
|
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
|
|
|
]);
|
|
|
|
|
|
$rows = Reservation::query()
|
|
|
->with(['activity:id,title', 'venue:id,name', 'activityDay:id,activity_id,activity_date'])
|
|
|
->where('visitor_phone', $data['visitor_phone'])
|
|
|
->orderByDesc('id')
|
|
|
->limit(200)
|
|
|
->get();
|
|
|
|
|
|
return response()->json($rows);
|
|
|
}
|
|
|
|
|
|
public function detail(Request $request, int $reservationId): JsonResponse
|
|
|
{
|
|
|
$data = $request->validate([
|
|
|
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
|
|
|
]);
|
|
|
|
|
|
$row = Reservation::query()
|
|
|
->with(['activity:id,title', 'venue:id,name,address', 'activityDay:id,activity_id,activity_date'])
|
|
|
->where('id', $reservationId)
|
|
|
->where('visitor_phone', $data['visitor_phone'])
|
|
|
->firstOrFail();
|
|
|
|
|
|
return response()->json($row);
|
|
|
}
|
|
|
|
|
|
public function cancel(Request $request, int $reservationId): JsonResponse
|
|
|
{
|
|
|
$data = $request->validate([
|
|
|
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
|
|
|
]);
|
|
|
|
|
|
$reservation = Reservation::query()
|
|
|
->where('id', $reservationId)
|
|
|
->where('visitor_phone', $data['visitor_phone'])
|
|
|
->firstOrFail();
|
|
|
|
|
|
if ($reservation->status === 'verified') {
|
|
|
throw ValidationException::withMessages(['status' => ['已核销预约不可取消']]);
|
|
|
}
|
|
|
if ($reservation->status === 'cancelled') {
|
|
|
throw ValidationException::withMessages(['status' => ['该预约已取消']]);
|
|
|
}
|
|
|
|
|
|
DB::transaction(function () use ($reservation) {
|
|
|
$reservation->status = 'cancelled';
|
|
|
$reservation->save();
|
|
|
|
|
|
if ($reservation->activity_day_id) {
|
|
|
$day = ActivityDay::query()->find($reservation->activity_day_id);
|
|
|
if ($day) {
|
|
|
$newBooked = max(0, (int) $day->booked_count - max(1, (int) ($reservation->ticket_count ?? 1)));
|
|
|
$day->booked_count = $newBooked;
|
|
|
$day->save();
|
|
|
}
|
|
|
}
|
|
|
if ($reservation->activity_id) {
|
|
|
Activity::refreshRegisteredCountFromReservations($reservation->activity_id);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
return response()->json(['message' => '取消成功']);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array<int, string>
|
|
|
*/
|
|
|
private function bookingModesFor(string $mode): array
|
|
|
{
|
|
|
if ($mode === self::BOOKING_MODE_INDIVIDUAL) return ['individual'];
|
|
|
if ($mode === self::BOOKING_MODE_GROUP) return ['group'];
|
|
|
return ['individual', 'group'];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $data
|
|
|
* @return array{0: string, 1: int} booking_type, people_count
|
|
|
*/
|
|
|
private function resolveBookingTypeAndPeopleCount(string $mode, array $data, int $minPeople, int $maxPeople): array
|
|
|
{
|
|
|
$bookingType = (string) ($data['booking_type'] ?? '');
|
|
|
$peopleCount = (int) ($data['people_count'] ?? 0);
|
|
|
|
|
|
// Backward compatible: ticket_mode -> people_count
|
|
|
if ($peopleCount <= 0) {
|
|
|
$ticketMode = (string) ($data['ticket_mode'] ?? '');
|
|
|
if ($ticketMode === 'pair') $peopleCount = 2;
|
|
|
else $peopleCount = 1;
|
|
|
}
|
|
|
|
|
|
// Infer booking_type if not provided.
|
|
|
if ($bookingType !== 'individual' && $bookingType !== 'group') {
|
|
|
$bookingType = ($peopleCount > 1 ? 'group' : 'individual');
|
|
|
}
|
|
|
|
|
|
if ($mode === self::BOOKING_MODE_INDIVIDUAL) {
|
|
|
if ($bookingType !== 'individual' || $peopleCount !== 1) {
|
|
|
throw ValidationException::withMessages(['people_count' => ['该活动仅支持个人预约(人数=1)']]);
|
|
|
}
|
|
|
return [$bookingType, 1];
|
|
|
}
|
|
|
|
|
|
if ($mode === self::BOOKING_MODE_GROUP) {
|
|
|
if ($bookingType !== 'group') {
|
|
|
throw ValidationException::withMessages(['booking_type' => ['该活动仅支持团体预约']]);
|
|
|
}
|
|
|
if ($peopleCount < $minPeople || $peopleCount > $maxPeople) {
|
|
|
throw ValidationException::withMessages(['people_count' => ["团体人数需在 {$minPeople}-{$maxPeople} 人之间"]]);
|
|
|
}
|
|
|
return [$bookingType, $peopleCount];
|
|
|
}
|
|
|
|
|
|
// both: individual must be 1; group must be within min/max
|
|
|
if ($bookingType === 'individual') {
|
|
|
if ($peopleCount !== 1) {
|
|
|
throw ValidationException::withMessages(['people_count' => ['个人预约人数固定为 1 人']]);
|
|
|
}
|
|
|
return [$bookingType, 1];
|
|
|
}
|
|
|
|
|
|
if ($peopleCount < $minPeople || $peopleCount > $maxPeople) {
|
|
|
throw ValidationException::withMessages(['people_count' => ["团体人数需在 {$minPeople}-{$maxPeople} 人之间"]]);
|
|
|
}
|
|
|
return ['group', $peopleCount];
|
|
|
}
|
|
|
}
|