where('ticket_grab_event_id', $eventId); if ($venueId !== null) { $q->where('venue_id', $venueId); } $pivots = $q->get(); foreach ($pivots as $p) { $days = TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $p->venue_id) ->orderBy('release_date') ->get(); $tz = (string) config('app.timezone'); $today = Carbon::now($tz)->toDateString(); $run = 0; foreach ($days as $d) { $d->carry_in = $run; $raw = (int) $d->carry_in + (int) $d->day_quota - (int) $d->booked_count; $rem = max(0, $raw); $rd = $d->release_date instanceof Carbon ? $d->release_date->format('Y-m-d') : Carbon::parse($d->release_date)->format('Y-m-d'); // 仅当该放票日已早于「今天」才把当日余量滚入下一天;当日与未来的日期不提前把余量算进次日 if ($rd < $today) { $run = $rem; } else { $run = 0; } $d->save(); } } } /** * 根据活动与预约起止、场馆及配额,重建每日放票行(会删除该活动下未删除场馆对应的旧行并插入新行,booked 清零 —— 见调用方在更新时的保护). */ public static function rebuildAllReleaseDaysForEvent(int $eventId, bool $preserveBooked = false): void { $event = TicketGrabEvent::query()->with('eventVenuePivots')->find($eventId); if (! $event || ! $event->booking_start_at || ! $event->booking_end_at) { return; } $start = $event->booking_start_at->copy(); $end = $event->booking_end_at->copy(); if ($end->lt($start)) { return; } $dates = []; foreach (CarbonPeriod::create($start, $end) as $d) { $dates[] = $d->toDateString(); } $n = count($dates); if ($n === 0) { return; } DB::transaction(function () use ($eventId, $dates, $n, $preserveBooked) { if (! $preserveBooked) { TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->delete(); } $pivots = TicketGrabEventVenue::query()->where('ticket_grab_event_id', $eventId)->get(); foreach ($pivots as $p) { $alloc = self::equalSplit((int) $p->venue_total_quota, $n); if (! $preserveBooked) { foreach ($dates as $i => $dateStr) { TicketGrabVenueReleaseDay::query()->create([ 'ticket_grab_event_id' => $eventId, 'venue_id' => $p->venue_id, 'release_date' => $dateStr, 'day_quota' => (int) ($alloc[$i] ?? 0), 'booked_count' => 0, 'carry_in' => 0, ]); } } } self::syncCarryInChain($eventId); }); if (! $preserveBooked) { $sum = (int) TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $eventId) ->sum('venue_total_quota'); TicketGrabEvent::query()->where('id', $eventId)->update(['total_quota' => $sum]); } } /** * 预约起止变更后同步每日放票行:已有预约时不可整表重建,改为按新窗口增删日期并重新均分配额。 * * @throws ValidationException 缩短窗口导致「窗口外」仍有已预约放票日时 */ public static function syncReleaseDaysToBookingWindow(int $eventId): void { $event = TicketGrabEvent::query()->with('eventVenuePivots')->find($eventId); if (! $event || ! $event->booking_start_at || ! $event->booking_end_at) { return; } $tz = (string) config('app.timezone'); $start = Carbon::parse($event->booking_start_at, $tz)->copy()->startOfDay(); $end = Carbon::parse($event->booking_end_at, $tz)->copy()->startOfDay(); if ($end->lt($start)) { return; } $dates = []; foreach (CarbonPeriod::create($start, $end) as $d) { $dates[] = $d->toDateString(); } $n = count($dates); if ($n === 0) { return; } $invalid = TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->whereNotIn('release_date', $dates) ->where('booked_count', '>', 0) ->exists(); if ($invalid) { throw ValidationException::withMessages([ 'booking_end_at' => ['所选预约窗口之外仍有已预约人数对应的放票日,无法缩小至此范围。可先扩大结束日或处理相关预约后再调整。'], ]); } DB::transaction(function () use ($eventId, $dates, $n) { $pivots = TicketGrabEventVenue::query()->where('ticket_grab_event_id', $eventId)->get(); foreach ($pivots as $p) { $total = (int) $p->venue_total_quota; $alloc = self::equalSplit($total, $n); foreach ($dates as $i => $dateStr) { $target = (int) ($alloc[$i] ?? 0); $row = TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $p->venue_id) ->whereDate('release_date', $dateStr) ->first(); if (! $row) { TicketGrabVenueReleaseDay::query()->create([ 'ticket_grab_event_id' => $eventId, 'venue_id' => $p->venue_id, 'release_date' => $dateStr, 'day_quota' => $target, 'booked_count' => 0, 'carry_in' => 0, ]); } else { $booked = (int) $row->booked_count; $row->day_quota = max($target, $booked); $row->save(); } } TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $p->venue_id) ->whereNotIn('release_date', $dates) ->delete(); self::rebalanceVenueReleaseDays($eventId, $p->venue_id, $total); } self::syncCarryInChain($eventId); }); $sum = (int) TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $eventId) ->sum('venue_total_quota'); TicketGrabEvent::query()->where('id', $eventId)->update(['total_quota' => $sum]); } /** * 将某馆各日 day_quota 之和收敛为 venue_total_quota(已尊重 booked_count 下限)。 * * @throws ValidationException 无法在不跌破已预约数的前提下收敛时 */ private static function rebalanceVenueReleaseDays(int $eventId, int $venueId, int $targetTotal): void { $rows = TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $venueId) ->orderBy('release_date') ->get(); if ($rows->isEmpty()) { return; } $sum = (int) $rows->sum('day_quota'); $diff = $targetTotal - $sum; if ($diff === 0) { return; } if ($diff > 0) { $keys = $rows->keys()->all(); $k = count($keys); $i = 0; while ($diff > 0) { $idx = $keys[$i % $k]; $row = $rows[$idx]; $row->day_quota = (int) $row->day_quota + 1; $row->save(); $diff--; $i++; } return; } $need = -$diff; $sorted = $rows->sortByDesc(fn ($r) => (int) $r->day_quota - (int) $r->booked_count)->values(); foreach ($sorted as $row) { if ($need <= 0) { break; } $slack = max(0, (int) $row->day_quota - (int) $row->booked_count); $dec = min($slack, $need); if ($dec > 0) { $row->day_quota = (int) $row->day_quota - $dec; $row->save(); $need -= $dec; } } if ($need > 0) { throw ValidationException::withMessages([ 'booking_end_at' => ['各日已预约数量较多,无法在给定预约窗口与放票总数下平衡每日配额,请检查后再试。'], ]); } } public static function updateDayQuotasFromAdmin(int $eventId, int $venueId, array $dateToQuota): void { $pivot = TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $venueId) ->firstOrFail(); $total = (int) $pivot->venue_total_quota; if ($total === 0) { foreach ($dateToQuota as $q) { if ((int) $q !== 0) { throw ValidationException::withMessages([ 'day_quota' => ['该馆放票总数为 0 时,各日放票数须均为 0。'], ]); } } TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $venueId) ->update(['day_quota' => 0]); self::syncCarryInChain($eventId, $venueId); return; } foreach ($dateToQuota as $date => $q) { TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $venueId) ->whereDate('release_date', $date) ->update(['day_quota' => (int) $q]); } $dbSum = (int) TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $venueId) ->sum('day_quota'); if ($dbSum !== $total) { throw ValidationException::withMessages([ 'day_quota' => ["各日放票数之和必须等于该馆放票总数({$total})。当前合计为 {$dbSum}。"], ]); } self::syncCarryInChain($eventId, $venueId); } }