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.

296 lines
12 KiB

5 days ago
<?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];
}
}