, * pending_activity_dates: list, * unpaid_bill_dates: list * } */ public function hints(int $adminSiteId): array { $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); if ($siteCode === '') { return $this->emptyHints(); } $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->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) { $occupiedPeriodDates[$day] = true; } } $lastPeriod = DB::table('settlement_periods') ->where('admin_site_id', $adminSiteId) ->whereIn('status', ['closed', 'completed']) ->orderByDesc('period_end') ->first(); $pendingActivityDates = 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') ->selectRaw('DATE(sl.settled_at) as activity_day') ->groupBy('activity_day') ->orderBy('activity_day') ->pluck('activity_day') ->map(static fn ($day): string => (string) $day) ->values() ->all(); $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->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) { $unpaidBillDates[$day] = true; } } $suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates); return [ 'suggested_start' => $suggested['start'], 'suggested_end' => $suggested['end'], 'occupied_period_dates' => array_keys($occupiedPeriodDates), 'pending_activity_dates' => $pendingActivityDates, '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): array { $lastEndDay = $lastPeriod !== null ? Carbon::parse((string) $lastPeriod->period_end)->utc()->startOfDay() : null; $freePending = array_values(array_filter( $pendingActivityDates, static fn (string $day): bool => ! isset($occupiedPeriodDates[$day]), )); if ($freePending !== []) { $minDay = Carbon::parse($freePending[0])->utc()->startOfDay(); $maxDay = Carbon::parse($freePending[array_key_last($freePending)])->utc()->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('UTC')->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 expandPeriodToUtcDays(string $periodStart, string $periodEnd): array { $dates = []; $tz = 'Asia/Shanghai'; $cursor = Carbon::parse($periodStart)->timezone($tz)->startOfDay(); $end = Carbon::parse($periodEnd)->timezone($tz)->startOfDay(); while ($cursor->lessThanOrEqualTo($end)) { $dates[] = $cursor->format('Y-m-d'); $cursor->addDay(); } return $dates; } /** @return array{suggested_start: string, suggested_end: string, occupied_period_dates: list, pending_activity_dates: list, unpaid_bill_dates: list} */ private function emptyHints(): array { return [ 'suggested_start' => '', 'suggested_end' => '', 'occupied_period_dates' => [], 'pending_activity_dates' => [], 'unpaid_bill_dates' => [], ]; } }