|
|
<?php
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
use App\Models\Activity;
|
|
|
use App\Models\ActivityDay;
|
|
|
use App\Models\Reservation;
|
|
|
use App\Models\WechatUser;
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
use Illuminate\Http\Request;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Str;
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
use Laravel\Sanctum\PersonalAccessToken;
|
|
|
|
|
|
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' => ['该手机号在该活动日期已预约过']]);
|
|
|
}
|
|
|
|
|
|
$wechatUser = $this->authWechatUser($request);
|
|
|
|
|
|
$row = Reservation::create([
|
|
|
'venue_id' => $activity->venue_id,
|
|
|
'activity_id' => $activity->id,
|
|
|
'activity_day_id' => $day->id,
|
|
|
'wechat_user_id' => $wechatUser?->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;
|
|
|
});
|
|
|
|
|
|
$reservation->load([
|
|
|
'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng',
|
|
|
'venue:id,name,address',
|
|
|
'activityDay:id,activity_id,activity_date',
|
|
|
]);
|
|
|
|
|
|
return response()->json([
|
|
|
'message' => '预约成功',
|
|
|
'reservation' => $this->reservationToH5Array($reservation),
|
|
|
], 201);
|
|
|
}
|
|
|
|
|
|
public function myReservations(Request $request): JsonResponse
|
|
|
{
|
|
|
$wechatUser = $this->authWechatUser($request);
|
|
|
|
|
|
$query = Reservation::query()
|
|
|
->with([
|
|
|
'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng',
|
|
|
'venue:id,name,address',
|
|
|
'activityDay:id,activity_id,activity_date',
|
|
|
])
|
|
|
->orderByDesc('id')
|
|
|
->limit(200);
|
|
|
|
|
|
if ($wechatUser) {
|
|
|
$rows = (clone $query)->where('wechat_user_id', $wechatUser->id)->get();
|
|
|
} else {
|
|
|
$data = $request->validate([
|
|
|
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
|
|
|
]);
|
|
|
$rows = (clone $query)->where('visitor_phone', $data['visitor_phone'])->get();
|
|
|
}
|
|
|
|
|
|
return response()->json($rows->map(fn (Reservation $r) => $this->reservationToH5Array($r))->values());
|
|
|
}
|
|
|
|
|
|
public function detail(Request $request, int $reservationId): JsonResponse
|
|
|
{
|
|
|
$wechatUser = $this->authWechatUser($request);
|
|
|
|
|
|
$q = Reservation::query()
|
|
|
->with([
|
|
|
'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng',
|
|
|
'venue:id,name,address',
|
|
|
'activityDay:id,activity_id,activity_date',
|
|
|
])
|
|
|
->where('id', $reservationId);
|
|
|
|
|
|
if ($wechatUser) {
|
|
|
$row = $q->where('wechat_user_id', $wechatUser->id)->firstOrFail();
|
|
|
} else {
|
|
|
$data = $request->validate([
|
|
|
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
|
|
|
]);
|
|
|
$row = $q->where('visitor_phone', $data['visitor_phone'])->firstOrFail();
|
|
|
}
|
|
|
|
|
|
return response()->json($this->reservationToH5Array($row));
|
|
|
}
|
|
|
|
|
|
public function cancel(Request $request, int $reservationId): JsonResponse
|
|
|
{
|
|
|
$wechatUser = $this->authWechatUser($request);
|
|
|
|
|
|
$q = Reservation::query()->where('id', $reservationId);
|
|
|
if ($wechatUser) {
|
|
|
$reservation = $q->where('wechat_user_id', $wechatUser->id)->firstOrFail();
|
|
|
} else {
|
|
|
$data = $request->validate([
|
|
|
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
|
|
|
]);
|
|
|
$reservation = $q->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];
|
|
|
}
|
|
|
|
|
|
private function authWechatUser(Request $request): ?WechatUser
|
|
|
{
|
|
|
$token = $request->bearerToken();
|
|
|
if (!$token) {
|
|
|
return null;
|
|
|
}
|
|
|
$accessToken = PersonalAccessToken::findToken($token);
|
|
|
if (!$accessToken || ! ($accessToken->tokenable instanceof WechatUser)) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
return $accessToken->tokenable;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array<string, mixed>
|
|
|
*/
|
|
|
private function reservationToH5Array(Reservation $r): array
|
|
|
{
|
|
|
$a = $r->relationLoaded('activity') ? $r->activity : $r->activity()->first();
|
|
|
$day = $r->relationLoaded('activityDay') ? $r->activityDay : $r->activityDay()->first();
|
|
|
$venue = $r->relationLoaded('venue') ? $r->venue : $r->venue()->first();
|
|
|
|
|
|
return [
|
|
|
'id' => $r->id,
|
|
|
'status' => $r->status,
|
|
|
'visitor_name' => $r->visitor_name,
|
|
|
'visitor_phone' => $r->visitor_phone,
|
|
|
'id_card' => $r->id_card,
|
|
|
'ticket_count' => (int) ($r->ticket_count ?? 1),
|
|
|
'booking_type' => $r->booking_type,
|
|
|
'qr_token' => $r->qr_token,
|
|
|
'created_at' => $r->created_at?->format('Y-m-d H:i:s'),
|
|
|
'verified_at' => $r->verified_at?->format('Y-m-d H:i:s'),
|
|
|
'wechat_user_id' => $r->wechat_user_id,
|
|
|
'activity' => $a ? [
|
|
|
'id' => $a->id,
|
|
|
'title' => $a->title,
|
|
|
'summary' => $a->summary,
|
|
|
'cover_image' => $a->cover_image,
|
|
|
'address' => $a->address,
|
|
|
'lat' => $a->lat !== null ? (float) $a->lat : null,
|
|
|
'lng' => $a->lng !== null ? (float) $a->lng : null,
|
|
|
'start_at' => $a->start_at?->format('Y-m-d H:i:s'),
|
|
|
'end_at' => $a->end_at?->format('Y-m-d H:i:s'),
|
|
|
'tags' => array_values($a->tags ?? []),
|
|
|
] : null,
|
|
|
'activity_day' => $day && $day->activity_date ? [
|
|
|
'id' => $day->id,
|
|
|
'activity_date' => $day->activity_date->format('Y-m-d'),
|
|
|
] : null,
|
|
|
'venue' => $venue ? [
|
|
|
'id' => $venue->id,
|
|
|
'name' => $venue->name,
|
|
|
'address' => $venue->address,
|
|
|
] : null,
|
|
|
];
|
|
|
}
|
|
|
}
|