CaiWuXiTong/app/Business/SystemizationTodoAuditLogger.php
yueyang 48b433be4d feat: 系统化待办按今日同步并优化 Excel 导入
操作记录默认从今日显示,并增强 Excel 同步的类型保护和同名分组匹配。
2026-10-28 09:37:21 +08:00

304 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Business;
use App\Models\Auth\User;
use App\Models\Business\SystemizationTodo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
class SystemizationTodoAuditLogger
{
public const TABLE = 'business_systemization_todo_audit_logs';
public const HISTORY_LIMIT = 500;
public const ACTION_LABELS = [
'create' => '新增',
'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<string, mixed>
*/
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<int, array{
* company_id:int,
* todo_id:?int,
* action:string,
* before:?array,
* after:?array,
* user_id:?int,
* actor_name?:?string,
* meta?: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<int, array<string, mixed>>
*/
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<string, mixed>
*/
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<int, string>
*/
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;
}
}
}