'新增', 'update' => '修改', 'delete' => '删除', 'import' => '导入', ]; public const FIELD_LABELS = [ 'category' => '类型', 'section' => '内容', 'title' => '待办事项', 'owner' => '总负责人', 'assignee' => '责任人', 'start_date' => '开始时间', 'start_note' => '开始时间备注', 'due_date' => '完成时间', 'due_note' => '完成时间备注', 'status' => '状态', 'completed_at' => '完成日期', 'progress' => '完成情况', 'notes' => '备注', 'attachment_count' => '完成截图', 'sort_order' => '排序位置', ]; /** * @return array */ public function snapshot(SystemizationTodo $todo, bool $withAttachments = false): array { $snapshot = [ 'id' => (int) $todo->id, 'category' => (string) ($todo->category ?? ''), 'section' => (string) ($todo->section ?? ''), 'title' => (string) ($todo->title ?? ''), 'owner' => (string) ($todo->owner ?? ''), 'assignee' => (string) ($todo->assignee ?? ''), 'start_date' => $todo->start_date?->format('Y-m-d') ?? '', 'start_note' => (string) ($todo->start_note ?? ''), 'due_date' => $todo->due_date?->format('Y-m-d') ?? '', 'due_note' => (string) ($todo->due_note ?? ''), 'status' => $todo->statusLabel(), 'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '', 'progress' => (string) ($todo->progress ?? ''), 'notes' => (string) ($todo->notes ?? ''), 'sort_order' => (int) $todo->sort_order, 'source_key' => (string) ($todo->source_key ?? ''), ]; if ($withAttachments) { $snapshot['attachment_count'] = (int) $todo->attachments()->count(); } return $snapshot; } public function record( string $action, int $companyId, ?int $todoId, ?array $before, ?array $after, ?int $userId = null, ?string $actorName = null, array $meta = [], ): void { $this->recordMany([[ 'company_id' => $companyId, 'todo_id' => $todoId, 'action' => $action, 'before' => $before, 'after' => $after, 'user_id' => $userId, 'actor_name' => $actorName, 'meta' => $meta, ]]); } /** * @param array $events */ public function recordMany(array $events): void { if ($events === []) { return; } if (! $this->tableReady()) { throw new RuntimeException('系统化待办操作记录表尚未就绪,请先完成数据库迁移。'); } $now = $this->platformNow(); $actorNames = []; $rows = []; foreach ($events as $event) { $action = (string) ($event['action'] ?? ''); if (! array_key_exists($action, self::ACTION_LABELS)) { throw new RuntimeException('不支持的待办操作记录类型。'); } $userId = isset($event['user_id']) ? (int) $event['user_id'] : null; $actorName = trim((string) ($event['actor_name'] ?? '')); if ($actorName === '' && $userId !== null) { if (! array_key_exists($userId, $actorNames)) { $actorNames[$userId] = (string) (User::query()->whereKey($userId)->value('name') ?? ''); } $actorName = $actorNames[$userId]; } $meta = is_array($event['meta'] ?? null) ? $event['meta'] : []; $rows[] = [ 'company_id' => max(1, (int) ($event['company_id'] ?? 1)), 'todo_id' => isset($event['todo_id']) ? (int) $event['todo_id'] : null, 'action' => $action, 'before_json' => $this->json($event['before'] ?? null), 'after_json' => $this->json($event['after'] ?? null), 'actor_user_id' => $userId, 'actor_name' => mb_substr($actorName, 0, 191), 'extra_json' => $meta === [] ? null : $this->json($meta), 'at' => $now->toDateTimeString(), 'created_at' => $now->toDateTimeString(), 'updated_at' => $now->toDateTimeString(), ]; } DB::table(self::TABLE)->insert($rows); $companyIds = array_values(array_unique(array_column($rows, 'company_id'))); foreach ($companyIds as $companyId) { $maxId = (int) DB::table(self::TABLE) ->where('company_id', $companyId) ->max('id'); if ($maxId > self::HISTORY_LIMIT) { DB::table(self::TABLE) ->where('company_id', $companyId) ->where('id', '<=', $maxId - self::HISTORY_LIMIT) ->delete(); } } } /** * @return array> */ public function history( int $companyId, int $limit = self::HISTORY_LIMIT, ?string $from = null, ): array { if (! $this->tableReady()) { return []; } return DB::table(self::TABLE) ->where('company_id', $companyId) ->when($from !== null && $from !== '', fn ($query) => $query->where('at', '>=', $from)) ->orderByDesc('id') ->limit(max(1, min($limit, self::HISTORY_LIMIT))) ->get() ->map(fn (object $row): array => $this->serializeEvent($row)) ->all(); } public function platformNow(): Carbon { $timezone = trim((string) config('app.platform_timezone', '')); if ($timezone === '') { $timezone = (string) config('app.timezone', 'UTC'); } try { return Carbon::now($timezone); } catch (Throwable) { return Carbon::now((string) config('app.timezone', 'UTC')); } } /** * @return array */ private function serializeEvent(object $row): array { $before = $row->before_json ? json_decode((string) $row->before_json, true) : null; $after = $row->after_json ? json_decode((string) $row->after_json, true) : null; $before = is_array($before) ? $before : null; $after = is_array($after) ? $after : null; $meta = $row->extra_json ? json_decode((string) $row->extra_json, true) : []; $meta = is_array($meta) ? $meta : []; $source = $after ?? $before ?? []; return [ 'id' => (int) $row->id, 'at' => (string) $row->at, 'action' => (string) $row->action, 'action_label' => self::ACTION_LABELS[(string) $row->action] ?? (string) $row->action, 'todo_id' => $row->todo_id === null ? null : (int) $row->todo_id, 'category' => (string) ($source['category'] ?? ''), 'section' => (string) ($source['section'] ?? ''), 'title' => (string) ($source['title'] ?? ''), 'actor' => (string) ($row->actor_name ?? ''), 'before' => $before, 'after' => $after, 'meta' => $meta, 'changes' => $this->changes($before, $after), ]; } /** * @return array */ private function changes(?array $before, ?array $after): array { if ($after === null && $before !== null) { return ['删除前:' . $this->itemLabel($before)]; } if ($before === null && $after !== null) { return ['新增:' . $this->itemLabel($after)]; } if ($before === null && $after === null) { return ['—']; } $changes = []; foreach (self::FIELD_LABELS as $field => $label) { $old = $this->displayValue($field, $before[$field] ?? ''); $new = $this->displayValue($field, $after[$field] ?? ''); if ($old !== $new) { $changes[] = $label . ':' . ($old !== '' ? $old : '空') . ' → ' . ($new !== '' ? $new : '空'); } } return $changes === [] ? ['内容无变化'] : $changes; } private function itemLabel(array $row): string { $parts = array_values(array_filter([ trim((string) ($row['category'] ?? '')), trim((string) ($row['section'] ?? '')), trim((string) ($row['title'] ?? '')), ], static fn (string $value): bool => $value !== '')); return $parts === [] ? '未命名待办' : implode(' · ', $parts); } private function displayValue(string $field, mixed $value): string { if ($field === 'attachment_count') { return max(0, (int) $value) . ' 张'; } return trim(str_replace(["\r\n", "\r"], "\n", (string) ($value ?? ''))); } private function json(mixed $value): ?string { if ($value === null) { return null; } $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('待办操作记录序列化失败。'); } return $json; } private function tableReady(): bool { try { return DB::getSchemaBuilder()->hasTable(self::TABLE); } catch (Throwable) { return false; } } }