feat: 增加系统化待办操作记录并修正 Excel 同步匹配 #690
298
app/Business/SystemizationTodoAuditLogger.php
Normal file
298
app/Business/SystemizationTodoAuditLogger.php
Normal file
@ -0,0 +1,298 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,11 @@ class SystemizationTodoWorkbookImporter
|
||||
'notes' => '备注',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SystemizationTodoAuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。
|
||||
*
|
||||
@ -58,7 +63,15 @@ class SystemizationTodoWorkbookImporter
|
||||
);
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use ($rows, $companyId, $userId, &$created, &$skipped): void {
|
||||
DB::transaction(function () use (
|
||||
$rows,
|
||||
$sheetName,
|
||||
$companyId,
|
||||
$userId,
|
||||
&$created,
|
||||
&$skipped,
|
||||
): void {
|
||||
$auditEvents = [];
|
||||
foreach ($rows as $row) {
|
||||
$exists = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
@ -69,15 +82,29 @@ class SystemizationTodoWorkbookImporter
|
||||
continue;
|
||||
}
|
||||
|
||||
SystemizationTodo::withoutGlobalScopes()->create([
|
||||
$todo = SystemizationTodo::withoutGlobalScopes()->create([
|
||||
...$row['data'],
|
||||
'company_id' => $companyId,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => null,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'import',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$created++;
|
||||
}
|
||||
$this->audit->recordMany($auditEvents);
|
||||
});
|
||||
|
||||
return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName];
|
||||
@ -171,9 +198,11 @@ class SystemizationTodoWorkbookImporter
|
||||
$retainedSortOrder,
|
||||
$companyId,
|
||||
$userId,
|
||||
$sheetName,
|
||||
&$created,
|
||||
&$updated,
|
||||
): void {
|
||||
$auditEvents = [];
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
@ -185,6 +214,7 @@ class SystemizationTodoWorkbookImporter
|
||||
if (! $todo) {
|
||||
continue;
|
||||
}
|
||||
$before = $this->audit->snapshot($todo);
|
||||
$todo->source_key = null;
|
||||
$todo->syncOriginalAttribute('source_key');
|
||||
$todo->fill($rows[$rowIndex]['data']);
|
||||
@ -206,18 +236,44 @@ class SystemizationTodoWorkbookImporter
|
||||
$todo->updated_by = $userId;
|
||||
$todo->save();
|
||||
if ($contentChanged) {
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => $before,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'sync',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($newIndexes as $rowIndex) {
|
||||
SystemizationTodo::withoutGlobalScopes()->create([
|
||||
$todo = SystemizationTodo::withoutGlobalScopes()->create([
|
||||
...$rows[$rowIndex]['data'],
|
||||
'company_id' => $companyId,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$auditEvents[] = [
|
||||
'company_id' => $companyId,
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'import',
|
||||
'before' => null,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => $userId,
|
||||
'meta' => [
|
||||
'source' => 'excel',
|
||||
'mode' => 'sync',
|
||||
'sheet' => $sheetName,
|
||||
],
|
||||
];
|
||||
$created++;
|
||||
}
|
||||
|
||||
@ -229,6 +285,8 @@ class SystemizationTodoWorkbookImporter
|
||||
'source_key' => $oldSourceKeys[$todo->id] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->audit->recordMany($auditEvents);
|
||||
});
|
||||
|
||||
return [
|
||||
@ -272,11 +330,15 @@ class SystemizationTodoWorkbookImporter
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
$categoryValue = $this->cell($cells, $columns['category'] ?? null);
|
||||
$categoryValue = $this->cleanImportedLabel(
|
||||
$this->cell($cells, $columns['category'] ?? null)
|
||||
);
|
||||
if ($categoryValue !== '') {
|
||||
$category = $categoryValue;
|
||||
}
|
||||
$sectionValue = $this->cell($cells, $columns['section'] ?? null);
|
||||
$sectionValue = $this->cleanImportedLabel(
|
||||
$this->cell($cells, $columns['section'] ?? null)
|
||||
);
|
||||
if ($sectionValue !== '') {
|
||||
$section = $sectionValue;
|
||||
}
|
||||
@ -362,7 +424,7 @@ class SystemizationTodoWorkbookImporter
|
||||
static fn (string $line): bool => preg_match($marker, trim($line)) === 1,
|
||||
);
|
||||
if (count($numberedLines) < 2) {
|
||||
return [$value];
|
||||
return [$this->cleanImportedLabel($value)];
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
@ -385,7 +447,9 @@ class SystemizationTodoWorkbookImporter
|
||||
$segments[] = trim($current);
|
||||
}
|
||||
|
||||
return $segments !== [] ? $segments : [$value];
|
||||
return $segments !== []
|
||||
? $segments
|
||||
: [$this->cleanImportedLabel($value)];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -419,8 +483,8 @@ class SystemizationTodoWorkbookImporter
|
||||
$candidates[$sourceKey] ?? [],
|
||||
fn (SystemizationTodo $todo): bool =>
|
||||
! in_array($todo->id, $usedIds, true)
|
||||
&& $this->matchText($todo->category) === $this->matchText($row['category'])
|
||||
&& $this->matchText($todo->section) === $this->matchText($row['section']),
|
||||
&& $this->matchGroupKey($todo->category) === $this->matchGroupKey($row['category'])
|
||||
&& $this->matchGroupKey($todo->section) === $this->matchGroupKey($row['section']),
|
||||
));
|
||||
if ($available === []) {
|
||||
continue;
|
||||
@ -467,8 +531,8 @@ class SystemizationTodoWorkbookImporter
|
||||
foreach ($remainingIds as $todoId) {
|
||||
$todo = $todoById->get($todoId);
|
||||
if (! $todo
|
||||
|| $this->matchText($todo->category) !== $this->matchText($row['category'])
|
||||
|| $this->matchText($todo->section) !== $this->matchText($row['section'])) {
|
||||
|| $this->matchGroupKey($todo->category) !== $this->matchGroupKey($row['category'])
|
||||
|| $this->matchGroupKey($todo->section) !== $this->matchGroupKey($row['section'])) {
|
||||
continue;
|
||||
}
|
||||
$similarity = $this->titleSimilarity((string) $todo->title, $row['title']);
|
||||
@ -510,6 +574,20 @@ class SystemizationTodoWorkbookImporter
|
||||
return $this->matchText($value);
|
||||
}
|
||||
|
||||
private function matchGroupKey(?string $value): string
|
||||
{
|
||||
return $this->matchTitleKey($this->cleanImportedLabel($value));
|
||||
}
|
||||
|
||||
private function cleanImportedLabel(?string $value): string
|
||||
{
|
||||
return trim((string) preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
));
|
||||
}
|
||||
|
||||
private function matchText(?string $value): string
|
||||
{
|
||||
return mb_strtolower(
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\SystemizationTodoAuditLogger;
|
||||
use App\Business\SystemizationTodoWorkbookImporter;
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use App\Models\Business\SystemizationTodoAttachment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
@ -27,6 +29,11 @@ class SystemizationTodos extends Controller
|
||||
'none' => '无提醒',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SystemizationTodoAuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
public function page(): array
|
||||
{
|
||||
$filters = $this->filters();
|
||||
@ -202,6 +209,8 @@ class SystemizationTodos extends Controller
|
||||
'attachment' => '__ATTACHMENT__',
|
||||
]),
|
||||
'import' => route('business.systemization-todos.import'),
|
||||
'history' => route('business.systemization-todos.history'),
|
||||
'historyExport' => route('business.systemization-todos.history.export'),
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -392,10 +401,22 @@ class SystemizationTodos extends Controller
|
||||
? 1
|
||||
: min($position, $siblings->count() + 1);
|
||||
|
||||
return SystemizationTodo::query()->create([
|
||||
$todo = SystemizationTodo::query()->create([
|
||||
...$data,
|
||||
'sort_order' => $insertOrder,
|
||||
]);
|
||||
$this->audit->record(
|
||||
'create',
|
||||
(int) company_id(),
|
||||
(int) $todo->id,
|
||||
null,
|
||||
$this->audit->snapshot($todo),
|
||||
user_id(),
|
||||
null,
|
||||
['source' => 'manual'],
|
||||
);
|
||||
|
||||
return $todo;
|
||||
});
|
||||
$this->storeAttachments($request, $todo);
|
||||
|
||||
@ -529,16 +550,34 @@ class SystemizationTodos extends Controller
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
SystemizationTodo::query()
|
||||
|
||||
$todos = SystemizationTodo::query()
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
$auditEvents = [];
|
||||
foreach ($todos as $todo) {
|
||||
$before = $this->audit->snapshot($todo);
|
||||
$todo->fill([
|
||||
...$data,
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
])->save();
|
||||
$auditEvents[] = [
|
||||
'company_id' => (int) company_id(),
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'update',
|
||||
'before' => $before,
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => user_id(),
|
||||
'meta' => ['source' => 'manual', 'scope' => 'group'],
|
||||
];
|
||||
}
|
||||
$this->audit->recordMany($auditEvents);
|
||||
}
|
||||
|
||||
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
|
||||
{
|
||||
$before = $this->audit->snapshot($todo, true);
|
||||
$data = $this->validated($request);
|
||||
$this->normalizePendingDates($request, $data);
|
||||
$targetPosition = max(0, (int) ($data['position'] ?? 0));
|
||||
@ -573,18 +612,107 @@ class SystemizationTodos extends Controller
|
||||
$todo->setRawAttributes($lockedTodo->getAttributes(), true);
|
||||
});
|
||||
$this->storeAttachments($request, $todo);
|
||||
$todo->refresh();
|
||||
$after = $this->audit->snapshot($todo, true);
|
||||
if ($before !== $after) {
|
||||
$this->audit->record(
|
||||
'update',
|
||||
(int) company_id(),
|
||||
(int) $todo->id,
|
||||
$before,
|
||||
$after,
|
||||
user_id(),
|
||||
null,
|
||||
['source' => 'manual'],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->backToPage('待办事项已更新并定位。', 'success', $todo);
|
||||
}
|
||||
|
||||
public function destroy(SystemizationTodo $todo): RedirectResponse
|
||||
{
|
||||
$todo->update(['updated_by' => user_id()]);
|
||||
$todo->delete();
|
||||
DB::transaction(function () use ($todo): void {
|
||||
$before = $this->audit->snapshot($todo, true);
|
||||
$todo->update(['updated_by' => user_id()]);
|
||||
$todo->delete();
|
||||
$this->audit->record(
|
||||
'delete',
|
||||
(int) company_id(),
|
||||
(int) $todo->id,
|
||||
$before,
|
||||
null,
|
||||
user_id(),
|
||||
null,
|
||||
['source' => 'manual'],
|
||||
);
|
||||
});
|
||||
|
||||
return $this->backToPage('待办事项已删除。');
|
||||
}
|
||||
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$events = $this->audit->history((int) company_id());
|
||||
|
||||
return response()->json([
|
||||
'events' => $events,
|
||||
'total' => count($events),
|
||||
'limit' => SystemizationTodoAuditLogger::HISTORY_LIMIT,
|
||||
]);
|
||||
}
|
||||
|
||||
public function historyExport(Request $request): StreamedResponse
|
||||
{
|
||||
$action = trim((string) $request->query('action', ''));
|
||||
$keyword = trim((string) $request->query('keyword', ''));
|
||||
$rows = array_values(array_filter(
|
||||
$this->audit->history((int) company_id()),
|
||||
fn (array $row): bool => $this->historyMatches($row, $action, $keyword),
|
||||
));
|
||||
|
||||
return response()->streamDownload(function () use ($rows): void {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
fputcsv($handle, ['时间', '操作', '类型', '内容', '待办事项', '变更内容', '操作人']);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($handle, [
|
||||
$row['at'],
|
||||
$row['action_label'],
|
||||
$row['category'],
|
||||
$row['section'],
|
||||
$row['title'],
|
||||
implode(';', $row['changes']),
|
||||
$row['actor'],
|
||||
]);
|
||||
}
|
||||
fclose($handle);
|
||||
}, '系统化待办_操作记录.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function historyMatches(array $row, string $action, string $keyword): bool
|
||||
{
|
||||
if ($action !== '' && (string) ($row['action'] ?? '') !== $action) {
|
||||
return false;
|
||||
}
|
||||
if ($keyword === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = implode(' ', [
|
||||
(string) ($row['category'] ?? ''),
|
||||
(string) ($row['section'] ?? ''),
|
||||
(string) ($row['title'] ?? ''),
|
||||
(string) ($row['actor'] ?? ''),
|
||||
implode(' ', (array) ($row['changes'] ?? [])),
|
||||
]);
|
||||
|
||||
return mb_stripos($haystack, $keyword, 0, 'UTF-8') !== false;
|
||||
}
|
||||
|
||||
public function attachment(SystemizationTodo $todo, SystemizationTodoAttachment $attachment): StreamedResponse
|
||||
{
|
||||
abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404);
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('business_systemization_todo_audit_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('business_systemization_todo_audit_logs', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id')->default(1);
|
||||
$table->unsignedBigInteger('todo_id')->nullable();
|
||||
$table->string('action', 32);
|
||||
$table->longText('before_json')->nullable();
|
||||
$table->longText('after_json')->nullable();
|
||||
$table->unsignedBigInteger('actor_user_id')->nullable();
|
||||
$table->string('actor_name', 191)->nullable();
|
||||
$table->longText('extra_json')->nullable();
|
||||
$table->dateTime('at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(
|
||||
['company_id', 'at'],
|
||||
'systemization_todo_audit_company_at_index'
|
||||
);
|
||||
$table->index(
|
||||
['company_id', 'todo_id', 'id'],
|
||||
'systemization_todo_audit_company_todo_index'
|
||||
);
|
||||
$table->index(
|
||||
['company_id', 'action', 'id'],
|
||||
'systemization_todo_audit_company_action_index'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_systemization_todo_audit_logs');
|
||||
}
|
||||
};
|
||||
@ -11,6 +11,7 @@
|
||||
.sys-todo-page__button--primary { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; }
|
||||
.sys-todo-page__button--primary:hover { background:#6a5129; color:#fff; }
|
||||
.sys-todo-page__button .material-icons-outlined { font-size:1rem; }
|
||||
.sys-todo-page__head-actions .sys-todo-page__button { min-height:2.125rem; }
|
||||
.sys-todo-page__intro { margin:.35rem 0 0; color:var(--sys-muted); font-size:.75rem; line-height:1.6; }
|
||||
.sys-todo-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.55rem; margin:.8rem 0; }
|
||||
.sys-todo-summary__item { display:flex; align-items:center; justify-content:space-between; gap:.6rem; padding:.5rem .7rem; border:1px solid #e6dfd2; border-radius:.5rem; background:#fffdf9; }
|
||||
@ -111,6 +112,7 @@
|
||||
.sys-todo-import label { display:grid; gap:.3rem; color:#595959; font-size:.75rem; }
|
||||
.sys-todo-import input { font:inherit; font-size:.8125rem; }
|
||||
.sys-todo-dialog { width:min(64rem,calc(100vw - 2rem)); max-height:90vh; padding:0; border:1px solid var(--sys-border); border-radius:1rem; background:#fff; color:#171717; overflow:hidden; }
|
||||
.sys-todo-dialog--history { width:min(82rem,calc(100vw - 2rem)); }
|
||||
.sys-todo-dialog::backdrop { background:rgba(23,23,23,.5); }
|
||||
.sys-todo-dialog__head { display:flex; align-items:center; gap:1rem; padding:1rem 1.25rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; }
|
||||
.sys-todo-dialog__head h2 { margin:0; font-size:1rem; }
|
||||
@ -176,6 +178,30 @@
|
||||
.sys-todo-dialog__foot .sys-todo-dialog__delete { margin-right:auto; border-color:#e7c3c3; color:#b80000; }
|
||||
.sys-todo-dialog__foot .sys-todo-dialog__delete:hover { background:#fff3f3; }
|
||||
.sys-todo-dialog__foot .sys-todo-dialog__delete[hidden] { display:none; }
|
||||
.sys-todo-history-tools { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.75rem; }
|
||||
.sys-todo-history-tools select, .sys-todo-history-tools input { min-height:2.1rem; padding:0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.78rem; }
|
||||
.sys-todo-history-tools input { flex:1 1 16rem; min-width:12rem; }
|
||||
.sys-todo-history-tools button { min-height:2.1rem; padding:0 .75rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.78rem; cursor:pointer; }
|
||||
.sys-todo-history-tools button:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); }
|
||||
.sys-todo-history-meta { color:#666; font-size:.72rem; }
|
||||
.sys-todo-history-scroll { max-height:calc(90vh - 13rem); overflow:auto; border:1px solid var(--sys-border); border-radius:.55rem; }
|
||||
.sys-todo-history-table { width:100%; min-width:68rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
|
||||
.sys-todo-history-table th { position:sticky; top:0; z-index:1; padding:.55rem .65rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
|
||||
.sys-todo-history-table td { padding:.6rem .65rem; border-bottom:1px solid #eee7da; color:#302b24; line-height:1.55; vertical-align:top; overflow-wrap:anywhere; }
|
||||
.sys-todo-history-table tbody tr:hover td { background:#fdfbf6; }
|
||||
.sys-todo-history-table__at { width:9.5rem; white-space:nowrap; }
|
||||
.sys-todo-history-table__action { width:5rem; white-space:nowrap; }
|
||||
.sys-todo-history-table__item { width:18rem; }
|
||||
.sys-todo-history-table__actor { width:7rem; white-space:nowrap; }
|
||||
.sys-todo-history-tag { display:inline-flex; align-items:center; padding:.08rem .42rem; border:1px solid #d9cfbd; border-radius:999px; background:#faf6ee; color:#51483c; font-size:.7rem; font-weight:600; }
|
||||
.sys-todo-history-tag--create,.sys-todo-history-tag--import { border-color:#bad0ac; background:#f2f8ed; color:#53793e; }
|
||||
.sys-todo-history-tag--update { border-color:#dbbf7a; background:#fff7df; color:#935f07; }
|
||||
.sys-todo-history-tag--delete { border-color:#e7c3c3; background:#fff3f3; color:#b80000; }
|
||||
.sys-todo-history-item-title { margin-top:.2rem; color:#595959; font-size:.72rem; }
|
||||
.sys-todo-history-changes { margin:0; padding-left:1rem; }
|
||||
.sys-todo-history-changes li + li { margin-top:.22rem; }
|
||||
.sys-todo-history-empty { padding:1.5rem 1rem; color:#666; font-size:.8rem; text-align:center; }
|
||||
.sys-todo-history-empty[hidden] { display:none; }
|
||||
.sys-todo-confirm { width:min(30rem,calc(100vw - 2rem)); padding:0; border:1px solid var(--sys-border); border-radius:.8rem; background:#fff; color:#171717; overflow:hidden; }
|
||||
.sys-todo-confirm::backdrop { background:rgba(23,23,23,.58); }
|
||||
.sys-todo-confirm__head { display:flex; align-items:center; gap:.55rem; padding:.9rem 1rem; border-bottom:1px solid #e8e0d2; background:#fff8f8; color:#b80000; font-weight:600; }
|
||||
@ -224,6 +250,12 @@
|
||||
</div>
|
||||
<p class="sys-todo-page__intro">日常过会使用。在类型、内容或序号旁直接添加,筛选修改后仍保持当前筛选结果。</p>
|
||||
</div>
|
||||
<div class="sys-todo-page__head-actions">
|
||||
<button type="button" class="sys-todo-page__button" data-sys-todo-history-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">history</span>
|
||||
操作记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -747,6 +779,43 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="sys-todo-dialog sys-todo-dialog--history" id="sysTodoHistoryDialog" aria-labelledby="sysTodoHistoryDialogTitle">
|
||||
<div class="sys-todo-dialog__head">
|
||||
<h2 id="sysTodoHistoryDialogTitle">系统化待办 · 操作记录</h2>
|
||||
<button type="button" class="sys-todo-dialog__close" data-sys-todo-history-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="sys-todo-dialog__body">
|
||||
<div class="sys-todo-history-tools">
|
||||
<select data-sys-todo-history-action aria-label="按操作筛选">
|
||||
<option value="">全部操作</option>
|
||||
<option value="create">新增</option>
|
||||
<option value="update">修改</option>
|
||||
<option value="delete">删除</option>
|
||||
<option value="import">导入</option>
|
||||
</select>
|
||||
<input type="search" data-sys-todo-history-keyword placeholder="搜类型、内容、待办事项、变更内容或操作人" aria-label="搜索操作记录">
|
||||
<button type="button" data-sys-todo-history-export>导出记录 CSV</button>
|
||||
<span class="sys-todo-history-meta" data-sys-todo-history-meta></span>
|
||||
<span class="sys-todo-history-meta">时间为北京时间,保留最近 500 条</span>
|
||||
</div>
|
||||
<div class="sys-todo-history-scroll">
|
||||
<table class="sys-todo-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sys-todo-history-table__at">时间</th>
|
||||
<th class="sys-todo-history-table__action">操作</th>
|
||||
<th class="sys-todo-history-table__item">类型 / 内容 / 待办事项</th>
|
||||
<th>变更内容</th>
|
||||
<th class="sys-todo-history-table__actor">操作人</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-sys-todo-history-body></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="sys-todo-history-empty" data-sys-todo-history-empty hidden>暂无操作记录。</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog class="sys-todo-confirm" id="sysTodoDeleteConfirm" aria-labelledby="sysTodoDeleteConfirmTitle">
|
||||
<form method="POST" data-sys-todo-delete-form>
|
||||
@csrf
|
||||
@ -847,6 +916,13 @@
|
||||
var groupLabel = groupDialog.querySelector('[data-sys-todo-group-label]');
|
||||
var groupInput = groupDialog.querySelector('[data-sys-todo-group-input]');
|
||||
var groupNote = groupDialog.querySelector('[data-sys-todo-group-note]');
|
||||
var historyDialog = document.getElementById('sysTodoHistoryDialog');
|
||||
var historyAction = historyDialog.querySelector('[data-sys-todo-history-action]');
|
||||
var historyKeyword = historyDialog.querySelector('[data-sys-todo-history-keyword]');
|
||||
var historyExport = historyDialog.querySelector('[data-sys-todo-history-export]');
|
||||
var historyMeta = historyDialog.querySelector('[data-sys-todo-history-meta]');
|
||||
var historyBody = historyDialog.querySelector('[data-sys-todo-history-body]');
|
||||
var historyEmpty = historyDialog.querySelector('[data-sys-todo-history-empty]');
|
||||
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
var positionGroups = @json($todoPositionGroups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
var contentOptions = @json($todoContentOptions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@ -873,6 +949,7 @@
|
||||
return Number(column.getAttribute('data-default-width') || 100);
|
||||
});
|
||||
var columnWidths = defaultColumnWidths.slice();
|
||||
var historyEvents = [];
|
||||
|
||||
function decodePayload(value) {
|
||||
var binary = atob(value);
|
||||
@ -1494,7 +1571,134 @@
|
||||
else deleteConfirm.removeAttribute('open');
|
||||
}
|
||||
|
||||
function historyFilteredEvents() {
|
||||
var action = historyAction.value;
|
||||
var keyword = historyKeyword.value.trim().toLowerCase();
|
||||
return historyEvents.filter(function (event) {
|
||||
if (action && event.action !== action) return false;
|
||||
if (!keyword) return true;
|
||||
var changes = Array.isArray(event.changes) ? event.changes.join(' ') : '';
|
||||
var haystack = [
|
||||
event.category,
|
||||
event.section,
|
||||
event.title,
|
||||
event.actor,
|
||||
changes
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.indexOf(keyword) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function appendHistoryChanges(cell, event) {
|
||||
var changes = Array.isArray(event.changes) && event.changes.length
|
||||
? event.changes
|
||||
: ['—'];
|
||||
if (changes.length === 1) {
|
||||
cell.textContent = changes[0];
|
||||
return;
|
||||
}
|
||||
var list = document.createElement('ul');
|
||||
list.className = 'sys-todo-history-changes';
|
||||
changes.forEach(function (change) {
|
||||
var item = document.createElement('li');
|
||||
item.textContent = change;
|
||||
list.appendChild(item);
|
||||
});
|
||||
cell.appendChild(list);
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
var events = historyFilteredEvents();
|
||||
historyBody.replaceChildren();
|
||||
events.forEach(function (event) {
|
||||
var row = document.createElement('tr');
|
||||
|
||||
var at = document.createElement('td');
|
||||
at.className = 'sys-todo-history-table__at';
|
||||
at.textContent = event.at || '—';
|
||||
row.appendChild(at);
|
||||
|
||||
var action = document.createElement('td');
|
||||
action.className = 'sys-todo-history-table__action';
|
||||
var tag = document.createElement('span');
|
||||
tag.className = 'sys-todo-history-tag sys-todo-history-tag--' + (event.action || '');
|
||||
tag.textContent = event.action_label || event.action || '—';
|
||||
action.appendChild(tag);
|
||||
row.appendChild(action);
|
||||
|
||||
var item = document.createElement('td');
|
||||
item.className = 'sys-todo-history-table__item';
|
||||
var category = document.createElement('div');
|
||||
category.textContent = [event.category, event.section].filter(Boolean).join(' / ') || '—';
|
||||
var title = document.createElement('div');
|
||||
title.className = 'sys-todo-history-item-title';
|
||||
title.textContent = event.title || '—';
|
||||
item.appendChild(category);
|
||||
item.appendChild(title);
|
||||
row.appendChild(item);
|
||||
|
||||
var changes = document.createElement('td');
|
||||
appendHistoryChanges(changes, event);
|
||||
row.appendChild(changes);
|
||||
|
||||
var actor = document.createElement('td');
|
||||
actor.className = 'sys-todo-history-table__actor';
|
||||
actor.textContent = event.actor || '—';
|
||||
row.appendChild(actor);
|
||||
historyBody.appendChild(row);
|
||||
});
|
||||
|
||||
historyEmpty.hidden = events.length > 0;
|
||||
historyEmpty.textContent = historyEvents.length
|
||||
? '没有符合条件的操作记录。'
|
||||
: '暂无操作记录;从本次功能上线后的第一次新增、修改、删除或导入开始记录。';
|
||||
historyMeta.textContent = '显示 ' + events.length + ' / ' + historyEvents.length + ' 条';
|
||||
var params = new URLSearchParams();
|
||||
if (historyAction.value) params.set('action', historyAction.value);
|
||||
if (historyKeyword.value.trim()) params.set('keyword', historyKeyword.value.trim());
|
||||
historyExport.setAttribute(
|
||||
'data-export-url',
|
||||
routes.historyExport + (params.toString() ? '?' + params.toString() : '')
|
||||
);
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
historyMeta.textContent = '正在读取…';
|
||||
fetch(routes.history, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(function (response) {
|
||||
return response.json().catch(function () { return {}; }).then(function (data) {
|
||||
if (!response.ok) throw new Error(data.message || '读取失败(HTTP ' + response.status + ')');
|
||||
return data;
|
||||
});
|
||||
}).then(function (data) {
|
||||
historyEvents = Array.isArray(data.events) ? data.events : [];
|
||||
renderHistory();
|
||||
}).catch(function (error) {
|
||||
historyEvents = [];
|
||||
historyBody.replaceChildren();
|
||||
historyEmpty.hidden = false;
|
||||
historyEmpty.textContent = '操作记录读取失败:' + (error && error.message ? error.message : error);
|
||||
historyMeta.textContent = '读取失败';
|
||||
});
|
||||
}
|
||||
|
||||
function openHistoryDialog() {
|
||||
if (typeof historyDialog.showModal === 'function') historyDialog.showModal();
|
||||
else historyDialog.setAttribute('open', '');
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function closeHistoryDialog() {
|
||||
if (typeof historyDialog.close === 'function') historyDialog.close();
|
||||
else historyDialog.removeAttribute('open');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (event.target.closest('[data-sys-todo-history-open]')) {
|
||||
openHistoryDialog();
|
||||
return;
|
||||
}
|
||||
var gallery = event.target.closest('[data-sys-todo-image-gallery]');
|
||||
if (gallery) {
|
||||
event.preventDefault();
|
||||
@ -1575,6 +1779,7 @@
|
||||
if (event.target.closest('[data-sys-todo-close]')) closeDialog();
|
||||
if (event.target.closest('[data-sys-todo-group-close]')) closeGroupDialog();
|
||||
if (event.target.closest('[data-sys-todo-delete-close]')) closeDeleteConfirm();
|
||||
if (event.target.closest('[data-sys-todo-history-close]')) closeHistoryDialog();
|
||||
if (event.target.closest('[data-sys-todo-image-close]')) closeImageViewer();
|
||||
if (event.target.closest('[data-sys-todo-image-prev]')) showPreviousImage();
|
||||
if (event.target.closest('[data-sys-todo-image-next]')) showNextImage();
|
||||
@ -1602,6 +1807,15 @@
|
||||
deleteConfirm.addEventListener('click', function (event) {
|
||||
if (event.target === deleteConfirm) closeDeleteConfirm();
|
||||
});
|
||||
historyDialog.addEventListener('click', function (event) {
|
||||
if (event.target === historyDialog) closeHistoryDialog();
|
||||
});
|
||||
historyAction.addEventListener('change', renderHistory);
|
||||
historyKeyword.addEventListener('input', renderHistory);
|
||||
historyExport.addEventListener('click', function () {
|
||||
var url = historyExport.getAttribute('data-export-url');
|
||||
if (url) window.location.href = url;
|
||||
});
|
||||
categorySelect.addEventListener('change', function () {
|
||||
syncCategoryValue();
|
||||
refreshSectionForCategory();
|
||||
|
||||
@ -568,6 +568,11 @@ Route::get('workspace/courier-payables/third-party-warehouse-storage/settlement-
|
||||
->middleware('business.page:courier-payables,third-party-warehouse-storage')
|
||||
->name('business.courier-payables.third-party-warehouse-storage.settlement-export');
|
||||
|
||||
// 必须注册在通用 {section}/{page}/{tab}/export 之前,否则会被通用导出路由截获。
|
||||
Route::get('workspace/other/systemization-todos/history/export', [SystemizationTodos::class, 'historyExport'])
|
||||
->middleware('business.page:other,systemization-todos')
|
||||
->name('business.systemization-todos.history.export');
|
||||
|
||||
Route::middleware('business.page.viewer')->group(function (): void {
|
||||
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
|
||||
->name('business.pages.export');
|
||||
@ -785,6 +790,7 @@ Route::prefix('workspace/other/systemization-todos')
|
||||
->middleware('import')
|
||||
->name('import');
|
||||
Route::post('/', [SystemizationTodos::class, 'store'])->name('store');
|
||||
Route::get('history', [SystemizationTodos::class, 'history'])->name('history');
|
||||
Route::patch('{todo}/category', [SystemizationTodos::class, 'updateCategory'])
|
||||
->whereNumber('todo')
|
||||
->name('category');
|
||||
|
||||
@ -39,6 +39,9 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
->assertSeeText('完成情况 / 备注')
|
||||
->assertSeeText('提醒')
|
||||
->assertSee('data-sys-todo-default-start-date', false)
|
||||
->assertSee('data-sys-todo-history-open', false)
|
||||
->assertSee('id="sysTodoHistoryDialog"', false)
|
||||
->assertSee('data-sys-todo-history-export', false)
|
||||
->assertSee('data-sys-todo-inline-add', false)
|
||||
->assertSee('data-sys-todo-col-resizer', false)
|
||||
->assertSee('data-sys-todo-reset-columns', false)
|
||||
@ -857,6 +860,121 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_manual_changes_are_listed_in_operation_history_and_csv(): void
|
||||
{
|
||||
$this->loginAs();
|
||||
|
||||
$this->post(route('business.systemization-todos.store'), [
|
||||
'category' => '收入',
|
||||
'section' => '操作记录测试',
|
||||
'title' => '原待办',
|
||||
'owner' => '晨曦',
|
||||
'assignee' => '栎阳',
|
||||
'start_date' => '2026-10-27',
|
||||
'due_date' => '2026-10-30',
|
||||
'status' => SystemizationTodo::STATUS_INCOMPLETE,
|
||||
])->assertRedirect();
|
||||
|
||||
$todo = SystemizationTodo::query()->firstOrFail();
|
||||
$this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [
|
||||
'category' => '收入',
|
||||
'section' => '操作记录测试',
|
||||
'title' => '修改后的待办',
|
||||
'owner' => '晨曦、人参',
|
||||
'assignee' => '栎阳',
|
||||
'start_date' => '2026-10-27',
|
||||
'due_date' => '2026-10-31',
|
||||
'status' => SystemizationTodo::STATUS_COMPLETED,
|
||||
'progress' => '已完成并留档',
|
||||
])->assertRedirect();
|
||||
|
||||
$this->delete(route('business.systemization-todos.destroy', ['todo' => $todo->id]))
|
||||
->assertRedirect();
|
||||
|
||||
$history = $this->getJson(route('business.systemization-todos.history'))
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
$this->assertSame(['delete', 'update', 'create'], array_column($history['events'], 'action'));
|
||||
$this->assertSame($this->user->name, $history['events'][0]['actor']);
|
||||
$this->assertSame('修改后的待办', $history['events'][1]['title']);
|
||||
$this->assertContains('待办事项:原待办 → 修改后的待办', $history['events'][1]['changes']);
|
||||
$this->assertContains('状态:未完成 → 已完成', $history['events'][1]['changes']);
|
||||
$this->assertStringContainsString('删除前:收入 · 操作记录测试 · 修改后的待办', $history['events'][0]['changes'][0]);
|
||||
|
||||
$csv = $this->get(
|
||||
route('business.systemization-todos.history.export') . '?action=update'
|
||||
)->streamedContent();
|
||||
$this->assertStringContainsString('时间,操作,类型,内容,待办事项,变更内容,操作人', $csv);
|
||||
$this->assertStringContainsString('修改,收入,操作记录测试,修改后的待办', $csv);
|
||||
$this->assertStringNotContainsString('删除前:', $csv);
|
||||
$this->assertStringNotContainsString('新增:', $csv);
|
||||
}
|
||||
|
||||
public function test_excel_sync_audits_changes_and_matches_numbered_group_labels_without_duplicates(): void
|
||||
{
|
||||
$this->loginAs();
|
||||
|
||||
$todo = $this->createTodo(
|
||||
'收入',
|
||||
'平台收入核对',
|
||||
'平台收入核对',
|
||||
'2026-11-30',
|
||||
10,
|
||||
);
|
||||
$todo->update(['imported_at' => now()]);
|
||||
|
||||
$path = sys_get_temp_dir() . '/systemization-numbered-sync-' . uniqid() . '.xlsx';
|
||||
try {
|
||||
$this->writeWorkbook($path, [[
|
||||
'1、收入',
|
||||
'2、平台收入核对',
|
||||
'1、平台收入核对(已更新)',
|
||||
'晨曦',
|
||||
'栎阳',
|
||||
'2026-10-27',
|
||||
'2026-11-30',
|
||||
null,
|
||||
null,
|
||||
]]);
|
||||
|
||||
$preview = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($path, (int) company_id(), user_id(), true);
|
||||
$this->assertSame(0, $preview['created']);
|
||||
$this->assertSame(1, $preview['updated']);
|
||||
$this->assertSame(0, $preview['retained']);
|
||||
$this->assertDatabaseCount('business_systemization_todo_audit_logs', 0);
|
||||
|
||||
$result = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($path, (int) company_id(), user_id());
|
||||
$this->assertSame(0, $result['created']);
|
||||
$this->assertSame(1, $result['updated']);
|
||||
$this->assertSame(0, $result['retained']);
|
||||
$this->assertSame(1, SystemizationTodo::query()->count());
|
||||
|
||||
$todo->refresh();
|
||||
$this->assertSame('收入', $todo->category);
|
||||
$this->assertSame('平台收入核对', $todo->section);
|
||||
$this->assertSame('平台收入核对(已更新)', $todo->title);
|
||||
$this->assertDatabaseHas('business_systemization_todo_audit_logs', [
|
||||
'company_id' => (int) company_id(),
|
||||
'todo_id' => $todo->id,
|
||||
'action' => 'import',
|
||||
]);
|
||||
|
||||
$event = $this->getJson(route('business.systemization-todos.history'))
|
||||
->assertOk()
|
||||
->json('events.0');
|
||||
$this->assertSame('import', $event['action']);
|
||||
$this->assertContains(
|
||||
'待办事项:平台收入核对 → 平台收入核对(已更新)',
|
||||
$event['changes']
|
||||
);
|
||||
} finally {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_excel_sync_splits_numbered_titles_and_keeps_attachment_on_first_item(): void
|
||||
{
|
||||
Storage::fake('uploads');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user