CaiWuXiTong/app/Http/Controllers/Business/SystemizationTodos.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

998 lines
38 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\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;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class SystemizationTodos extends Controller
{
private const OVERDUE_FILTER = '__overdue__';
private const REMINDER_OPTIONS = [
'overdue' => '已逾期',
'today' => '今天到期',
'upcoming' => '剩余天数',
'pending' => '时间待定',
'none' => '无提醒',
];
public function __construct(
private readonly SystemizationTodoAuditLogger $audit,
) {
}
public function page(): array
{
$filters = $this->filters();
$query = SystemizationTodo::query()
->with('attachments')
->withCount('attachments');
if ($filters['status'] === self::OVERDUE_FILTER) {
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereNotNull('due_date')
->whereDate('due_date', '<', today());
} elseif ($filters['status'] === SystemizationTodo::STATUS_INCOMPLETE) {
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED);
} elseif ($filters['status'] === SystemizationTodo::STATUS_COMPLETED) {
$query->where('status', $filters['status']);
}
if ($filters['reminder'] !== '') {
if ($filters['reminder'] === 'overdue') {
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereNotNull('due_date')
->whereDate('due_date', '<', today());
} elseif ($filters['reminder'] === 'today') {
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereDate('due_date', today());
} elseif ($filters['reminder'] === 'upcoming') {
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereDate('due_date', '>', today());
} elseif ($filters['reminder'] === 'pending') {
$query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereNull('due_date');
} elseif ($filters['reminder'] === 'none') {
$query->where('status', SystemizationTodo::STATUS_COMPLETED);
}
}
if ($filters['category'] !== '') {
$query->where('category', $filters['category']);
}
if ($filters['owners'] !== []) {
$query->where(function ($builder) use ($filters): void {
foreach ($filters['owners'] as $owner) {
$builder->orWhere(
'owner',
'like',
'%' . addcslashes($owner, '%_\\') . '%',
);
}
});
}
if ($filters['assignees'] !== []) {
$query->where(function ($builder) use ($filters): void {
foreach ($filters['assignees'] as $assignee) {
$builder->orWhere(
'assignee',
'like',
'%' . addcslashes($assignee, '%_\\') . '%',
);
}
});
}
if ($filters['start_date'] !== '') {
$query->whereDate('start_date', $filters['start_date']);
}
if ($filters['due_date'] !== '') {
$query->whereDate('due_date', $filters['due_date']);
}
if ($filters['keyword'] !== '') {
$like = '%' . addcslashes($filters['keyword'], '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('category', 'like', $like)
->orWhere('section', 'like', $like)
->orWhere('owner', 'like', $like)
->orWhere('assignee', 'like', $like)
->orWhere('progress', 'like', $like)
->orWhere('notes', 'like', $like);
});
}
$items = $query
->select($query->qualifyColumn('*'))
->orderBy('sort_order')
->orderBy('id')
->get();
$sequenceMap = $this->hierarchySequenceMap();
$items->each(function (SystemizationTodo $todo) use ($sequenceMap): void {
$sequence = $sequenceMap[$todo->id] ?? [
'type_sequence' => 0,
'content_sequence' => 0,
'item_sequence' => 0,
'order' => PHP_INT_MAX,
'category_display' => '',
'section_display' => '',
'type_count' => 0,
'content_count' => 0,
];
$todo->setAttribute('type_sequence', $sequence['type_sequence']);
$todo->setAttribute('content_sequence', $sequence['content_sequence']);
$todo->setAttribute('item_sequence', $sequence['item_sequence']);
$todo->setAttribute('hierarchy_order', $sequence['order']);
$todo->setAttribute('category_display', $sequence['category_display']);
$todo->setAttribute('section_display', $sequence['section_display']);
$todo->setAttribute('type_count', $sequence['type_count']);
$todo->setAttribute('content_count', $sequence['content_count']);
$todo->setAttribute('status_label', $todo->statusLabel());
$reminder = $todo->reminder();
$todo->setAttribute('reminder_label', $reminder['label']);
$todo->setAttribute('reminder_tone', $reminder['tone']);
});
$items = $items->sortBy('hierarchy_order')->values();
$positionGroups = $this->positionGroups($sequenceMap);
$base = SystemizationTodo::query();
$totalCount = (clone $base)->count();
$completedCount = (clone $base)
->where('status', SystemizationTodo::STATUS_COMPLETED)
->count();
$incompleteCount = max(0, $totalCount - $completedCount);
$categories = SystemizationTodo::query()
->whereNotNull('category')
->where('category', '<>', '')
->distinct()
->orderBy('category')
->pluck('category');
$overdueCount = (clone $base)
->where('status', '<>', SystemizationTodo::STATUS_COMPLETED)
->whereNotNull('due_date')
->whereDate('due_date', '<', today())
->count();
$statusOptions = collect();
foreach (SystemizationTodo::STATUSES as $status) {
$statusOptions->push([
'value' => $status,
'label' => $status,
]);
}
return [
'view' => 'business.pages.systemization-todos',
'title' => '系统化待办',
'items' => $items,
'todoPositionGroups' => $positionGroups,
'todoFilters' => $filters,
'todoCategories' => $categories,
'todoContentOptions' => $this->contentOptions(),
'todoOwners' => $this->peopleOptions('owner'),
'todoAssignees' => $this->peopleOptions('assignee'),
'todoStatuses' => SystemizationTodo::STATUSES,
'todoStatusOptions' => $statusOptions,
'todoReminderOptions' => collect(self::REMINDER_OPTIONS)
->map(static fn (string $label, string $value): array => [
'value' => $value,
'label' => $label,
])
->values(),
'todoReturnFilters' => base64_encode(json_encode(
$filters,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
)),
'todoSummary' => [
'total' => $totalCount,
'incomplete' => $incompleteCount,
'completed' => $completedCount,
'overdue' => $overdueCount,
],
'todoRoutes' => [
'store' => route('business.systemization-todos.store'),
'update' => route('business.systemization-todos.update', ['todo' => '__TODO__']),
'category' => route('business.systemization-todos.category', ['todo' => '__TODO__']),
'section' => route('business.systemization-todos.section', ['todo' => '__TODO__']),
'destroy' => route('business.systemization-todos.destroy', ['todo' => '__TODO__']),
'attachment' => route('business.systemization-todos.attachments.show', [
'todo' => '__TODO__',
'attachment' => '__ATTACHMENT__',
]),
'import' => route('business.systemization-todos.import'),
'history' => route('business.systemization-todos.history'),
'historyExport' => route('business.systemization-todos.history.export'),
],
];
}
private function hierarchySequenceMap(): array
{
$todos = SystemizationTodo::query()
->orderBy('sort_order')
->orderBy('id')
->get(['id', 'category', 'section', 'sort_order']);
$rows = $todos->map(function (SystemizationTodo $todo): array {
$category = $this->hierarchyLabel($todo->category);
$section = $this->hierarchyLabel($todo->section);
return [
'category_key' => $this->hierarchyGroupKey($category),
'category_label' => $category,
'section_key' => $this->hierarchyGroupKey($section),
'section_label' => $section,
'sort_order' => (int) $todo->sort_order,
'id' => (int) $todo->id,
];
})->all();
$categoryOrder = [];
$categoryLabels = [];
$sectionOrder = [];
$sectionLabels = [];
$groupedRows = [];
foreach ($rows as $row) {
$categoryKey = $row['category_key'];
$sectionKey = $row['section_key'];
$categoryOrder[$categoryKey] = min(
$categoryOrder[$categoryKey] ?? PHP_INT_MAX,
$row['sort_order'],
);
$categoryLabels[$categoryKey] ??= $row['category_label'];
$sectionOrder[$categoryKey][$sectionKey] = min(
$sectionOrder[$categoryKey][$sectionKey] ?? PHP_INT_MAX,
$row['sort_order'],
);
$sectionLabels[$categoryKey][$sectionKey] ??= $row['section_label'];
$groupedRows[$categoryKey][$sectionKey][] = $row;
}
$categoryKeys = array_keys($categoryOrder);
usort($categoryKeys, function (string $left, string $right) use (
$categoryOrder,
$categoryLabels,
): int {
return $this->compareHierarchyGroups(
$left,
$right,
$categoryOrder,
$categoryLabels,
);
});
$sequenceMap = [];
$hierarchyOrder = 0;
foreach ($categoryKeys as $categoryIndex => $categoryKey) {
$typeCount = array_sum(array_map(
static fn (array $rows): int => count($rows),
$groupedRows[$categoryKey],
));
$contentKeys = array_keys($sectionOrder[$categoryKey]);
usort($contentKeys, function (string $left, string $right) use (
$categoryKey,
$sectionOrder,
$sectionLabels,
): int {
return $this->compareHierarchyGroups(
$left,
$right,
$sectionOrder[$categoryKey],
$sectionLabels[$categoryKey],
);
});
foreach ($contentKeys as $contentIndex => $contentKey) {
$contentRows = $groupedRows[$categoryKey][$contentKey];
usort($contentRows, static fn (array $left, array $right): int =>
[$left['sort_order'], $left['id']] <=> [$right['sort_order'], $right['id']]
);
foreach ($contentRows as $itemIndex => $row) {
$sequenceMap[$row['id']] = [
'type_sequence' => $categoryIndex + 1,
'content_sequence' => $contentIndex + 1,
'item_sequence' => $itemIndex + 1,
'order' => $hierarchyOrder++,
'category_display' => $row['category_label'],
'section_display' => $row['section_label'],
'type_count' => $typeCount,
'content_count' => count($contentRows),
];
}
}
}
return $sequenceMap;
}
private function positionGroups(array $sequenceMap): array
{
$groups = [];
$todos = SystemizationTodo::query()
->orderBy('sort_order')
->orderBy('id')
->get(['id', 'category', 'section', 'sort_order']);
foreach ($todos as $todo) {
$sequence = $sequenceMap[$todo->id] ?? null;
if ($sequence === null) {
continue;
}
$key = $sequence['type_sequence'] . ':' . $sequence['content_sequence'];
$groups[$key] ??= [
'category' => (string) ($todo->category ?? ''),
'section' => $sequence['section_display'],
'count' => 0,
];
$groups[$key]['count']++;
}
return array_values($groups);
}
private function compareHierarchyGroups(
string $left,
string $right,
array $sortOrders,
array $labels,
): int {
$comparison = ($sortOrders[$left] ?? PHP_INT_MAX) <=> ($sortOrders[$right] ?? PHP_INT_MAX);
return $comparison !== 0
? $comparison
: strnatcasecmp($labels[$left] ?? '', $labels[$right] ?? '');
}
private function hierarchyLabel(?string $value): string
{
return trim((string) preg_replace(
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
'',
(string) $value,
));
}
private function hierarchyGroupKey(string $label): string
{
$normalized = preg_replace(
'/[\s\p{Z}\x{200B}\x{FEFF}]+/u',
'',
$label,
);
$normalized = (string) $normalized;
return $normalized === '' ? "\0" : mb_strtolower($normalized);
}
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
$this->normalizePendingDates($request, $data);
$position = max(1, (int) ($data['position'] ?? 1));
unset($data['position']);
$data['company_id'] = (int) company_id();
$data['created_by'] = user_id();
$data['updated_by'] = user_id();
$this->normalizeCompletionDates($data);
$insertedSequence = $position;
$todo = DB::transaction(function () use ($data, $position, &$insertedSequence): SystemizationTodo {
$siblings = $this->contentSiblings($data['category'] ?? null, $data['section'] ?? null);
$nextOrder = (int) SystemizationTodo::query()->max('sort_order') + 1;
if ($siblings->isEmpty()) {
$insertOrder = $nextOrder;
} elseif ($position <= $siblings->count()) {
$insertOrder = (int) $siblings[$position - 1]->sort_order;
} else {
$insertOrder = (int) $siblings->max('sort_order') + 1;
}
SystemizationTodo::query()
->where('sort_order', '>=', $insertOrder)
->increment('sort_order');
$insertedSequence = $siblings->isEmpty()
? 1
: min($position, $siblings->count() + 1);
$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);
return $this->backToPage(
'待办事项已新增为当前内容第 ' . $insertedSequence . ' 条并定位。',
'success',
$todo,
);
}
public function updateCategory(Request $request, SystemizationTodo $todo): RedirectResponse
{
$data = $request->validate([
'category' => ['required', 'string', 'max:100'],
]);
$ids = [];
DB::transaction(function () use ($todo, $data, &$ids): void {
$ids = $this->groupTodoIds($todo, false);
$this->updateGroupField($ids, ['category' => trim($data['category'])]);
});
return $this->backToPage(
'类型已更新,当前类型下的 ' . count($ids) . ' 条待办已同步调整。',
'success',
$todo,
);
}
public function updateSection(Request $request, SystemizationTodo $todo): RedirectResponse
{
$data = $request->validate([
'section' => ['required', 'string', 'max:255'],
]);
$ids = [];
DB::transaction(function () use ($todo, $data, &$ids): void {
$ids = $this->groupTodoIds($todo, true);
$this->updateGroupField($ids, ['section' => trim($data['section'])]);
});
return $this->backToPage(
'内容已更新,当前内容下的 ' . count($ids) . ' 条待办已同步调整。',
'success',
$todo,
);
}
private function contentSiblings(
?string $category,
?string $section,
?int $excludeId = null,
): Collection
{
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($category));
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($section));
return SystemizationTodo::query()
->orderBy('sort_order')
->orderBy('id')
->lockForUpdate()
->get()
->filter(fn (SystemizationTodo $todo): bool =>
(int) $todo->id !== (int) $excludeId
&& $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)) === $categoryKey
&& $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)) === $sectionKey
)
->values();
}
private function positionForUpdate(
SystemizationTodo $todo,
int $targetPosition,
array &$data,
): void {
$currentCategoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category));
$currentSectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section));
$targetCategoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($data['category'] ?? null));
$targetSectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($data['section'] ?? null));
$sameGroup = $currentCategoryKey === $targetCategoryKey
&& $currentSectionKey === $targetSectionKey;
if ($sameGroup) {
$currentSiblings = $this->contentSiblings($todo->category, $todo->section);
$currentIndex = $currentSiblings->search(
static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $todo->id
);
if ($currentIndex !== false && $targetPosition === ((int) $currentIndex + 1)) {
return;
}
}
$siblings = $this->contentSiblings(
$data['category'] ?? null,
$data['section'] ?? null,
(int) $todo->id,
);
if ($siblings->isEmpty()) {
$insertOrder = (int) SystemizationTodo::query()->max('sort_order') + 1;
} elseif ($targetPosition <= $siblings->count()) {
$insertOrder = (int) $siblings[$targetPosition - 1]->sort_order;
} else {
$insertOrder = (int) $siblings->max('sort_order') + 1;
}
SystemizationTodo::query()
->where('sort_order', '>=', $insertOrder)
->increment('sort_order');
$data['sort_order'] = $insertOrder;
}
private function groupTodoIds(SystemizationTodo $todo, bool $includeSection): array
{
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category));
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section));
return SystemizationTodo::query()
->lockForUpdate()
->get(['id', 'category', 'section'])
->filter(fn (SystemizationTodo $candidate): bool =>
$this->hierarchyGroupKey($this->hierarchyLabel($candidate->category)) === $categoryKey
&& (! $includeSection
|| $this->hierarchyGroupKey($this->hierarchyLabel($candidate->section)) === $sectionKey)
)
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
}
private function updateGroupField(array $ids, array $data): void
{
if ($ids === []) {
return;
}
$todos = SystemizationTodo::query()
->whereIn('id', $ids)
->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));
unset($data['position']);
$data['updated_by'] = user_id();
$this->normalizeCompletionDates($data);
$removeIds = collect($request->input('remove_attachments', []))
->map(static fn (mixed $id): int => (int) $id)
->filter()
->unique()
->values();
if ($removeIds->isNotEmpty()) {
$attachments = $todo->attachments()
->where('company_id', (int) company_id())
->whereIn('id', $removeIds)
->get();
foreach ($attachments as $attachment) {
Storage::disk('uploads')->delete($attachment->path);
$attachment->delete();
}
}
DB::transaction(function () use ($todo, $data, $targetPosition): void {
$lockedTodo = SystemizationTodo::query()
->lockForUpdate()
->findOrFail($todo->id);
if ($targetPosition > 0) {
$this->positionForUpdate($lockedTodo, $targetPosition, $data);
}
$lockedTodo->update($data);
$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
{
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);
abort_unless((int) $attachment->company_id === (int) company_id(), 404);
$disk = Storage::disk('uploads');
abort_unless($disk->exists($attachment->path), 404, '截图不存在。');
return $disk->response($attachment->path, $attachment->original_name, [
'Cache-Control' => 'private, no-store',
'Content-Type' => $attachment->mime_type,
'X-Content-Type-Options' => 'nosniff',
]);
}
public function importWorkbook(
Request $request,
SystemizationTodoWorkbookImporter $importer,
): RedirectResponse {
$data = $request->validate([
'source' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
]);
try {
$result = $importer->sync(
$data['source']->getRealPath(),
(int) company_id(),
user_id(),
);
} catch (Throwable $exception) {
report($exception);
return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error');
}
return $this->backToPage(sprintf(
'同步完成:%s,新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。',
$result['sheet'],
$result['created'],
$result['updated'],
$result['retained'],
));
}
private function filters(): array
{
return [
'status' => $this->normalizeStatusValue(request('status')),
'reminder' => $this->normalizeReminderValue(request('reminder')),
'category' => trim((string) request('category', '')),
'owners' => $this->peopleFilter('owner'),
'assignees' => $this->peopleFilter('assignee'),
'start_date' => $this->normalizeDateValue(request('start_date')),
'due_date' => $this->normalizeDateValue(request('due_date')),
'keyword' => trim((string) request('keyword', '')),
];
}
private function normalizeStatusValue(mixed $value): string
{
$status = trim((string) $value);
if (in_array($status, SystemizationTodo::LEGACY_INCOMPLETE_STATUSES, true)) {
return SystemizationTodo::STATUS_INCOMPLETE;
}
return $status === self::OVERDUE_FILTER
|| in_array($status, SystemizationTodo::STATUSES, true)
? $status
: '';
}
private function normalizeReminderValue(mixed $value): string
{
$reminder = trim((string) $value);
return array_key_exists($reminder, self::REMINDER_OPTIONS) ? $reminder : '';
}
private function peopleFilter(string $key): array
{
return collect((array) request($key, []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
}
private function peopleOptions(string $column): Collection
{
return SystemizationTodo::query()
->whereNotNull($column)
->where($column, '<>', '')
->pluck($column)
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
->filter()
->unique()
->sort()
->values();
}
private function contentOptions(): array
{
$options = [];
$todos = SystemizationTodo::query()
->whereNotNull('section')
->where('section', '<>', '')
->get(['category', 'section']);
foreach ($todos as $todo) {
$category = $this->hierarchyLabel($todo->category);
$section = $this->hierarchyLabel($todo->section);
if ($category !== '' && $section !== '') {
$options[$category][$section] = true;
}
}
foreach ($options as $category => $sections) {
$labels = array_keys($sections);
usort($labels, 'strnatcasecmp');
$options[$category] = $labels;
}
return $options;
}
private function normalizeDateValue(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value
? $value
: '';
}
private function validated(Request $request): array
{
return $request->validate([
'category' => ['nullable', 'string', 'max:100'],
'section' => ['nullable', 'string', 'max:255'],
'title' => ['required', 'string', 'max:5000'],
'position' => ['nullable', 'integer', 'min:1'],
'owner' => ['nullable', 'string', 'max:255'],
'assignee' => ['nullable', 'string', 'max:255'],
'start_date' => ['nullable', 'date_format:Y-m-d'],
'start_note' => ['nullable', 'string', 'max:50'],
'start_pending' => ['nullable', 'boolean'],
'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'due_note' => ['nullable', 'string', 'max:50'],
'due_pending' => ['nullable', 'boolean'],
'status' => ['required', Rule::in(SystemizationTodo::STATUSES)],
'completed_at' => ['nullable', 'date_format:Y-m-d'],
'progress' => ['nullable', 'string', 'max:10000'],
'notes' => ['nullable', 'string', 'max:10000'],
'screenshots' => ['nullable', 'array', 'max:12'],
'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
'remove_attachments' => ['nullable', 'array'],
'remove_attachments.*' => ['integer'],
]);
}
private function normalizePendingDates(Request $request, array &$data): void
{
foreach (['start', 'due'] as $prefix) {
if ($request->boolean($prefix . '_pending')) {
$data[$prefix . '_date'] = null;
$data[$prefix . '_note'] = '待定';
} elseif (!empty($data[$prefix . '_date'])) {
$data[$prefix . '_note'] = null;
}
unset($data[$prefix . '_pending']);
}
}
private function normalizeCompletionDates(array &$data): void
{
$completed = ($data['status'] ?? '') === SystemizationTodo::STATUS_COMPLETED;
$data['completed_at'] = $completed
? (($data['completed_at'] ?? null) ?: ($data['due_date'] ?? null) ?: now()->toDateString())
: null;
}
private function storeAttachments(Request $request, SystemizationTodo $todo): void
{
foreach ($request->file('screenshots', []) as $file) {
$extension = strtolower($file->getClientOriginalExtension());
$fileName = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : '');
$path = Storage::disk('uploads')->putFileAs(
'systemization-todos/' . (int) company_id() . '/' . $todo->id,
$file,
$fileName,
);
if ($path === false) {
throw new \RuntimeException('完成截图保存失败。');
}
SystemizationTodoAttachment::query()->create([
'company_id' => (int) company_id(),
'systemization_todo_id' => $todo->id,
'path' => $path,
'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 240),
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size' => $file->getSize() ?: 0,
'uploaded_by' => user_id(),
]);
}
}
private function backToPage(
string $message,
string $type = 'success',
?SystemizationTodo $focus = null,
): RedirectResponse
{
$parameters = [
'section' => 'other',
'page' => 'systemization-todos',
];
$parameters = array_merge($parameters, $this->returnedFilters());
if ($focus) {
$parameters['highlight'] = $focus->id;
}
return redirect()
->route('business.pages.show', $parameters)
->with($type === 'success' ? 'success' : 'error', $message);
}
private function returnedFilters(): array
{
$encoded = request('return_filters');
if (!is_string($encoded) || $encoded === '') {
return [];
}
$json = base64_decode($encoded, true);
$filters = $json === false ? null : json_decode($json, true);
if (!is_array($filters)) {
return [];
}
$category = is_string($filters['category'] ?? null)
? trim($filters['category'])
: '';
$keyword = is_string($filters['keyword'] ?? null)
? trim($filters['keyword'])
: '';
$owners = collect((array) ($filters['owners'] ?? []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
$assignees = collect((array) ($filters['assignees'] ?? []))
->filter(static fn (mixed $value): bool => is_string($value))
->map(static fn (string $value): string => trim($value))
->filter()
->unique()
->take(50)
->values()
->all();
return array_filter([
'status' => $this->normalizeStatusValue($filters['status'] ?? null),
'reminder' => $this->normalizeReminderValue($filters['reminder'] ?? null),
'category' => $category,
'owner' => $owners,
'assignee' => $assignees,
'start_date' => $this->normalizeDateValue($filters['start_date'] ?? null),
'due_date' => $this->normalizeDateValue($filters['due_date'] ?? null),
'keyword' => $keyword,
], static fn (mixed $value): bool => $value !== '' && $value !== []);
}
}