, * pending_activity_dates: list, * orphan_activity_dates: list, * unpaid_bill_dates: list * } */ public function hints(int $adminSiteId): array { $site = DB::table('admin_sites')->where('id', $adminSiteId)->first(['code', 'settlement_timezone']); $siteCode = (string) ($site->code ?? ''); if ($siteCode === '') { return $this->emptyHints(); } $timezone = AgentSettlementSiteTimezone::isValid((string) ($site->settlement_timezone ?? '')) ? (string) $site->settlement_timezone : AgentSettlementSiteTimezone::forSiteId($adminSiteId); $periodRows = DB::table('settlement_periods') ->where('admin_site_id', $adminSiteId) ->orderBy('period_start') ->get(['period_start', 'period_end', 'status']); $occupiedPeriodDates = []; foreach ($periodRows as $row) { foreach ($this->expandPeriodToLocalDays((string) $row->period_start, (string) $row->period_end, $timezone) as $day) { $occupiedPeriodDates[$day] = true; } } $lastPeriod = DB::table('settlement_periods') ->where('admin_site_id', $adminSiteId) ->whereIn('status', ['closed', 'completed']) ->orderByDesc('period_end') ->first(); $pendingLedgerRows = DB::table('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) ->whereNull('sl.settlement_period_id') ->whereNull('sl.reversal_of_id') ->orderBy('sl.settled_at') ->get(['sl.settled_at']); $pendingActivityDates = []; $orphanActivityDates = []; foreach ($pendingLedgerRows as $row) { $day = AgentSettlementSiteTimezone::localDayFromUtc($row->settled_at, $timezone); $pendingActivityDates[$day] = true; if (isset($occupiedPeriodDates[$day])) { $orphanActivityDates[$day] = true; } } $pendingActivityDates = array_keys($pendingActivityDates); $orphanActivityDates = array_keys($orphanActivityDates); $unpaidPeriodRows = DB::table('settlement_periods as sp') ->where('sp.admin_site_id', $adminSiteId) ->whereIn('sp.status', ['closed', 'completed']) ->whereExists(function ($query): void { $query->selectRaw('1') ->from('settlement_bills as sb') ->whereColumn('sb.settlement_period_id', 'sp.id') ->where('sb.unpaid_amount', '>', 0) ->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']); }) ->orderBy('sp.period_start') ->get(['sp.period_start', 'sp.period_end']); $unpaidBillDates = []; foreach ($unpaidPeriodRows as $row) { foreach ($this->expandPeriodToLocalDays((string) $row->period_start, (string) $row->period_end, $timezone) as $day) { $unpaidBillDates[$day] = true; } } $suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates, $timezone); return [ 'suggested_start' => $suggested['start'], 'suggested_end' => $suggested['end'], 'settlement_timezone' => $timezone, 'occupied_period_dates' => array_keys($occupiedPeriodDates), 'pending_activity_dates' => $pendingActivityDates, 'orphan_activity_dates' => $orphanActivityDates, 'unpaid_bill_dates' => array_keys($unpaidBillDates), ]; } /** * @param list $pendingActivityDates UTC `Y-m-d` * @param array $occupiedPeriodDates * @return array{start: string, end: string} */ private function suggestRange( ?object $lastPeriod, array $pendingActivityDates, array $occupiedPeriodDates, string $timezone, ): array { $lastEndDay = $lastPeriod !== null ? Carbon::parse((string) $lastPeriod->period_end, 'UTC')->timezone($timezone)->startOfDay() : null; $freePending = array_values(array_filter( $pendingActivityDates, static fn (string $day): bool => ! isset($occupiedPeriodDates[$day]), )); if ($freePending !== []) { $minDay = Carbon::parse($freePending[0])->startOfDay(); $maxDay = Carbon::parse($freePending[array_key_last($freePending)])->startOfDay(); $startDay = $lastEndDay !== null ? ($lastEndDay->copy()->addDay()->lessThanOrEqualTo($minDay) ? $lastEndDay->copy()->addDay() : $minDay) : $minDay; $candidate = [ 'start' => $startDay->format('Y-m-d'), 'end' => $maxDay->format('Y-m-d'), ]; return $this->withoutOccupiedOverlap($candidate, $occupiedPeriodDates); } if ($lastEndDay !== null) { $startDay = $lastEndDay->copy()->addDay(); $endDay = Carbon::now($timezone)->subDay()->startOfDay(); if ($endDay->lessThan($startDay)) { return ['start' => '', 'end' => '']; } return $this->withoutOccupiedOverlap([ 'start' => $startDay->format('Y-m-d'), 'end' => $endDay->format('Y-m-d'), ], $occupiedPeriodDates); } return ['start' => '', 'end' => '']; } /** * @param array{start: string, end: string} $candidate * @param array $occupiedPeriodDates * @return array{start: string, end: string} */ private function withoutOccupiedOverlap(array $candidate, array $occupiedPeriodDates): array { if ($candidate['start'] === '' || $candidate['end'] === '') { return ['start' => '', 'end' => '']; } if ($this->rangeOverlapsOccupied($candidate['start'], $candidate['end'], $occupiedPeriodDates)) { return ['start' => '', 'end' => '']; } return $candidate; } /** * @param array $occupiedPeriodDates */ private function rangeOverlapsOccupied(string $startYmd, string $endYmd, array $occupiedPeriodDates): bool { $cursor = Carbon::parse($startYmd)->utc()->startOfDay(); $end = Carbon::parse($endYmd)->utc()->startOfDay(); while ($cursor->lessThanOrEqualTo($end)) { if (isset($occupiedPeriodDates[$cursor->format('Y-m-d')])) { return true; } $cursor->addDay(); } return false; } /** @return list 站点本地日历 `Y-m-d` */ private function expandPeriodToLocalDays(string $periodStart, string $periodEnd, string $timezone): array { $dates = []; $cursor = Carbon::parse($periodStart, 'UTC')->timezone($timezone)->startOfDay(); $end = Carbon::parse($periodEnd, 'UTC')->timezone($timezone)->startOfDay(); while ($cursor->lessThanOrEqualTo($end)) { $dates[] = $cursor->format('Y-m-d'); $cursor->addDay(); } return $dates; } /** @return array{suggested_start: string, suggested_end: string, settlement_timezone: string, occupied_period_dates: list, pending_activity_dates: list, orphan_activity_dates: list, unpaid_bill_dates: list} */ private function emptyHints(): array { return [ 'suggested_start' => '', 'suggested_end' => '', 'settlement_timezone' => (string) config('lottery.settlement.default_timezone', 'UTC'), 'occupied_period_dates' => [], 'pending_activity_dates' => [], 'orphan_activity_dates' => [], 'unpaid_bill_dates' => [], ]; } }