loginAs() ->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ])) ->assertOk() ->assertSeeText('财务系统化待办') ->assertSeeText('新增待办') ->assertSeeText('从 Excel 导入现有待办') ->assertSeeText('总负责人') ->assertSeeText('责任人') ->assertSeeText('已逾期') ->assertSeeText('开始时间') ->assertSeeText('完成时间') ->assertSeeText('在列表底部新增一条') ->assertSee('data-sys-todo-create', false) ->assertSee('data-sys-todo-owner-filter', false) ->assertSee('data-sys-todo-assignee-filter', false) ->assertSee('name="owner[]"', false) ->assertSee('name="assignee[]"', false) ->assertSee('data-sys-todo-category-select', false) ->assertSee('data-sys-todo-new-category-input', false) ->assertSeeText('+ 新增类型') ->assertSee('data-sys-todo-choose-files', false) ->assertSee('id="sysTodoImageViewer"', 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('晨曦、人参', $todo->owner); $this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status); $this->assertSame(2, $todo->attachments()->count()); $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ])) ->assertOk() ->assertSee('data-sys-todo-row-edit', false) ->assertSee('data-sys-todo-image-url', false); $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); } } public function test_page_filters_by_date_and_merges_adjacent_category_and_section_cells(): void { $this->loginAs(); $this->createTodo('收入', '1、平台收入核对', '1、收入事项 A', '2026-10-26', 1, '晨曦', '栎阳、秋香'); $this->createTodo('收入', '平台收入核对', '2、收入事项 B', '2026-10-27', 2); $this->createTodo('成本', '3、退款核销', '3、成本事项', '2026-10-26', 3, '常仪', '山时'); $this->createTodo('费用', '4、逾期核对', '4、逾期事项', '2026-10-20', 4, '人参', '玉兔'); $pageResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ])); $pageResponse ->assertOk() ->assertSee('rowspan="2"', false) ->assertSee('内容序号待办事项', false) ->assertSee('title="当前类型内序号">1', false) ->assertSee('title="当前类型内序号">2', false) ->assertDontSee('1、平台收入核对') ->assertDontSee('3、退款核销') ->assertDontSee('1、收入事项 A') ->assertDontSee('3、成本事项') ->assertSee('收入事项 A') ->assertSee('收入事项 B'); $pageContent = $pageResponse->viewData('content'); $this->assertSame( ['__overdue__', '待办'], array_column($pageContent['todoStatusOptions']->all(), 'value'), ); $pageItems = $pageContent['items']->getCollection(); $this->assertSame( [1, 2], $pageItems->where('category', '收入') ->pluck('category_sequence') ->map(static fn (mixed $value): int => (int) $value) ->values() ->all(), ); $this->assertSame( [1], $pageItems->where('category', '成本') ->pluck('category_sequence') ->map(static fn (mixed $value): int => (int) $value) ->values() ->all(), ); $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'due_date' => '2026-10-26', ])); $response->assertOk()->assertSee('收入事项 A')->assertSee('成本事项'); $content = $response->viewData('content'); $this->assertNotContains('收入事项 B', $content['items']->pluck('title')->all()); $this->assertSame([ 'due_date' => '2026-10-26', ], array_intersect_key($content['todoFilters'], array_flip(['due_date']))); $peopleResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'assignee' => ['秋香', '山时'], ]))->assertOk(); $peopleContent = $peopleResponse->viewData('content'); $this->assertEqualsCanonicalizing( ['1、收入事项 A', '3、成本事项'], $peopleContent['items']->pluck('title')->all(), ); $ownerResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'owner' => ['常仪', '人参'], ]))->assertOk(); $this->assertEqualsCanonicalizing( ['3、成本事项', '4、逾期事项'], $ownerResponse->viewData('content')['items']->pluck('title')->all(), ); $overdueResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'status' => '__overdue__', ]))->assertOk(); $this->assertSame( ['4、逾期事项'], $overdueResponse->viewData('content')['items']->pluck('title')->all(), ); } private function createTodo( string $category, string $section, string $title, string $dueDate, int $sortOrder, string $owner = '晨曦', string $assignee = '栎阳', ): SystemizationTodo { return SystemizationTodo::query()->create([ 'company_id' => (int) company_id(), 'category' => $category, 'section' => $section, 'title' => $title, 'owner' => $owner, 'assignee' => $assignee, 'start_date' => '2026-10-25', 'due_date' => $dueDate, 'status' => SystemizationTodo::STATUS_PENDING, 'sort_order' => $sortOrder, 'created_by' => user_id(), 'updated_by' => user_id(), ]); } private function pngUpload(string $name): UploadedFile { return UploadedFile::fake()->createWithContent( $name, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='), ); } }