filters(); $query = SystemizationTodo::query() ->with('attachments') ->withCount('attachments'); if ($filters['status'] === self::OVERDUE_FILTER) { $query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED]) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()); } elseif ($filters['status'] !== '') { $query->where('status', $filters['status']); } 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' => '', ]; $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']); }); $items = $items->sortBy('hierarchy_order')->values(); if ($filters['type_sequence'] > 0) { $items = $items ->filter(static fn (SystemizationTodo $todo): bool => (int) $todo->type_sequence === $filters['type_sequence'] ) ->values(); } if ($filters['content_sequence'] > 0) { $items = $items ->filter(static fn (SystemizationTodo $todo): bool => (int) $todo->content_sequence === $filters['content_sequence'] ) ->values(); } if ($filters['item_sequence'] > 0) { $items = $items ->filter(static fn (SystemizationTodo $todo): bool => (int) $todo->item_sequence === $filters['item_sequence'] ) ->values(); } $base = SystemizationTodo::query(); $categories = SystemizationTodo::query() ->whereNotNull('category') ->where('category', '<>', '') ->distinct() ->orderBy('category') ->pluck('category'); $statusCounts = (clone $base) ->selectRaw('status, COUNT(*) AS aggregate') ->groupBy('status') ->pluck('aggregate', 'status'); $overdueCount = (clone $base) ->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED]) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()) ->count(); $statusOptions = collect(); if ($overdueCount > 0) { $statusOptions->push([ 'value' => self::OVERDUE_FILTER, 'label' => '已逾期', ]); } foreach (SystemizationTodo::STATUSES as $status) { if ((int) $statusCounts->get($status, 0) > 0) { $statusOptions->push([ 'value' => $status, 'label' => $status, ]); } } return [ 'view' => 'business.pages.systemization-todos', 'title' => '系统化待办', 'items' => $items, 'todoFilters' => $filters, 'todoCategories' => $categories, 'todoOwners' => $this->peopleOptions('owner'), 'todoAssignees' => $this->peopleOptions('assignee'), 'todoStatuses' => SystemizationTodo::STATUSES, 'todoStatusOptions' => $statusOptions, 'todoSummary' => [ 'total' => (clone $base)->count(), 'pending' => (int) $statusCounts->get(SystemizationTodo::STATUS_PENDING, 0), 'in_progress' => (int) $statusCounts->get(SystemizationTodo::STATUS_IN_PROGRESS, 0), 'completed' => (int) $statusCounts->get(SystemizationTodo::STATUS_COMPLETED, 0), 'overdue' => $overdueCount, ], 'todoRoutes' => [ 'store' => route('business.systemization-todos.store'), 'update' => route('business.systemization-todos.update', ['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'), ], ]; } 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' => $category === '' ? "\0" : $category, 'category_label' => $category, 'section_key' => $section === '' ? "\0" : $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) { $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'], ]; } } } return $sequenceMap; } 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, )); } public function store(Request $request): RedirectResponse { $data = $this->validated($request); $data['company_id'] = (int) company_id(); $data['sort_order'] = (int) SystemizationTodo::query()->max('sort_order') + 1; $data['created_by'] = user_id(); $data['updated_by'] = user_id(); $this->normalizeCompletionDates($data); $todo = SystemizationTodo::query()->create($data); $this->storeAttachments($request, $todo); return $this->backToPage('待办事项已新增并定位。', 'success', $todo); } public function update(Request $request, SystemizationTodo $todo): RedirectResponse { $data = $this->validated($request); $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(); } } $todo->update($data); $this->storeAttachments($request, $todo); return $this->backToPage('待办事项已更新并定位。', 'success', $todo); } public function destroy(SystemizationTodo $todo): RedirectResponse { $todo->update(['updated_by' => user_id()]); $todo->delete(); return $this->backToPage('待办事项已删除。'); } 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 { $status = (string) request('status', ''); return [ 'status' => $status === self::OVERDUE_FILTER || in_array($status, SystemizationTodo::STATUSES, true) ? $status : '', 'category' => trim((string) request('category', '')), 'owners' => $this->peopleFilter('owner'), 'assignees' => $this->peopleFilter('assignee'), 'start_date' => $this->normalizeDateFilter(request('start_date')), 'due_date' => $this->normalizeDateFilter(request('due_date')), 'type_sequence' => max(0, (int) request('type_sequence', 0)), 'content_sequence' => max(0, (int) request('content_sequence', 0)), 'item_sequence' => max(0, (int) request('item_sequence', 0)), 'keyword' => trim((string) request('keyword', '')), ]; } 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 normalizeDateFilter(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'], 'owner' => ['nullable', 'string', 'max:255'], 'assignee' => ['nullable', 'string', 'max:255'], 'start_date' => ['nullable', 'date_format:Y-m-d'], 'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'], '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 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', ]; if ($focus) { $parameters['highlight'] = $focus->id; } return redirect() ->route('business.pages.show', $parameters) ->with($type === 'success' ? 'success' : 'error', $message); } }