186 lines
7.4 KiB
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);
|
|
}
|
|
}
|