CaiWuXiTong/app/Console/Commands/SyncDingAttendance.php

186 lines
7.4 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Services\Auth\FinanceRoster;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Exceptions\DingTalkAttendanceException;
use Modules\EcommerceFinance\Repositories\DingAttendanceMirrorRepository;
use Modules\EcommerceFinance\Services\DingTalkAttendanceClient;
use Throwable;
class SyncDingAttendance extends Command
{
protected $signature = 'business-mirror:sync-ding-attendance
{--days= : 回看天数,默认使用配置值}
{--from= : 开始日期 YYYY-MM-DD}
{--to= : 结束日期 YYYY-MM-DD}';
protected $description = '同步财务中心成员的钉钉考勤打卡记录';
public function handle(
DingTalkAttendanceClient $client,
DingAttendanceMirrorRepository $repository,
FinanceRoster $financeRoster,
): int {
if (! (bool) config('dingtalk.attendance.enabled', true)) {
return self::SUCCESS;
}
if (! $repository->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);
}
}