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' => '', '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']); }); $items = $items->sortBy('hierarchy_order')->values(); $positionGroups = $this->positionGroups($sequenceMap); $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, 'todoPositionGroups' => $positionGroups, 'todoFilters' => $filters, 'todoCategories' => $categories, 'todoContentOptions' => $this->contentOptions(), '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__']), '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'), ], ]; } 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); $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); return SystemizationTodo::query()->create([ ...$data, 'sort_order' => $insertOrder, ]); }); $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; } SystemizationTodo::query() ->whereIn('id', $ids) ->update([ ...$data, 'updated_by' => user_id(), ]); } public function update(Request $request, SystemizationTodo $todo): RedirectResponse { $data = $this->validated($request); $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); 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')), '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 contentOptions(): array { $options = []; $todos = SystemizationTodo::query() ->whereNotNull('section') ->where('section', '<>', '') ->get(['category', 'section']); foreach ($todos as $todo) { $category = trim((string) ($todo->category ?? '')); $section = $this->hierarchyLabel($todo->section); if ($section !== '') { $options[$category][$section] = true; } } foreach ($options as $category => $sections) { $labels = array_keys($sections); usort($labels, 'strnatcasecmp'); $options[$category] = $labels; } return $options; } 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'], 'position' => ['nullable', 'integer', 'min:1'], '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); } }