diff --git a/app/Business/SystemizationTodoWorkbookImporter.php b/app/Business/SystemizationTodoWorkbookImporter.php new file mode 100644 index 00000000..bda80867 --- /dev/null +++ b/app/Business/SystemizationTodoWorkbookImporter.php @@ -0,0 +1,249 @@ + '类型', + 'section' => '内容', + 'title' => '待办事项', + 'owner' => '总负责人', + 'assignee' => '责任人', + 'start' => '开始时间', + 'finish' => '完成时间', + 'progress' => '完成情况', + 'notes' => '备注', + ]; + + /** + * 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。 + * + * @return array{created:int,skipped:int,sheet:string} + */ + public function import(string $path, int $companyId, ?int $userId = null): array + { + if (! is_file($path)) { + throw new RuntimeException('找不到待办 Excel 文件。'); + } + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($extension !== 'xlsx') { + throw new RuntimeException('系统化待办仅支持 .xlsx 文件。'); + } + + $reader = PayablesWorkbookStreamReader::forFile($path, $extension); + foreach ($reader->sheetNames() as $sheetIndex => $sheetName) { + $headerInfo = $this->headerMap($reader, $sheetIndex); + if ($headerInfo === null) { + continue; + } + $header = $headerInfo['columns']; + $headerRow = $headerInfo['row']; + + $created = 0; + $skipped = 0; + DB::transaction(function () use ( + $reader, + $sheetIndex, + $sheetName, + $header, + $headerRow, + $companyId, + $userId, + &$created, + &$skipped + ): void { + $category = ''; + $section = ''; + foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) { + if ($rowNumber <= $headerRow) { + continue; + } + $categoryValue = $this->cell($cells, $header['category'] ?? null); + if ($categoryValue !== '') { + $category = $categoryValue; + } + $sectionValue = $this->cell($cells, $header['section'] ?? null); + if ($sectionValue !== '') { + $section = $sectionValue; + } + + $title = $this->cell($cells, $header['title'] ?? null); + if ($title === '') { + continue; + } + + $owner = $this->cell($cells, $header['owner'] ?? null); + $assignee = $this->cell($cells, $header['assignee'] ?? null); + $sourceKey = hash( + 'sha256', + implode('|', [$companyId, $sheetName, $rowNumber, $category, $section, $title, $owner, $assignee]) + ); + $exists = SystemizationTodo::withoutGlobalScopes() + ->where('company_id', $companyId) + ->where('source_key', $sourceKey) + ->exists(); + if ($exists) { + $skipped++; + continue; + } + + $startText = $this->cell($cells, $header['start'] ?? null); + $finishText = $this->cell($cells, $header['finish'] ?? null); + [$startDate, $startNote] = $this->dateParts($startText); + [$finishDate, $finishNote] = $this->dateParts($finishText); + $progress = $this->cell($cells, $header['progress'] ?? null); + $status = $this->status($progress, $startText, $finishText); + $completedAt = $status === SystemizationTodo::STATUS_COMPLETED + ? ($finishDate ?? now()->toDateString()) + : null; + + SystemizationTodo::withoutGlobalScopes()->create([ + 'company_id' => $companyId, + 'category' => $category !== '' ? $category : null, + 'section' => $section !== '' ? $section : null, + 'title' => $title, + 'owner' => $owner !== '' ? $owner : null, + 'assignee' => $assignee !== '' ? $assignee : null, + 'start_date' => $startDate, + 'start_note' => $startNote !== '' ? $startNote : null, + 'due_date' => $finishDate, + 'due_note' => $finishNote !== '' ? $finishNote : null, + 'status' => $status, + 'completed_at' => $completedAt, + 'progress' => $progress !== '' ? $progress : null, + 'notes' => ($notes = $this->cell($cells, $header['notes'] ?? null)) !== '' ? $notes : null, + 'sort_order' => $rowNumber, + 'source_key' => $sourceKey, + 'imported_at' => now(), + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName]; + } + + throw new RuntimeException( + '未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。' + ); + } + + /** + * @return array{row:int,columns:array}|null + */ + private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array + { + foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) { + $normalized = []; + foreach ($cells as $column => $value) { + $value = $this->normalizeHeader($this->text($value)); + if ($value !== '') { + $normalized[$value] = $column; + } + } + + $mapped = []; + foreach (self::REQUIRED_HEADERS as $field => $label) { + $needle = $this->normalizeHeader($label); + foreach ($normalized as $header => $column) { + if ($header === $needle || str_starts_with($header, $needle)) { + $mapped[$field] = $column; + break; + } + } + } + + if (isset($mapped['title']) + && isset($mapped['owner']) + && isset($mapped['assignee'])) { + return ['row' => $rowNumber, 'columns' => $mapped]; + } + } + + return null; + } + + private function normalizeHeader(string $value): string + { + return preg_replace('/[\s ()()]+/u', '', $value) ?? ''; + } + + /** + * @param array $cells + */ + private function cell(array $cells, ?int $column): string + { + return $column === null ? '' : $this->text($cells[$column] ?? null); + } + + private function text(mixed $value): string + { + if ($value === null) { + return ''; + } + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + return trim(str_replace("\u{00A0}", ' ', (string) $value)); + } + + /** + * @return array{0:string|null,1:string} + */ + private function dateParts(string $value): array + { + $value = trim($value); + if ($value === '' || $value === '已完成') { + return [null, '']; + } + + if (is_numeric($value)) { + $serial = (float) $value; + if ($serial >= 20000 && $serial <= 80000) { + return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), '']; + } + } + + foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) { + try { + $date = Carbon::createFromFormat($format, $value); + if ($date !== false) { + return [$date->format('Y-m-d'), '']; + } + } catch (\Throwable) { + // 不是标准日期,保留原值作为时间备注。 + } + } + + return [null, mb_substr($value, 0, 100)]; + } + + private function status(string $progress, string $startNote, string $finishNote): string + { + if (str_contains($progress, '已完成')) { + return SystemizationTodo::STATUS_COMPLETED; + } + if ($progress !== '') { + return str_contains($progress, '延期') + ? SystemizationTodo::STATUS_DELAYED + : SystemizationTodo::STATUS_IN_PROGRESS; + } + if (str_contains($startNote, '延期') || str_contains($finishNote, '延期')) { + return SystemizationTodo::STATUS_DELAYED; + } + + return SystemizationTodo::STATUS_PENDING; + } +} diff --git a/app/Console/Commands/ImportSystemizationTodos.php b/app/Console/Commands/ImportSystemizationTodos.php new file mode 100644 index 00000000..ac3026a1 --- /dev/null +++ b/app/Console/Commands/ImportSystemizationTodos.php @@ -0,0 +1,41 @@ +import( + (string) $this->argument('file'), + max((int) $this->option('company'), 1), + $this->option('user') !== null ? (int) $this->option('user') : null, + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + '工作表 %s:新增 %d 条,跳过已存在 %d 条。', + $result['sheet'], + $result['created'], + $result['skipped'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index ba073ebb..03651e8f 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -4186,6 +4186,7 @@ class Pages extends Controller 'shipping-data' => $this->contentForShippingData(), 'return-pickup-management' => $this->contentForReturnPickupManagement(), 'withdrawal-management' => $this->contentForWithdrawalManagement(), + 'systemization-todos' => app(\App\Http\Controllers\Business\SystemizationTodos::class)->page(), 'todos' => $this->contentForTodos(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), 'account-mapping' => $this->contentForAccountMapping(), diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php new file mode 100644 index 00000000..b48f1929 --- /dev/null +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -0,0 +1,284 @@ +filters(); + $query = SystemizationTodo::query() + ->with('attachments') + ->withCount('attachments'); + + if ($filters['status'] !== '') { + $query->where('status', $filters['status']); + } + if ($filters['category'] !== '') { + $query->where('category', $filters['category']); + } + if ($filters['owner'] !== '') { + $query->where(function ($builder) use ($filters): void { + $builder->where('owner', $filters['owner']) + ->orWhere('assignee', $filters['owner']); + }); + } + 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 + ->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END") + ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END') + ->orderBy('due_date') + ->orderBy('sort_order') + ->orderBy('id') + ->paginate(50) + ->withQueryString(); + + $base = SystemizationTodo::query(); + $categories = SystemizationTodo::query() + ->whereNotNull('category') + ->where('category', '<>', '') + ->distinct() + ->orderBy('category') + ->pluck('category'); + $owners = SystemizationTodo::query() + ->whereNotNull('owner') + ->where('owner', '<>', '') + ->pluck('owner') + ->merge( + SystemizationTodo::query() + ->whereNotNull('assignee') + ->where('assignee', '<>', '') + ->pluck('assignee') + ) + ->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: [])) + ->filter() + ->unique() + ->sort() + ->values(); + + return [ + 'view' => 'business.pages.systemization-todos', + 'title' => '系统化待办', + 'items' => $items, + 'todoFilters' => $filters, + 'todoCategories' => $categories, + 'todoOwners' => $owners, + 'todoStatuses' => SystemizationTodo::STATUSES, + 'todoSummary' => [ + 'total' => (clone $base)->count(), + 'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(), + 'in_progress' => (clone $base)->where('status', SystemizationTodo::STATUS_IN_PROGRESS)->count(), + 'completed' => (clone $base)->where('status', SystemizationTodo::STATUS_COMPLETED)->count(), + 'overdue' => (clone $base) + ->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED]) + ->whereNotNull('due_date') + ->whereDate('due_date', '<', today()) + ->count(), + ], + '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'), + ], + ]; + } + + 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('待办事项已新增。'); + } + + 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('待办事项已更新。'); + } + + 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->import( + $data['source']->getRealPath(), + (int) company_id(), + user_id(), + ); + } catch (Throwable $exception) { + report($exception); + + return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error'); + } + + return $this->backToPage(sprintf( + '导入完成:%s,新增 %d 条,跳过已存在 %d 条。', + $result['sheet'], + $result['created'], + $result['skipped'], + )); + } + + private function filters(): array + { + return [ + 'status' => in_array(request('status'), SystemizationTodo::STATUSES, true) + ? (string) request('status') + : '', + 'category' => trim((string) request('category', '')), + 'owner' => trim((string) request('owner', '')), + 'keyword' => trim((string) request('keyword', '')), + ]; + } + + 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'): RedirectResponse + { + return redirect() + ->route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ]) + ->with($type === 'success' ? 'success' : 'error', $message); + } +} diff --git a/app/Models/Business/SystemizationTodo.php b/app/Models/Business/SystemizationTodo.php new file mode 100644 index 00000000..b73b540f --- /dev/null +++ b/app/Models/Business/SystemizationTodo.php @@ -0,0 +1,68 @@ + 'integer', + 'start_date' => 'date', + 'due_date' => 'date', + 'completed_at' => 'date', + 'sort_order' => 'integer', + 'imported_at' => 'datetime', + ]; + + public function attachments(): HasMany + { + return $this->hasMany(SystemizationTodoAttachment::class, 'systemization_todo_id') + ->orderBy('id'); + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } +} diff --git a/app/Models/Business/SystemizationTodoAttachment.php b/app/Models/Business/SystemizationTodoAttachment.php new file mode 100644 index 00000000..fd17b832 --- /dev/null +++ b/app/Models/Business/SystemizationTodoAttachment.php @@ -0,0 +1,32 @@ + 'integer', + 'systemization_todo_id' => 'integer', + 'size' => 'integer', + ]; + + public function todo(): BelongsTo + { + return $this->belongsTo(SystemizationTodo::class, 'systemization_todo_id'); + } +} diff --git a/app/Utilities/SidebarMenu.php b/app/Utilities/SidebarMenu.php index 9105ef45..4fc3875f 100644 --- a/app/Utilities/SidebarMenu.php +++ b/app/Utilities/SidebarMenu.php @@ -44,6 +44,7 @@ class SidebarMenu ['company-management', 'company-progress', '公司进度表', 'timeline'], ['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'], ['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'], + ['other', 'systemization-todos', '系统化待办', 'checklist'], ['workspace', 'tasks', '待办事项', 'assignment_turned_in'], ['workspace', 'warnings', '预警页面', 'warning_amber'], ]], diff --git a/config/business_navigation.php b/config/business_navigation.php index cb07dea3..b5ae5cb2 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -956,6 +956,29 @@ $navigation = [ 'title' => '其他', 'icon' => 'more_horiz', 'pages' => [ + [ + 'slug' => 'systemization-todos', + 'title' => '系统化待办', + 'directory' => [ + 'icon' => 'checklist', + 'status' => '持续更新', + 'description' => '财务系统化工作的日常待办、责任人、进度和完成截图。', + 'empty' => '暂时没有系统化待办。', + 'fields' => [ + ['name' => 'category', 'label' => '类型'], + ['name' => 'section', 'label' => '内容'], + ['name' => 'title', 'label' => '待办事项'], + ['name' => 'owner', 'label' => '总负责人'], + ['name' => 'assignee', 'label' => '责任人'], + ['name' => 'start_date', 'label' => '开始时间', 'kind' => 'date'], + ['name' => 'due_date', 'label' => '完成时间', 'kind' => 'date'], + ['name' => 'status', 'label' => '状态'], + ['name' => 'progress', 'label' => '完成情况'], + ['name' => 'attachments', 'label' => '完成截图'], + ['name' => 'notes', 'label' => '备注'], + ], + ], + ], [ 'slug' => 'todos', 'title' => '待办事项', diff --git a/database/migrations/2026_10_25_160000_create_systemization_todo_tables.php b/database/migrations/2026_10_25_160000_create_systemization_todo_tables.php new file mode 100644 index 00000000..85757a13 --- /dev/null +++ b/database/migrations/2026_10_25_160000_create_systemization_todo_tables.php @@ -0,0 +1,73 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id')->default(1); + $table->string('category', 100)->nullable(); + $table->string('section', 255)->nullable(); + $table->text('title'); + $table->string('owner', 255)->nullable(); + $table->string('assignee', 255)->nullable(); + $table->date('start_date')->nullable(); + $table->string('start_note', 100)->nullable(); + $table->date('due_date')->nullable(); + $table->string('due_note', 100)->nullable(); + $table->string('status', 20)->default('待办'); + $table->date('completed_at')->nullable(); + $table->text('progress')->nullable(); + $table->text('notes')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->string('source_key', 64)->nullable(); + $table->timestamp('imported_at')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'status'], 'systemization_todos_company_status_index'); + $table->index(['company_id', 'category'], 'systemization_todos_company_category_index'); + $table->index(['company_id', 'due_date'], 'systemization_todos_company_due_index'); + $table->unique(['company_id', 'source_key'], 'systemization_todos_company_source_unique'); + }); + } + + if (! Schema::hasTable('business_systemization_todo_attachments')) { + Schema::create('business_systemization_todo_attachments', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->unsignedInteger('company_id')->default(1); + $table->unsignedBigInteger('systemization_todo_id'); + $table->string('path'); + $table->string('original_name'); + $table->string('mime_type', 100); + $table->unsignedBigInteger('size')->default(0); + $table->unsignedBigInteger('uploaded_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index( + ['company_id', 'systemization_todo_id'], + 'systemization_todo_attachments_company_todo_index' + ); + $table->foreign('systemization_todo_id') + ->references('id') + ->on('business_systemization_todos') + ->cascadeOnDelete(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('business_systemization_todo_attachments'); + Schema::dropIfExists('business_systemization_todos'); + } +}; diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php new file mode 100644 index 00000000..1659e857 --- /dev/null +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -0,0 +1,443 @@ +
+ + +
+
+
+
+ + 财务系统化待办 +
+

日常过会使用。新增事项、跟进责任人和完成情况,截图直接附在该事项下。

+
+
+ +
+
+
+ + @if (is_string(session('success')) && session('success') !== '') +
{{ session('success') }}
+ @endif + @if (is_string(session('error')) && session('error') !== '') +
{{ session('error') }}
+ @endif + +
+
全部
{{ number_format($todoSummary['total']) }}
+
待办
{{ number_format($todoSummary['pending']) }}
+
进行中
{{ number_format($todoSummary['in_progress']) }}
+
已完成
{{ number_format($todoSummary['completed']) }}
+
已逾期
{{ number_format($todoSummary['overdue']) }}
+
+ +
+ 从 Excel 导入现有待办(只新增,不覆盖系统内已有记录) +
+ @csrf + + +
+
+ +
+ + + + +
+ + 重置 +
+
+ +
+ + + + + + + + + @forelse ($items as $item) + @php + $isOverdue = ! $item->isCompleted() + && $item->due_date !== null + && $item->due_date->isBefore(today()); + $statusClass = match ($item->status) { + \App\Models\Business\SystemizationTodo::STATUS_IN_PROGRESS => 'sys-todo-badge--progress', + \App\Models\Business\SystemizationTodo::STATUS_COMPLETED => 'sys-todo-badge--completed', + \App\Models\Business\SystemizationTodo::STATUS_DELAYED => 'sys-todo-badge--delayed', + default => '', + }; + $todoPayload = [ + 'id' => $item->id, + 'category' => $item->category ?? '', + 'section' => $item->section ?? '', + 'title' => $item->title, + 'owner' => $item->owner ?? '', + 'assignee' => $item->assignee ?? '', + 'start_date' => $item->start_date?->format('Y-m-d') ?? '', + 'due_date' => $item->due_date?->format('Y-m-d') ?? '', + 'status' => $item->status, + 'completed_at' => $item->completed_at?->format('Y-m-d') ?? '', + 'progress' => $item->progress ?? '', + 'notes' => $item->notes ?? '', + 'attachments' => $item->attachments->map(static fn ($attachment): array => [ + 'id' => $attachment->id, + 'name' => $attachment->original_name, + 'url' => route('business.systemization-todos.attachments.show', [ + 'todo' => $item->id, + 'attachment' => $attachment->id, + ]), + ])->values()->all(), + ]; + @endphp + + + + + + + + + + + + + + + + @empty + + @endforelse + +
序号类型内容待办事项总负责人责任人开始时间完成时间状态完成情况完成截图备注操作
{{ $items->firstItem() + $loop->index }}{{ $item->category ?: '—' }}{{ $item->section ?: '—' }}{!! nl2br(e($item->title)) !!}{{ $item->owner ?: '—' }}{{ $item->assignee ?: '—' }}{{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }} + {{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }} + @if ($isOverdue)
已逾期@endif +
{{ $item->status }}{!! nl2br(e($item->progress ?: '—')) !!} + @if ($item->attachments->isNotEmpty()) +
+ @foreach ($item->attachments as $attachment) + + {{ $attachment->original_name }} + + @endforeach +
+ @else + — + @endif +
{!! nl2br(e($item->notes ?: '—')) !!} +
+ +
+ @csrf + @method('DELETE') + +
+
+
当前筛选条件下没有待办事项。
+
+ + @if ($items->hasPages()) +
+ 共 {{ number_format($items->total()) }} 条 · 第 {{ $items->currentPage() }} / {{ $items->lastPage() }} 页 + + @if ($items->onFirstPage()) + 上一页 + @else + 上一页 + @endif + @if ($items->hasMorePages()) + 下一页 + @else + 下一页 + @endif + +
+ @endif + + +
+ @csrf + +
+

新增待办

+ +
+
+
+ + + + + + + + + + +
+ + + 可选择、拖入或直接粘贴截图,每张不超过 10 MB。 + +
+
+
+
+ + +
+
+
+ + @foreach ($todoCategories as $category) + @foreach ($todoOwners as $owner) + + +
diff --git a/routes/admin.php b/routes/admin.php index d1760cd2..72a61f12 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Business\ProductPrices; use App\Http\Controllers\Business\SupplierPayableActions; use App\Http\Controllers\Business\PayablesFinalActions; use App\Http\Controllers\Business\SupplierReconciliation as SupplierReconciliationActions; +use App\Http\Controllers\Business\SystemizationTodos; use App\Http\Controllers\Business\WithdrawalManagement; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -758,6 +759,26 @@ Route::post('workspace/financial-ledger/accounts/keep-todo', [\App\Http\Controll ->middleware('business.page:financial-operations,ledger') ->name('business.financial-ledger.accounts.keep-todo'); +Route::prefix('workspace/other/systemization-todos') + ->name('business.systemization-todos.') + ->middleware('business.page:other,systemization-todos') + ->group(function (): void { + Route::post('import', [SystemizationTodos::class, 'importWorkbook']) + ->middleware('import') + ->name('import'); + Route::post('/', [SystemizationTodos::class, 'store'])->name('store'); + Route::patch('{todo}', [SystemizationTodos::class, 'update']) + ->whereNumber('todo') + ->name('update'); + Route::delete('{todo}', [SystemizationTodos::class, 'destroy']) + ->whereNumber('todo') + ->name('destroy'); + Route::get('{todo}/attachments/{attachment}', [SystemizationTodos::class, 'attachment']) + ->whereNumber('todo') + ->whereNumber('attachment') + ->name('attachments.show'); + }); + Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $request, ?string $page = null, ?string $tab = null) { abort_unless($page === null || $page === 'ledger', 404); diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index 06068b9e..f8d406b5 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -636,7 +636,7 @@ class ManagementCrudTest extends FeatureTestCase array_column($sections['store-management']['pages'], 'slug') ); $this->assertSame( - ['todos', 'warnings'], + ['systemization-todos', 'todos', 'warnings'], array_column($sections['other']['pages'], 'slug') ); @@ -711,6 +711,7 @@ class ManagementCrudTest extends FeatureTestCase ['section' => 'trademark-management', 'page' => 'trademark-progress', 'headers' => ['商标号', '是否完成', '办理人']], ['section' => 'store-management', 'page' => 'store-report', 'headers' => ['ERP系统店铺名称', '店铺名称', '店铺ID(唯一值)', '1月销量', '12月销量']], ['section' => 'store-management', 'page' => 'change-progress', 'headers' => ['接替公司', '完成时间', '办理人']], + ['section' => 'other', 'page' => 'systemization-todos', 'headers' => ['类型', '内容', '待办事项', '总负责人', '责任人', '状态', '完成截图']], ['section' => 'other', 'page' => 'todos', 'headers' => ['待办编号', '是否逾期', '关闭日期']], ['section' => 'other', 'page' => 'warnings', 'headers' => ['风险等级', '影响金额', '处理证据']], ]; diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php new file mode 100644 index 00000000..3db1d820 --- /dev/null +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -0,0 +1,152 @@ +loginAs() + ->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ])) + ->assertOk() + ->assertSeeText('财务系统化待办') + ->assertSeeText('新增待办') + ->assertSeeText('从 Excel 导入现有待办') + ->assertSee('data-sys-todo-create', false); + + $this->post(route('business.systemization-todos.store'), [ + 'category' => '收入', + 'section' => '平台收入核对', + 'title' => '核对淘系收入与平台账单差异', + 'owner' => '晨曦', + 'assignee' => '栎阳', + 'start_date' => '2026-10-25', + 'due_date' => '2026-10-28', + 'status' => SystemizationTodo::STATUS_IN_PROGRESS, + 'progress' => '已拉取账单,待核对订单', + 'notes' => '周会跟进', + 'screenshots' => [ + $this->pngUpload('first.png'), + $this->pngUpload('existing.png'), + ], + ])->assertRedirect(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ])); + + $todo = SystemizationTodo::query()->firstOrFail(); + $this->assertSame('收入', $todo->category); + $this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status); + $this->assertSame(2, $todo->attachments()->count()); + $firstAttachment = $todo->attachments()->firstOrFail(); + $existingAttachment = $todo->attachments()->reorder()->orderByDesc('id')->firstOrFail(); + Storage::disk('uploads')->assertExists($firstAttachment->path); + Storage::disk('uploads')->assertExists($existingAttachment->path); + $this->get(route('business.systemization-todos.attachments.show', [ + 'todo' => $todo->id, + 'attachment' => $firstAttachment->id, + ]))->assertOk()->assertHeader('content-type', 'image/png'); + + $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [ + 'category' => '收入', + 'section' => '平台收入核对', + 'title' => '核对淘系收入与平台账单差异', + 'owner' => '晨曦', + 'assignee' => '栎阳', + 'start_date' => '2026-10-25', + 'due_date' => '2026-10-27', + 'status' => SystemizationTodo::STATUS_COMPLETED, + 'progress' => '已完成核对并留档', + 'notes' => '周会确认关闭', + 'remove_attachments' => [$firstAttachment->id], + 'screenshots' => [$this->pngUpload('completed.png')], + ])->assertRedirect(); + + $todo->refresh(); + $this->assertSame(SystemizationTodo::STATUS_COMPLETED, $todo->status); + $this->assertSame('2026-10-27', $todo->completed_at?->format('Y-m-d')); + Storage::disk('uploads')->assertMissing($firstAttachment->path); + $this->assertTrue( + Storage::disk('uploads')->exists($existingAttachment->path), + '保留截图丢失:first=' . $firstAttachment->path . ' existing=' . $existingAttachment->path, + ); + $this->assertSame(2, $todo->attachments()->count()); + $this->assertTrue( + $todo->attachments()->pluck('original_name')->contains('completed.png') + ); + + $this->delete(route('business.systemization-todos.destroy', ['todo' => $todo->id])) + ->assertRedirect(); + $this->assertSoftDeleted('business_systemization_todos', ['id' => $todo->id]); + } + + public function test_excel_import_preserves_hierarchy_and_does_not_overwrite_existing_rows(): void + { + $path = sys_get_temp_dir() . '/systemization-todos-' . uniqid() . '.xlsx'; + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([ + ['类型', '内容', '待办事项(具体工作计划)', '总负责人', '责任人', '开始时间', '完成时间', '完成情况(放截图)', '备注'], + ['收入', '平台收入核对', '核对淘系账单', '晨曦', '栎阳', '2026-10-20', '2026-10-21', '已完成', '首轮导入'], + [null, null, '核对拼多多账单', '晨曦', '秋香', '待定', '11月', '第一步已开始', null], + ]); + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + + try { + $this->loginAs()->post(route('business.systemization-todos.import'), [ + 'source' => new UploadedFile( + $path, + '10.19工作梳理.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + null, + true, + ), + ])->assertRedirect(); + + $this->assertSame(2, SystemizationTodo::query()->count()); + $first = SystemizationTodo::query()->orderBy('sort_order')->firstOrFail(); + $second = SystemizationTodo::query()->orderByDesc('sort_order')->firstOrFail(); + $this->assertSame('收入', $first->category); + $this->assertSame('平台收入核对', $first->section); + $this->assertSame(SystemizationTodo::STATUS_COMPLETED, $first->status); + $this->assertSame('收入', $second->category); + $this->assertSame('平台收入核对', $second->section); + $this->assertSame('待定', $second->start_note); + $this->assertSame('11月', $second->due_note); + $this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $second->status); + + $result = app(SystemizationTodoWorkbookImporter::class) + ->import($path, (int) company_id(), user_id()); + $this->assertSame(0, $result['created']); + $this->assertSame(2, $result['skipped']); + + $this->assertSame(2, SystemizationTodo::query()->count()); + $this->assertSame(0, SystemizationTodoAttachment::query()->count()); + } finally { + @unlink($path); + } + } + + private function pngUpload(string $name): UploadedFile + { + return UploadedFile::fake()->createWithContent( + $name, + base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='), + ); + } +}