client->fetchDailySnapshot($day); $venues = is_array($json['venues'] ?? null) ? $json['venues'] : []; $statDate = $day->toDateString(); $written = 0; $skipped = 0; foreach ($venues as $row) { if (! is_array($row)) { continue; } $venueId = (int) ($row['venueId'] ?? 0); if ($venueId <= 0) { continue; } $enter = max(0, (int) ($row['enter'] ?? 0)); $exit = max(0, (int) ($row['exit'] ?? 0)); $passing = max(0, (int) ($row['passing'] ?? 0)); $updateCount = max(0, (int) ($row['updateCount'] ?? 0)); $lastUpdate = $this->parseLastUpdate($row['lastUpdate'] ?? null); if ($protectZeroOverwrite && $this->shouldSkipZeroOverwrite($venueId, $statDate, $enter, $exit, $passing, $updateCount)) { $skipped++; continue; } PeopleCountingDaily::query()->updateOrCreate( [ 'venue_id' => $venueId, 'stat_date' => $statDate, ], [ 'venue_name' => $this->nullableName($row['venueName'] ?? null), 'enter' => $enter, 'exit' => $exit, 'passing' => $passing, 'update_count' => $updateCount, 'last_update' => $lastUpdate, ] ); $written++; } return [ 'date' => $statDate, 'written' => $written, 'skipped' => $skipped, 'venues' => count($venues), ]; } /** * @return array{date: string, written: int, skipped: bool, data_source: string|null, venues: int} */ public function syncHourlyForDate(Carbon $day, bool $skipIfAllZero = true): array { $json = $this->client->fetchHourly($day); $venues = is_array($json['venues'] ?? null) ? $json['venues'] : []; $statDate = $day->toDateString(); $rootSource = is_string($json['dataSource'] ?? null) ? (string) $json['dataSource'] : null; $allZero = true; foreach ($venues as $row) { if (! is_array($row)) { continue; } foreach (is_array($row['hourly'] ?? null) ? $row['hourly'] : [] as $bucket) { if (! is_array($bucket)) { continue; } if (((int) ($bucket['enter'] ?? 0)) !== 0 || ((int) ($bucket['exit'] ?? 0)) !== 0 || ((int) ($bucket['passing'] ?? 0)) !== 0) { $allZero = false; break 2; } } } // 全 0 且仍是内存口径:昨日归档还没生成 / Java 小时回退未上线,写入会用假 0 盖掉已有小时 if ($skipIfAllZero && $allZero && ($rootSource === null || $rootSource === 'memory')) { return [ 'date' => $statDate, 'written' => 0, 'skipped' => true, 'data_source' => $rootSource, 'venues' => count($venues), ]; } $written = 0; foreach ($venues as $row) { if (! is_array($row)) { continue; } $venueId = (int) ($row['venueId'] ?? 0); if ($venueId <= 0) { continue; } $name = $this->nullableName($row['venueName'] ?? null); $source = is_string($row['dataSource'] ?? null) ? (string) $row['dataSource'] : $rootSource; $hours = is_array($row['hourly'] ?? null) ? $row['hourly'] : []; $byHour = []; foreach ($hours as $bucket) { if (! is_array($bucket)) { continue; } $h = (int) ($bucket['hour'] ?? -1); if ($h < 0 || $h > 23) { continue; } $byHour[$h] = [ 'enter' => max(0, (int) ($bucket['enter'] ?? 0)), 'exit' => max(0, (int) ($bucket['exit'] ?? 0)), 'passing' => max(0, (int) ($bucket['passing'] ?? 0)), ]; } for ($h = 0; $h <= 23; $h++) { $vals = $byHour[$h] ?? ['enter' => 0, 'exit' => 0, 'passing' => 0]; PeopleCountingHourly::query()->updateOrCreate( [ 'venue_id' => $venueId, 'stat_date' => $statDate, 'hour' => $h, ], [ 'venue_name' => $name, 'enter' => $vals['enter'], 'exit' => $vals['exit'], 'passing' => $vals['passing'], 'data_source' => $source, ] ); $written++; } } return [ 'date' => $statDate, 'written' => $written, 'skipped' => false, 'data_source' => $rootSource, 'venues' => count($venues), ]; } private function shouldSkipZeroOverwrite( int $venueId, string $statDate, int $enter, int $exit, int $passing, int $updateCount ): bool { if ($enter > 0 || $exit > 0 || $passing > 0 || $updateCount > 0) { return false; } $existing = PeopleCountingDaily::query() ->where('venue_id', $venueId) ->whereDate('stat_date', $statDate) ->first(); if ($existing === null) { return false; } $prev = (int) $existing->enter + (int) $existing->exit + (int) $existing->passing; return $prev > 0; } private function parseLastUpdate(mixed $raw): ?Carbon { if (! is_string($raw) || trim($raw) === '') { return null; } try { return Carbon::parse($raw); } catch (\Throwable $e) { Log::debug('people counting lastUpdate parse failed', ['raw' => $raw, 'e' => $e->getMessage()]); return null; } } private function nullableName(mixed $name): ?string { if (! is_string($name)) { return null; } $t = trim($name); return $t === '' ? null : mb_substr($t, 0, 191); } }