CaiWuXiTong/app/Business/SystemizationTodoAuditLogger.php
yueyang 9f67e2a308 feat: 增加系统化待办操作记录并修正 Excel 同步匹配 (#690)
## 任务占用与边界

- 任务名称:系统化待办操作记录与 Excel 同步修正
- 实际开发者:Codex
- 任务来源:负责人要求对照新版工作簿同步待办,并提供类似科目映射表的操作记录
- 目标结果:新增、修改、删除、Excel 导入均可追溯;修正工作簿编号前缀导致的重复匹配风险
- 预计修改路径:系统化待办导入器、页面、控制器、路由、操作记录表迁移及专项测试
- 将写入的公共契约:新增 business_systemization_todo_audit_logs、操作记录查询及 CSV 导出路由
- 仅复用、不修改的公共契约:现有待办表、附件表、分页和筛选结构
- 数据源、表/视图和字段:新版 10.19 工作梳理 Excel;待办表现有字段
- 上游 source_key / scope:不涉及上游
- 权限名、菜单键或共享组件:复用现有 business.page:other,systemization-todos
- 明确不修改的范围:不删除工作簿外事项,不覆盖完成截图

## 实际改动

- 新增操作记录:类型、内容、待办事项、负责人、责任人、日期、状态、完成情况、备注、排序、截图数量
- 支持按操作类型和关键词筛选,支持导出 CSV,保留最近 500 条
- 手工新增、编辑、删除、类型/内容批量修改、Excel 导入均写入操作记录
- Excel 同步会去除类型、内容和待办事项前的文字序号,并按无编号分组匹配,避免重复新增
- 保留工作簿外事项和已有截图

## 验证证据

- 待办专项测试:16 tests,299 assertions
- 覆盖:手工新增/修改/删除、操作记录查询、CSV 导出、Excel 新增/修改/保留截图、编号前缀匹配、干跑不落日志
- 页面 Blade 在测试中正常渲染
- 本次发布后将在线上实际导入新版工作簿并核验操作记录

## 风险检查

- [x] 涉及数据库迁移:仅新增 systemization todo audit logs 表,不影响既有业务表
- [x] 迁移已由任务内核对并随正式发布执行
- [x] 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [x] 前端改动符合 DESIGN.md,复用 VIP 黑金设计令牌

## 合并前复查

- [x] 已基于最新 main 开发
- [x] 已运行目标专项测试并通过
- [x] 已逐文件核对实际 diff
- [x] 允许任务 Codex 完成 Squash merge、验证 main、清理分支并部署

Reviewed-on: #690
2026-10-27 15:45:11 +08:00

299 lines
9.9 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): array
{
if (! $this->tableReady()) {
return [];
}
return DB::table(self::TABLE)
->where('company_id', $companyId)
->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;
}
}
}