ensureVenuePermission($request, $activity->venue_id); return response()->json($this->formatBookingPayload($activity->load('venue:id,appointment_type'))); } public function update(Request $request, Activity $activity): JsonResponse { $this->ensureVenuePermission($request, $activity->venue_id); $data = $request->validate([ 'booking_audience' => ['required', 'in:individual,group,both'], 'min_people_per_order' => ['nullable', 'integer', 'min:1'], 'max_people_per_order' => ['nullable', 'integer', 'min:1'], 'days' => ['required', 'array', 'min:1'], 'days.*.activity_date' => ['required', 'date'], 'days.*.day_quota' => ['required', 'integer', 'min:0'], 'days.*.opens_at' => ['required', 'date'], 'days.*.closes_at' => ['required', 'date'], ]); $mode = $data['booking_audience']; $venue = Venue::query()->findOrFail($activity->venue_id); if ($venue->appointment_type === 'team_only' && $mode !== self::BOOKING_MODE_GROUP) { throw ValidationException::withMessages([ 'booking_audience' => ['该场馆预约类型为「仅团队」,放票设置只能选择团体'], ]); } $minPeople = (int) ($data['min_people_per_order'] ?? 1); $maxPeople = (int) ($data['max_people_per_order'] ?? 1); if ($mode === self::BOOKING_MODE_INDIVIDUAL) { $minPeople = 1; $maxPeople = 1; } else { $minPeople = max(1, $minPeople); $maxPeople = max(1, $maxPeople); if ($maxPeople < $minPeople) { throw ValidationException::withMessages([ 'max_people_per_order' => ['最大预约人数不能小于最小预约人数'], ]); } } $this->validateDaysWithinActivityRange($activity, $data['days']); $this->validateUniqueDates($data['days']); $this->validateBookingPeriodWithinActivityDay($data['days']); $sumDay = 0; foreach ($data['days'] as $row) { $sumDay += (int) $row['day_quota']; } DB::transaction(function () use ($activity, $data, $sumDay, $minPeople, $maxPeople) { $activity->booking_audience = $data['booking_audience']; $activity->total_quota = $sumDay; $activity->min_people_per_order = $minPeople; $activity->max_people_per_order = $maxPeople; $activity->save(); $incomingDateKeys = collect($data['days'])->map(function (array $row) { return Carbon::parse($row['activity_date'])->format('Y-m-d'); })->values(); $existing = ActivityDay::where('activity_id', $activity->id)->get()->keyBy(fn (ActivityDay $d) => $d->activity_date->format('Y-m-d')); foreach ($data['days'] as $row) { $dateKey = Carbon::parse($row['activity_date'])->format('Y-m-d'); $dayQuota = (int) $row['day_quota']; $opensAt = Carbon::parse($row['opens_at']); $closesAt = Carbon::parse($row['closes_at']); if ($existing->has($dateKey)) { /** @var ActivityDay $day */ $day = $existing->get($dateKey); if ($day->booked_count > $dayQuota) { throw ValidationException::withMessages([ 'days' => ["{$dateKey} 当日已占用 {$day->booked_count} 张,放票数不能少于该值"], ]); } $day->day_quota = $dayQuota; $day->opens_at = $opensAt; $day->closes_at = $closesAt; $day->save(); } else { ActivityDay::create([ 'activity_id' => $activity->id, 'activity_date' => $dateKey, 'day_quota' => $dayQuota, 'booked_count' => 0, 'opens_at' => $opensAt, 'closes_at' => $closesAt, ]); } } foreach ($existing as $dateKey => $day) { if (!$incomingDateKeys->contains($dateKey)) { if ($day->booked_count > 0) { throw ValidationException::withMessages([ 'days' => ["{$dateKey} 已有预约占用,不能移除该放票日"], ]); } $day->delete(); } } }); return response()->json(array_merge( ['message' => '保存成功'], $this->formatBookingPayload($activity->fresh()->load('venue:id,appointment_type')) )); } /** * @param array $days */ private function validateDaysWithinActivityRange(Activity $activity, array $days): void { if (!$activity->start_at || !$activity->end_at) { return; } // 按应用时区的「日历日」比较,避免 date-only / ISO 与库中 datetime 混用时出现跨日误判 $tz = config('app.timezone'); $startDate = $activity->start_at->copy()->timezone($tz)->format('Y-m-d'); $endDate = $activity->end_at->copy()->timezone($tz)->format('Y-m-d'); foreach ($days as $row) { $dayStr = Carbon::parse($row['activity_date'])->timezone($tz)->format('Y-m-d'); if ($dayStr < $startDate || $dayStr > $endDate) { throw ValidationException::withMessages([ 'days' => ['活动日 '.$dayStr.' 须在活动开始、结束日期范围内'], ]); } } } /** * 开始/结束预约时刻的日期不能晚于活动日,且结束时刻需晚于开始时刻。 * * @param array $days */ private function validateBookingPeriodWithinActivityDay(array $days): void { $tz = config('app.timezone'); foreach ($days as $row) { $actDayStr = Carbon::parse($row['activity_date'])->timezone($tz)->format('Y-m-d'); $opens = Carbon::parse($row['opens_at']); $closes = Carbon::parse($row['closes_at']); $opensDateStr = $opens->copy()->timezone($tz)->format('Y-m-d'); $closesDateStr = $closes->copy()->timezone($tz)->format('Y-m-d'); if ($opensDateStr > $actDayStr || $closesDateStr > $actDayStr) { throw ValidationException::withMessages([ 'days' => ['活动日 '.$actDayStr.':预约开始/结束时刻的日期不能晚于活动日当天'], ]); } if ($closes->lte($opens)) { throw ValidationException::withMessages([ 'days' => ['活动日 '.$actDayStr.':预约结束时刻必须晚于开始时刻'], ]); } } } /** * @param array $days */ private function validateUniqueDates(array $days): void { $seen = []; foreach ($days as $row) { $key = Carbon::parse($row['activity_date'])->format('Y-m-d'); if (isset($seen[$key])) { throw ValidationException::withMessages(['days' => ['活动日不能重复']]); } $seen[$key] = true; } } /** * @return array{booking_audience: string|null, total_quota: int, days: array>} */ private function formatBookingPayload(Activity $activity): array { $days = $activity->activityDays()->orderBy('activity_date')->get()->map(function (ActivityDay $d) { return [ 'id' => $d->id, 'activity_date' => $d->activity_date->format('Y-m-d'), 'day_quota' => $d->day_quota, 'booked_count' => $d->booked_count, 'opens_at' => $d->opens_at->format('Y-m-d H:i:s'), 'closes_at' => ($d->closes_at ?: $d->opens_at->copy()->endOfDay())->format('Y-m-d H:i:s'), ]; })->values()->all(); $audience = $activity->booking_audience ?: self::BOOKING_MODE_BOTH; $venue = $activity->relationLoaded('venue') ? $activity->venue : $activity->venue()->first(['id', 'appointment_type']); $minP = 1; $maxP = 1; if ($audience === self::BOOKING_MODE_GROUP || $audience === self::BOOKING_MODE_BOTH) { $minP = max(1, (int) ($activity->min_people_per_order ?? 1)); $maxP = max($minP, (int) ($activity->max_people_per_order ?? $minP)); } return [ 'booking_audience' => $audience, 'total_quota' => (int) ($activity->total_quota ?? 0), 'min_people_per_order' => $minP, 'max_people_per_order' => $maxP, 'venue_appointment_type' => $venue?->appointment_type, 'days' => $days, ]; } private function ensureVenuePermission(Request $request, int $venueId): void { $user = $request->user(); if ($user->isSuperAdmin()) { return; } $allowed = $user->venues()->where('venues.id', $venueId)->exists(); abort_unless($allowed, 403, '仅可操作已绑定场馆'); } }