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.
szkp-map-service/app/Services/TicketGrabReleaseDayService...

326 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services;
use App\Models\TicketGrabEvent;
use App\Models\TicketGrabEventVenue;
use App\Models\TicketGrabVenueReleaseDay;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TicketGrabReleaseDayService
{
/**
* 将 total 均分至 n 天,不能整除时余量依次分给前 n 个日期各 +1等价于前 remainder 天多 1 张(此处按「多给前面日期」与需求「多的分配到第一日」一致为:余数 r 个日期各 +1从第 0 天开始;即 [ceil, floor, floor, ...])。
*
* @return int[]
*/
public static function equalSplit(int $total, int $n): array
{
if ($n <= 0) {
return [];
}
if ($total < 0) {
$total = 0;
}
$base = intdiv($total, $n);
$r = $total % $n;
$out = array_fill(0, $n, $base);
for ($i = 0; $i < $r; $i++) {
$out[$i]++;
}
return $out;
}
public static function syncCarryInChain(int $eventId, ?int $venueId = null): void
{
$q = TicketGrabEventVenue::query()->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);
}
}