available()) { $this->error('考勤镜像表尚未建立,请先执行数据库迁移。'); return self::FAILURE; } if (! $client->isConfigured()) { $repository->recordFailure('dingtalk_credentials_missing'); $this->error('钉钉应用凭据未配置。'); return self::FAILURE; } try { $to = $this->dateOption('to')?->endOfDay() ?? now()->endOfDay(); $from = $this->dateOption('from')?->startOfDay() ?? $to->copy()->subDays(max((int) ($this->option('days') ?: config('dingtalk.attendance.lookback_days', 35)), 1))->startOfDay(); if ($from->greaterThan($to)) { throw new \InvalidArgumentException('考勤同步开始日期不能晚于结束日期。'); } $members = $financeRoster->cachedFinanceMembers(); $names = []; $userIds = []; foreach ($members as $member) { $userId = trim((string) ($member['userid'] ?? '')); if ($userId === '') { continue; } $userIds[$userId] = $userId; $names[$userId] = trim((string) ($member['name'] ?? '')); } if ($userIds === []) { throw new \RuntimeException('财务中心没有可同步考勤的在职成员。'); } $now = now(); $rows = []; $pages = 0; for ($windowStart = $from->copy(); $windowStart->lessThanOrEqualTo($to); $windowStart = $windowEnd->copy()->addSecond()->startOfDay()) { $windowEnd = $windowStart->copy()->addDays(6)->endOfDay(); if ($windowEnd->greaterThan($to)) { $windowEnd = $to->copy(); } foreach (array_chunk(array_values($userIds), 50) as $userChunk) { $result = $client->listRecords($userChunk, $windowStart, $windowEnd); $pages += (int) $result['pages']; foreach ($result['records'] as $record) { if (! is_array($record)) { continue; } $recordId = trim((string) ($record['id'] ?? '')); $userId = trim((string) ($record['userId'] ?? '')); $workDate = $this->dateValue($record['workDate'] ?? null); if ($recordId === '' || $userId === '' || $workDate === null) { continue; } $rows[$recordId] = [ 'record_id' => mb_substr($recordId, 0, 64), 'user_id' => mb_substr($userId, 0, 128), 'user_name' => mb_substr($names[$userId] ?? '', 0, 191), 'work_date' => $workDate, 'check_type' => $this->nullableString($record['checkType'] ?? null, 32), 'time_result' => $this->nullableString($record['timeResult'] ?? null, 64), 'approve_tag_name' => $this->nullableString($record['approveTagName'] ?? null, 191), 'approve_id' => $this->nullableString($record['approveId'] ?? null, 64), 'proc_inst_id' => $this->nullableString($record['procInstId'] ?? null, 191), 'source_type' => $this->nullableString($record['sourceType'] ?? null, 32), 'base_check_time' => $this->dateTimeValue($record['baseCheckTime'] ?? null), 'user_check_time' => $this->dateTimeValue($record['userCheckTime'] ?? null), 'synced_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; } } } $upserted = $repository->upsertRecords(array_values($rows)); $repository->recordSuccess($upserted, $from, $to); $this->info(sprintf( '钉钉考勤同步完成:读取 %d 页,写入/更新 %d 条。', $pages, $upserted, )); return self::SUCCESS; } catch (DingTalkAttendanceException $exception) { $repository->recordFailure($exception->errorCode); report($exception); $this->error($exception->getMessage()); return self::FAILURE; } catch (Throwable $exception) { $repository->recordFailure('dingtalk_attendance_sync_failed'); report($exception); $this->error('钉钉考勤同步失败,详细原因已记录到受保护日志。'); return self::FAILURE; } } private function dateOption(string $name): ?Carbon { $value = trim((string) $this->option($name)); if ($value === '') { return null; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value) !== 1) { throw new \InvalidArgumentException('考勤同步日期格式应为 YYYY-MM-DD。'); } return Carbon::createFromFormat('!Y-m-d', $value, config('app.timezone', 'Asia/Shanghai')); } private function dateValue(mixed $value): ?string { $dateTime = $this->dateTimeValue($value); return $dateTime === null ? null : substr($dateTime, 0, 10); } private function dateTimeValue(mixed $value): ?string { if ($value === null || $value === '') { return null; } try { $timezone = (string) config('app.timezone', 'Asia/Shanghai'); if (is_numeric($value)) { $timestamp = (int) $value; return $timestamp > 99999999999 ? Carbon::createFromTimestampMs($timestamp, $timezone)->toDateTimeString() : Carbon::createFromTimestamp($timestamp, $timezone)->toDateTimeString(); } return Carbon::parse((string) $value, $timezone)->toDateTimeString(); } catch (Throwable) { return null; } } private function nullableString(mixed $value, int $length): ?string { if (! is_scalar($value)) { return null; } $value = trim((string) $value); return $value === '' ? null : mb_substr($value, 0, $length); } }