loginAs() ->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ])) ->assertOk() ->assertSeeText('财务系统化待办') ->assertSeeText('新增待办') ->assertSeeText('从 Excel 同步待办') ->assertSeeText('总负责人') ->assertSeeText('责任人') ->assertSeeText('待办事项序号') ->assertSeeText('已逾期') ->assertSeeText('开始时间') ->assertSeeText('完成时间') ->assertSeeText('待定') ->assertSee('name="reminder"', false) ->assertSeeText('全部提醒') ->assertSee('>查询', false) ->assertSeeText('完成情况 / 备注') ->assertSeeText('提醒') ->assertSee('data-sys-todo-default-start-date', false) ->assertSee('data-sys-todo-history-open', false) ->assertSee('id="sysTodoHistoryDialog"', false) ->assertSee('data-sys-todo-history-export', false) ->assertSee('data-sys-todo-inline-add', false) ->assertSee('data-sys-todo-col-resizer', false) ->assertSee('data-sys-todo-reset-columns', false) ->assertSee('data-sys-todo-date-pending="start"', false) ->assertSee('data-sys-todo-date-pending="due"', false) ->assertSee('data-sys-todo-date-toggle="start"', false) ->assertSee('data-sys-todo-date-clear="due"', false) ->assertSee('data-date-picker', false) ->assertSee('window.CaiWuDatePicker', false) ->assertSee('placeholder="YYYY-MM-DD"', false) ->assertDontSee('id="sysTodoStartDate" type="date"', false) ->assertDontSee('id="sysTodoDueDate" type="date"', false) ->assertSee('name="start_pending"', false) ->assertSee('name="due_pending"', false) ->assertSee('data-sys-todo-owner-filter', false) ->assertSee('data-sys-todo-assignee-filter', false) ->assertSee('data-assignee-owners', false) ->assertSee('data-sys-todo-assignee-options', false) ->assertSee('data-sys-todo-assignee-value', false) ->assertDontSee('id="sysTodoAssignees"', false) ->assertDontSeeText('无提醒') ->assertSee('name="owner[]"', false) ->assertSee('name="assignee[]"', false) ->assertSee('data-sys-todo-category-select', false) ->assertSee('data-sys-todo-new-category-input', false) ->assertSee('data-sys-todo-section-select', false) ->assertSee('data-sys-todo-new-section-input', false) ->assertSee('data-sys-todo-position', false) ->assertSee('data-sys-todo-insert-after', false) ->assertSeeText('待办事项序号') ->assertDontSee('name="type_sequence"', false) ->assertDontSee('name="content_sequence"', false) ->assertDontSee('name="item_sequence"', false) ->assertSee('data-sys-todo-group-edit', false) ->assertSee('id="sysTodoGroupDialog"', false) ->assertDontSee('sys-todo-table__clamp', false) ->assertSeeText('+ 新增类型') ->assertSeeText('+ 新增内容') ->assertSeeText('在类型、内容下方的+直接新增') ->assertSee('data-sys-todo-choose-files', false) ->assertSee('data-sys-todo-remove-attachment', false) ->assertSee('data-sys-todo-remove-input', false) ->assertSee('is-marked-for-removal', false) ->assertSee('保存后删除', false) ->assertSee('data-sys-todo-open-delete', false) ->assertSee('id="sysTodoDeleteConfirm"', false) ->assertSee('data-sys-todo-table-wrap', false) ->assertSee('data-sys-todo-scrollrail', false) ->assertSee('data-sys-todo-grid-save-url', false) ->assertSeeText('单击单元格直接编辑') ->assertSeeText('将 Excel 内容粘贴进来') ->assertSee('data-sys-todo-image-prev', false) ->assertSee('data-sys-todo-image-next', false) ->assertDontSee('playlist_add', false) ->assertDontSee('操作', false) ->assertDontSeeText('下一页') ->assertSee('id="sysTodoImageViewer"', false); $this->assertSame(0, substr_count((string) $pageResponse->getContent(), 'data-sys-todo-create')); $pageData = $pageResponse->viewData('content'); $this->assertSame( [SystemizationTodo::STATUS_INCOMPLETE, SystemizationTodo::STATUS_COMPLETED], $pageData['todoStatuses'], ); $this->assertSame([ 'value' => 'none', 'label' => '已完成', ], $pageData['todoReminderOptions']->firstWhere('value', 'none')); $storeResponse = $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_INCOMPLETE, 'progress' => '已拉取账单,待核对订单', 'notes' => '周会跟进', 'screenshots' => [ $this->pngUpload('first.png'), $this->pngUpload('existing.png'), ], ]); $storeResponse->assertRedirect(); $todo = SystemizationTodo::query()->firstOrFail(); $location = (string) $storeResponse->headers->get('Location'); parse_str((string) parse_url($location, PHP_URL_QUERY), $redirectQuery); $this->assertSame((string) $todo->id, $redirectQuery['highlight'] ?? null); $this->assertSame('收入', $todo->category); $this->assertSame('晨曦、人参', $todo->owner); $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $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-gallery', false) ->assertSee('data-sys-todo-grid-cell', false) ->assertSee('data-sys-todo-grid-field="title"', false) ->assertSee('data-sys-todo-grid-field="progress"', false) ->assertSee('data-sys-todo-grid-insert', false) ->assertSee('data-sys-todo-grid-move="up"', false) ->assertSee('data-sys-todo-grid-move="down"', false) ->assertSee('data-sys-todo-grid-delete', false) ->assertSeeText('截图 2'); $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_column_widths_are_saved_as_a_shared_page_preference(): void { $this->loginAs(); $widths = [ 'type' => 96, 'section' => 228, 'sequence' => 98, 'title' => 360, 'owner' => 112, 'assignee' => 116, 'start_date' => 120, 'due_date' => 120, 'reminder' => 118, 'status' => 96, 'progress_notes' => 280, ]; $this->postJson(route('business.systemization-todos.column-widths'), [ 'widths' => $widths, ])->assertOk() ->assertJson([ 'ok' => true, 'widths' => $widths, ]); $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk(); $content = $response->viewData('content'); $this->assertSame($widths, $content['todoColumnWidths']); $this->assertSame( route('business.systemization-todos.column-widths'), $content['todoRoutes']['columnWidths'], ); $this->assertStringContainsString( 'serverColumnWidths = ' . json_encode( $widths, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, ), (string) $response->getContent(), ); } public function test_default_column_widths_match_the_shared_todo_layout(): void { $this->loginAs(); $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk(); $defaults = [ 'type' => 94, 'section' => 212, 'sequence' => 94, 'title' => 330, 'owner' => 80, 'assignee' => 80, 'start_date' => 88, 'due_date' => 88, 'reminder' => 116, 'status' => 92, 'progress_notes' => 260, ]; foreach ($defaults as $column => $width) { $response->assertSee( 'data-sys-todo-col="' . $column . '" data-default-width="' . $width . '"', false, ); } } public function test_long_progress_and_notes_keep_the_standard_table_row_layout(): void { $this->loginAs(); $progress = str_repeat( '得物的安装费,是否在货款里;收款方式包括线下支付宝、京东、天猫、淘宝、拼多多及得物二维码。', 8, ); $notes = str_repeat('已核对订单号 116212206747637437,货款流水与账单均已留档。', 5); $this->createTodo( '费用', '平台费用核对', '核对安装费与货款', '2026-10-30', 1, )->update([ 'progress' => $progress, 'notes' => $notes, ]); $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk(); $html = (string) $response->getContent(); $response ->assertSee('
', false) ->assertSee('' . e($progress) . '', false) ->assertSee('' . e($notes) . '', false); $this->assertStringNotContainsString('.sys-todo-table__progress-notes { display:grid;', $html); $this->assertStringNotContainsString('.sys-todo-table td { height:3.5rem;', $html); $this->assertStringNotContainsString('.sys-todo-table tbody tr { height:3.5rem; }', $html); $this->assertMatchesRegularExpression( '/\s*
.*?' . preg_quote(e($progress), '/') . '.*?' . preg_quote(e($notes), '/') . '.*?<\/div>\s*<\/td>/s', $html, ); } public function test_multi_person_owners_and_assignees_render_one_name_per_line(): void { $this->loginAs(); $this->createTodo( '收入', '多人责任人展示', '检查多人姓名换行', '2026-11-30', 1, '晨曦、人参', '人参、秋香、常仪', ); $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk(); $html = (string) $response->getContent(); $this->assertStringContainsString( '', $html, ); $this->assertMatchesRegularExpression( '/\s*]*>\s*' . '\s*' . '晨曦<\/span>\s*' . '人参<\/span>\s*' . '<\/span>\s*<\/span>\s*<\/td>/s', $html, ); $this->assertMatchesRegularExpression( '/\s*]*>\s*' . '\s*' . '人参<\/span>\s*' . '秋香<\/span>\s*' . '常仪<\/span>\s*' . '<\/span>\s*<\/span>\s*<\/td>/s', $html, ); } public function test_people_filter_splits_slash_separated_names(): void { $this->loginAs(); $this->createTodo( '收入', '筛选责任人拆分', '核对斜杠分隔的多人姓名', '2026-11-30', 1, '人参 / 周生辰', '人参 / 周生辰', ); $content = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk()->viewData('content'); $this->assertSame( ['人参', '晨曦', '常仪', '待定'], $content['todoOwners']->all(), ); $this->assertContains('人参', $content['todoAssignees']); $this->assertContains('周生辰', $content['todoAssignees']); $this->assertNotContains('人参 / 周生辰', $content['todoAssignees']); $this->assertSame('人参', $content['items']->firstOrFail()->owner_display); } public function test_owner_filter_uses_canonical_owner_mapping(): void { $this->loginAs(); $this->createTodo( '收入', '负责人映射', '玉兔归常仪', '2026-11-30', 1, '玉兔', '玉兔', ); $this->createTodo( '收入', '负责人映射', '周生辰归人参', '2026-11-30', 2, '周生辰', '周生辰', ); $this->createTodo( '收入', '负责人映射', '已有总负责人保持人参', '2026-11-30', 3, '人参', '苏木', ); $this->createTodo( '收入', '负责人映射', '待定负责人事项', '2026-11-30', 4, '待定', '晨曦', ); $this->createTodo( '收入', '负责人映射', '未填写负责人事项', '2026-11-30', 5, '', '秋香', ); $changyiResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'owner' => ['常仪'], ]))->assertOk(); $changyiContent = $changyiResponse->viewData('content'); $this->assertSame( ['玉兔归常仪'], $changyiContent['items']->pluck('title')->all(), ); $this->assertSame('常仪', $changyiContent['items']->firstOrFail()->owner_display); $selfOwnerResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'owner' => ['人参'], ]))->assertOk(); $selfOwnerContent = $selfOwnerResponse->viewData('content'); $this->assertEqualsCanonicalizing( ['周生辰归人参', '已有总负责人保持人参'], $selfOwnerContent['items']->pluck('title')->all(), ); $pendingResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'owner' => ['待定'], ]))->assertOk(); $this->assertEqualsCanonicalizing( ['待定负责人事项', '未填写负责人事项'], $pendingResponse->viewData('content')['items']->pluck('title')->all(), ); $aliasFilterResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'owner' => ['玉兔'], ]))->assertOk(); $this->assertSame( ['常仪'], $aliasFilterResponse->viewData('content')['todoFilters']['owners'], ); } public function test_manual_owner_values_are_saved_as_canonical_owners(): void { $this->loginAs(); $this->post(route('business.systemization-todos.store'), [ 'category' => '收入', 'section' => '负责人规范化', 'title' => '新增时归并总负责人', 'owner' => '苏木 / 李斯', 'assignee' => '苏木 / 李斯', 'start_date' => '2026-10-27', 'due_date' => '2026-11-30', 'status' => SystemizationTodo::STATUS_INCOMPLETE, ])->assertRedirect(); $todo = SystemizationTodo::query()->firstOrFail(); $this->assertSame('人参', $todo->owner); $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [ 'category' => '收入', 'section' => '负责人规范化', 'title' => '新增时归并总负责人', 'owner' => '玉兔', 'assignee' => '玉兔', 'start_date' => '2026-10-27', 'due_date' => '2026-11-30', 'status' => SystemizationTodo::STATUS_INCOMPLETE, ])->assertRedirect(); $this->assertSame('常仪', $todo->refresh()->owner); } public function test_assignee_owner_correspondence_map_includes_fixed_and_derived_people(): void { $this->loginAs(); $this->createTodo( '收入', '负责人关联', '关联玉兔', '2026-11-30', 1, '常仪', '玉兔', ); $this->createTodo( '收入', '负责人关联', '关联蕙兰', '2026-11-30', 2, '常仪', '蕙兰', ); $this->createTodo( '收入', '负责人关联', '关联李斯', '2026-11-30', 3, '人参', '李斯', ); $content = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk()->viewData('content'); $map = $content['todoAssigneeOwners']; $this->assertSame(['常仪'], $map['玉兔']); $this->assertSame(['常仪'], $map['蕙兰']); $this->assertSame(['人参'], $map['李斯']); } public function test_can_insert_a_todo_at_a_selected_sequence_and_shift_existing_items(): void { $this->loginAs(); $items = []; foreach (range(1, 7) as $index) { $items[$index] = $this->createTodo( '收入', '平台收入核对', '原待办 ' . $index, '2026-11-30', $index, ); } $initialPage = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk() ->assertSee('title="在第 3 条后插入空白行"', false) ->assertSee('data-sys-todo-insert-position="4"', false); $this->assertMatchesRegularExpression( '/id="sys-todo-row-' . $items[3]->id . '".*?data-sys-todo-insert-position="4"/s', (string) $initialPage->getContent(), ); $this->post(route('business.systemization-todos.store'), [ 'category' => '收入', 'section' => '平台收入核对', 'position' => 7, 'title' => '新增待办 7', 'owner' => '晨曦', 'assignee' => '栎阳', 'status' => SystemizationTodo::STATUS_PENDING, ])->assertRedirect() ->assertSessionHas('success', '待办事项已新增为当前内容第 7 条并定位。'); $page = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk()->viewData('content'); $group = $page['items'] ->where('category', '收入') ->where('section', '平台收入核对') ->values(); $this->assertSame([ '原待办 1', '原待办 2', '原待办 3', '原待办 4', '原待办 5', '原待办 6', '新增待办 7', '原待办 7', ], $group->pluck('title')->all()); $this->assertSame( range(1, 8), $group->pluck('item_sequence')->map(static fn (mixed $value): int => (int) $value)->all(), ); $this->assertGreaterThan( $items[6]->fresh()->sort_order, $items[7]->fresh()->sort_order, ); $this->patch(route('business.systemization-todos.update', ['todo' => $items[7]->id]), [ 'category' => '收入', 'section' => '平台收入核对', 'position' => 2, 'title' => '原待办 7', 'owner' => '晨曦', 'assignee' => '栎阳', 'status' => SystemizationTodo::STATUS_PENDING, ])->assertRedirect(); $reordered = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk()->viewData('content')['items'] ->where('category', '收入') ->where('section', '平台收入核对') ->values(); $this->assertSame([ '原待办 1', '原待办 7', '原待办 2', '原待办 3', '原待办 4', '原待办 5', '原待办 6', '新增待办 7', ], $reordered->pluck('title')->all()); } public function test_can_add_a_new_sequence_after_the_last_todo(): void { $this->loginAs(); foreach (range(1, 7) as $index) { $this->createTodo( '收入', '平台收入核对', '原待办 ' . $index, '2026-11-30', $index, ); } $this->post(route('business.systemization-todos.store'), [ 'category' => '收入', 'section' => '平台收入核对', 'position' => 8, 'title' => '新增待办 8', 'owner' => '晨曦', 'assignee' => '栎阳', 'status' => SystemizationTodo::STATUS_PENDING, ])->assertRedirect() ->assertSessionHas('success', '待办事项已新增为当前内容第 8 条并定位。'); $group = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk()->viewData('content')['items'] ->where('category', '收入') ->where('section', '平台收入核对') ->values(); $this->assertSame([ '原待办 1', '原待办 2', '原待办 3', '原待办 4', '原待办 5', '原待办 6', '原待办 7', '新增待办 8', ], $group->pluck('title')->all()); $this->assertSame(8, (int) $group->last()->item_sequence); } public function test_type_and_content_cells_can_be_edited_for_the_whole_group(): void { $this->loginAs(); $incomeA = $this->createTodo('收入', '平台收入核对', '收入事项 A', '2026-11-30', 1); $incomeB = $this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-11-30', 2); $incomeOther = $this->createTodo('收入', '退款核对', '退款事项', '2026-11-30', 3); $cost = $this->createTodo('成本', '成本核对', '成本事项', '2026-11-30', 4); $this->patch(route('business.systemization-todos.category', ['todo' => $incomeA->id]), [ 'category' => '业务收入', ])->assertRedirect(); foreach ([$incomeA, $incomeB, $incomeOther] as $todo) { $this->assertSame('业务收入', $todo->fresh()->category); } $this->assertSame('成本', $cost->fresh()->category); $this->patch(route('business.systemization-todos.section', ['todo' => $incomeA->id]), [ 'section' => '收入核对新版', ])->assertRedirect(); $this->assertSame('收入核对新版', $incomeA->fresh()->section); $this->assertSame('收入核对新版', $incomeB->fresh()->section); $this->assertSame('退款核对', $incomeOther->fresh()->section); $this->assertSame('成本核对', $cost->fresh()->section); } public function test_grid_cell_batch_update_supports_row_fields_and_group_scope(): void { $this->loginAs(); $first = $this->createTodo('收入', '平台收入核对', '核对收入 A', '2026-11-30', 1); $second = $this->createTodo('收入', '平台收入核对', '核对收入 B', '2026-11-30', 2); $other = $this->createTodo('成本', '成本核对', '核对成本', '2026-11-30', 3); $this->patchJson(route('business.systemization-todos.grid-cells'), [ 'changes' => [ [ 'id' => $first->id, 'field' => 'category', 'value' => '业务收入', 'original' => '收入', 'scope' => 'group', ], [ 'id' => $first->id, 'field' => 'title', 'value' => '核对收入 A(已更新)', 'original' => '核对收入 A', 'scope' => 'row', ], [ 'id' => $first->id, 'field' => 'owner', 'value' => '晨曦、人参', 'original' => '晨曦', 'scope' => 'row', ], [ 'id' => $first->id, 'field' => 'start_date', 'value' => '2026-10-28', 'original' => '2026-10-25', 'scope' => 'row', ], [ 'id' => $first->id, 'field' => 'due_date', 'value' => '2026-11-15', 'original' => '2026-11-30', 'scope' => 'row', ], [ 'id' => $first->id, 'field' => 'status', 'value' => SystemizationTodo::STATUS_COMPLETED, 'original' => SystemizationTodo::STATUS_INCOMPLETE, 'scope' => 'row', ], [ 'id' => $first->id, 'field' => 'progress', 'value' => '已完成第一轮核对', 'original' => '', 'scope' => 'row', ], [ 'id' => $first->id, 'field' => 'notes', 'value' => '周会复核', 'original' => '', 'scope' => 'row', ], ], ])->assertOk() ->assertJson([ 'ok' => true, 'updated_cells' => 8, 'updated_rows' => 2, ]); $first->refresh(); $second->refresh(); $this->assertSame('业务收入', $first->category); $this->assertSame('业务收入', $second->category); $this->assertSame('成本', $other->fresh()->category); $this->assertSame('核对收入 A(已更新)', $first->title); $this->assertSame('晨曦、人参', $first->owner); $this->assertSame('2026-10-28', $first->start_date?->format('Y-m-d')); $this->assertSame('2026-11-15', $first->due_date?->format('Y-m-d')); $this->assertSame(SystemizationTodo::STATUS_COMPLETED, $first->status); $this->assertSame('2026-11-15', $first->completed_at?->format('Y-m-d')); $this->assertSame('已完成第一轮核对', $first->progress); $this->assertSame('周会复核', $first->notes); $this->assertDatabaseHas('business_systemization_todo_audit_logs', [ 'company_id' => (int) company_id(), 'todo_id' => $first->id, 'action' => 'update', ]); } public function test_grid_paste_can_change_one_category_without_splitting_the_whole_group_by_default(): void { $this->loginAs(); $first = $this->createTodo('收入', '平台收入核对', '收入事项 A', '2026-11-30', 1); $second = $this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-11-30', 2); $this->patchJson(route('business.systemization-todos.grid-cells'), [ 'changes' => [[ 'id' => $second->id, 'field' => 'category', 'value' => '其他收入', 'original' => '收入', 'scope' => 'row', ]], ])->assertOk() ->assertJsonPath('updated_rows', 1); $this->assertSame('收入', $first->fresh()->category); $this->assertSame('其他收入', $second->fresh()->category); } public function test_grid_batch_update_rejects_invalid_dates_and_conflicts_atomically(): void { $this->loginAs(); $todo = $this->createTodo('收入', '平台收入核对', '日期校验', '2026-11-30', 1); $this->patchJson(route('business.systemization-todos.grid-cells'), [ 'changes' => [ [ 'id' => $todo->id, 'field' => 'start_date', 'value' => '2026-12-01', 'original' => '2026-10-25', 'scope' => 'row', ], [ 'id' => $todo->id, 'field' => 'due_date', 'value' => '2026-11-01', 'original' => '2026-11-30', 'scope' => 'row', ], ], ])->assertStatus(422); $todo->refresh(); $this->assertSame('2026-10-25', $todo->start_date?->format('Y-m-d')); $this->assertSame('2026-11-30', $todo->due_date?->format('Y-m-d')); $this->patchJson(route('business.systemization-todos.grid-cells'), [ 'changes' => [[ 'id' => $todo->id, 'field' => 'title', 'value' => '不应覆盖', 'original' => '已被他人修改的标题', 'scope' => 'row', ]], ])->assertStatus(409); $this->assertSame('日期校验', $todo->fresh()->title); } public function test_grid_row_can_move_up_and_down_within_its_content_group(): void { $this->loginAs(); $first = $this->createTodo('收入', '顺序测试', '第一行', '2026-11-30', 1); $second = $this->createTodo('收入', '顺序测试', '第二行', '2026-11-30', 2); $third = $this->createTodo('收入', '顺序测试', '第三行', '2026-11-30', 3); $this->patchJson(route('business.systemization-todos.grid-move', ['todo' => $second->id]), [ 'direction' => 'up', ])->assertOk(); $this->assertLessThan($first->fresh()->sort_order, $second->fresh()->sort_order); $this->assertLessThan($third->fresh()->sort_order, $first->fresh()->sort_order); $titles = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->viewData('content')['items']->pluck('title')->all(); $this->assertSame(['第二行', '第一行', '第三行'], $titles); $this->patchJson(route('business.systemization-todos.grid-move', ['todo' => $second->id]), [ 'direction' => 'down', ])->assertOk(); $this->assertSame( ['第一行', '第二行', '第三行'], $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->viewData('content')['items']->pluck('title')->all(), ); } public function test_grid_insert_creates_a_blank_row_after_the_selected_todo(): void { $this->loginAs(); $first = $this->createTodo('收入', '插入测试', '第一行', '2026-11-30', 1); $second = $this->createTodo('收入', '插入测试', '第二行', '2026-11-30', 2); $response = $this->postJson(route('business.systemization-todos.grid-insert', [ 'todo' => $first->id, ]))->assertOk() ->assertJsonPath('ok', true); $createdId = (int) $response->json('id'); $created = SystemizationTodo::query()->findOrFail($createdId); $this->assertSame('新待办', $created->title); $this->assertSame('收入', $created->category); $this->assertSame('插入测试', $created->section); $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $created->status); $this->assertLessThan($second->fresh()->sort_order, $first->fresh()->sort_order); $this->assertLessThan($second->fresh()->sort_order, $created->sort_order); $this->assertGreaterThan($first->fresh()->sort_order, $created->sort_order); $this->assertSame( ['第一行', '新待办', '第二行'], $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->viewData('content')['items']->pluck('title')->all(), ); } 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_manual_edit_saves_and_preserves_pending_date_notes(): void { $this->loginAs(); $this->post(route('business.systemization-todos.store'), [ 'category' => '收入', 'section' => '平台收入核对', 'title' => '待确认开始与完成日期', 'owner' => '晨曦', 'assignee' => '栎阳', 'start_date' => null, 'start_pending' => 1, 'due_date' => null, 'due_pending' => 1, 'status' => SystemizationTodo::STATUS_INCOMPLETE, 'progress' => '', 'notes' => '', ])->assertRedirect(); $todo = SystemizationTodo::query()->firstOrFail(); $this->assertNull($todo->start_date); $this->assertSame('待定', $todo->start_note); $this->assertNull($todo->due_date); $this->assertSame('待定', $todo->due_note); $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [ 'category' => '收入', 'section' => '平台收入核对', 'title' => '待确认开始与完成日期(已更新)', 'owner' => '晨曦', 'assignee' => '栎阳', 'start_date' => null, 'start_pending' => 1, 'due_date' => null, 'due_note' => '11月', 'status' => SystemizationTodo::STATUS_INCOMPLETE, 'progress' => '等待平台确认', 'notes' => '', ])->assertRedirect(); $todo->refresh(); $this->assertSame('待确认开始与完成日期(已更新)', $todo->title); $this->assertSame('待定', $todo->start_note); $this->assertSame('11月', $todo->due_note); } public function test_edit_redirect_keeps_current_filters_and_highlight(): void { $this->loginAs(); $todo = $this->createTodo('收入', '平台收入核对', '筛选保持测试', '2026-10-30', 1); $returnFilters = base64_encode(json_encode([ 'status' => SystemizationTodo::STATUS_INCOMPLETE, 'reminder' => 'upcoming', 'category' => '收入', 'owners' => ['晨曦', '人参'], 'assignees' => ['栎阳'], 'start_date' => '2026-10-25', 'due_date' => '2026-10-30', 'keyword' => '筛选', ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $response = $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [ 'category' => '收入', 'section' => '平台收入核对', 'title' => '筛选保持测试(已更新)', 'owner' => '晨曦', 'assignee' => '栎阳', 'start_date' => '2026-10-25', 'due_date' => '2026-10-30', 'status' => SystemizationTodo::STATUS_INCOMPLETE, 'return_filters' => $returnFilters, ])->assertRedirect(); $location = (string) $response->headers->get('Location'); parse_str((string) parse_url($location, PHP_URL_QUERY), $query); $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $query['status'] ?? null); $this->assertSame('upcoming', $query['reminder'] ?? null); $this->assertSame('收入', $query['category'] ?? null); $this->assertSame(['晨曦', '人参'], $query['owner'] ?? null); $this->assertSame(['栎阳'], $query['assignee'] ?? null); $this->assertSame('2026-10-25', $query['start_date'] ?? null); $this->assertSame('2026-10-30', $query['due_date'] ?? null); $this->assertSame('筛选', $query['keyword'] ?? null); $this->assertSame((string) $todo->id, $query['highlight'] ?? null); } public function test_reminder_and_status_treat_legacy_open_states_as_incomplete(): void { Carbon::setTestNow('2026-10-26 12:00:00'); try { $this->loginAs(); $this->createTodo('收入', '提醒测试', '逾期事项', '2026-10-20', 1); $this->createTodo('收入', '提醒测试', '今天到期事项', '2026-10-26', 2); $this->createTodo('收入', '提醒测试', '未来事项', '2026-10-30', 3); $completed = $this->createTodo('收入', '提醒测试', '已完成事项', '2026-10-22', 4); $completed->update([ 'status' => SystemizationTodo::STATUS_COMPLETED, 'completed_at' => '2026-10-22', 'progress' => '已完成', ]); $legacy = $this->createTodo('收入', '提醒测试', '历史进行中事项', '2026-11-01', 5); $legacy->update(['status' => '进行中']); $pendingDate = $this->createTodo('收入', '提醒测试', '时间待定事项', '2026-10-30', 6); $pendingDate->update(['due_date' => null, 'due_note' => '待定']); $pageResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk() ->assertSee('data-sys-todo-default-start-date="2026-10-26"', false) ->assertSee("payload.start_date || (!currentTodoId ? defaultStartDate : '')", false) ->assertSee("applyDateControl(startDateControl, defaultStartDate, '');", false); $content = $pageResponse->viewData('content'); $items = $content['items']->keyBy('title'); $this->assertSame('逾期 6 天', $items['逾期事项']->reminder_label); $this->assertSame('danger', $items['逾期事项']->reminder_tone); $this->assertSame('今天到期', $items['今天到期事项']->reminder_label); $this->assertSame('剩余 4 天', $items['未来事项']->reminder_label); $this->assertSame('—', $items['已完成事项']->reminder_label); $this->assertSame('时间待定', $items['时间待定事项']->reminder_label); $this->assertSame('', $items['已完成事项']->displayProgress()); $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $items['历史进行中事项']->status_label); $incompleteTitles = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'status' => SystemizationTodo::STATUS_INCOMPLETE, ]))->assertOk()->viewData('content')['items']->pluck('title'); $this->assertContains('历史进行中事项', $incompleteTitles); $this->assertNotContains('已完成事项', $incompleteTitles); $completedTitles = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'status' => SystemizationTodo::STATUS_COMPLETED, ]))->assertOk()->viewData('content')['items']->pluck('title'); $this->assertSame(['已完成事项'], $completedTitles->all()); foreach ([ 'overdue' => ['逾期事项'], 'today' => ['今天到期事项'], 'upcoming' => ['未来事项', '历史进行中事项'], 'pending' => ['时间待定事项'], 'none' => ['已完成事项'], ] as $reminder => $expectedTitles) { $reminderContent = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'reminder' => $reminder, ]))->assertOk()->viewData('content'); $this->assertSame($reminder, $reminderContent['todoFilters']['reminder']); $this->assertSame($expectedTitles, $reminderContent['items']->pluck('title')->all()); } } finally { Carbon::setTestNow(); } } public function test_page_filters_by_date_and_merges_adjacent_category_and_section_cells(): void { $this->travelTo(Carbon::parse('2026-10-26 12:00:00')); $this->loginAs(); $this->createTodo('1、收入', '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('data-sys-todo-column="section"', false) ->assertSee('data-sys-todo-column="sequence"', false) ->assertSee('data-sys-todo-column="title"', false) ->assertSee('sys-todo-table__type', false) ->assertSee('title="类型序号">1', 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->assertContains('平台收入核对', $pageContent['todoContentOptions']['收入']); $this->assertArrayNotHasKey('1、收入', $pageContent['todoContentOptions']); $this->assertSame( [SystemizationTodo::STATUS_INCOMPLETE, SystemizationTodo::STATUS_COMPLETED], array_column($pageContent['todoStatusOptions']->all(), 'value'), ); $pageItems = $pageContent['items']; $this->assertSame( [1, 1], $pageItems->where('category_display', '收入') ->pluck('type_sequence') ->map(static fn (mixed $value): int => (int) $value) ->values() ->all(), ); $this->assertSame( [1, 2], $pageItems->where('category_display', '收入') ->pluck('item_sequence') ->map(static fn (mixed $value): int => (int) $value) ->values() ->all(), ); $this->assertSame( [2], $pageItems->where('category', '成本') ->pluck('type_sequence') ->map(static fn (mixed $value): int => (int) $value) ->values() ->all(), ); $highlightedTodo = $pageItems->firstWhere('title', '2、收入事项 B'); $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', 'highlight' => $highlightedTodo->id, ])) ->assertOk() ->assertSee('id="sys-todo-row-' . $highlightedTodo->id . '" class="is-highlighted"', false); $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(), ); } public function test_same_content_stays_merged_and_receives_scoped_hierarchy_sequences(): void { $this->loginAs(); $mergedSection = '钉钉流程,纳税人 3、盘点'; $mergedSectionVariant = '钉钉流程,纳税人3、盘点'; $pending = $this->createTodo('收入', $mergedSection, '待办事项 A', '2026-10-30', 1); $completed = $this->createTodo('收入', $mergedSectionVariant, '待办事项 B', '2026-10-10', 2); $completed->update([ 'status' => SystemizationTodo::STATUS_COMPLETED, 'completed_at' => '2026-10-10', ]); $inProgress = $this->createTodo('收入', $mergedSection, '待办事项 C', '2026-10-20', 3); $inProgress->update(['status' => SystemizationTodo::STATUS_IN_PROGRESS]); $secondContent = $this->createTodo('收入', '报销流程', '待办事项 D', '2026-11-01', 4); $cost = $this->createTodo('成本', '成本核对', '待办事项 E', '2026-11-02', 5); $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk(); $content = $response->viewData('content'); $items = $content['items']; $this->assertSame( [$pending->id, $completed->id, $inProgress->id, $secondContent->id, $cost->id], $items->pluck('id')->all(), ); $this->assertSame( [[1, 1, [1, 2, 3]], [1, 2, [1]], [2, 1, [1]]], $items ->groupBy(static fn (SystemizationTodo $todo): string => $todo->type_sequence . ':' . $todo->content_sequence) ->map(static fn ($group): array => [ (int) $group->first()->type_sequence, (int) $group->first()->content_sequence, $group->pluck('item_sequence')->map(static fn (mixed $value): int => (int) $value)->all(), ]) ->values() ->all(), ); $this->assertSame( [$pending->id, $completed->id, $inProgress->id], $items ->where('type_sequence', 1) ->where('content_sequence', 1) ->whereIn('status', [SystemizationTodo::STATUS_COMPLETED, SystemizationTodo::STATUS_IN_PROGRESS]) ->pluck('id') ->all(), ); $this->assertSame( 1, substr_count( (string) $response->getContent(), 'sys-todo-table__group-text">' . $mergedSection . '', ), ); } public function test_content_with_equivalent_line_numbers_and_punctuation_stays_merged(): void { $this->loginAs(); $lineBreakVariant = "钉钉流程,报销流程的节点跟科目,开票公司,金额,专票/普票,小规模/一般纳税人\n" . '3、盘点钉钉付款流程归类,盘影响报表的流程'; $commaVariant = '钉钉流程,报销流程的节点跟科目,开票公司,金额,专票/普票,小规模/一般纳税人,' . '盘点钉钉付款流程归类,盘影响报表的流程'; $first = $this->createTodo('管理费用', $lineBreakVariant, '盘点表单字段', '2026-10-30', 1); $second = $this->createTodo('管理费用', $commaVariant, '匹配字段逻辑', '2026-10-31', 2); $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk(); $items = $response->viewData('content')['items'] ->whereIn('id', [$first->id, $second->id]) ->values(); $this->assertSame([1, 1], $items->pluck('content_sequence')->map( static fn (mixed $value): int => (int) $value )->all()); $this->assertSame([1, 2], $items->pluck('item_sequence')->map( static fn (mixed $value): int => (int) $value )->all()); } public function test_page_renders_all_todos_on_one_continuous_list(): void { $this->loginAs(); foreach (range(1, 55) as $index) { $this->createTodo( $index % 2 === 0 ? '收入' : '成本', '连续列表测试', '连续事项 ' . $index, '2026-11-30', $index, ); } $response = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', ]))->assertOk(); $content = $response->viewData('content'); $this->assertCount(55, $content['items']); $this->assertTrue($content['items']->pluck('title')->contains('连续事项 55')); $response->assertSee('连续事项 55') ->assertSeeText('共 55 条,已全部显示') ->assertDontSeeText('下一页') ->assertDontSeeText('上一页'); } public function test_excel_sync_updates_changed_rows_and_preserves_attachments(): void { Storage::fake('uploads'); $this->loginAs(); $initialPath = sys_get_temp_dir() . '/systemization-sync-initial-' . uniqid() . '.xlsx'; $updatedPath = sys_get_temp_dir() . '/systemization-sync-updated-' . uniqid() . '.xlsx'; try { $this->writeWorkbook($initialPath, [ ['收入', '顾家、索菲亚过账', '系统搭建', '常仪', '晨曦', '2026-11-20', '2026-11-22', null, '原备注'], ]); app(SystemizationTodoWorkbookImporter::class) ->import($initialPath, (int) company_id(), user_id()); $todo = SystemizationTodo::query()->firstOrFail(); Storage::disk('uploads')->put('systemization-todos/test.png', 'image'); SystemizationTodoAttachment::query()->create([ 'company_id' => (int) company_id(), 'systemization_todo_id' => $todo->id, 'path' => 'systemization-todos/test.png', 'original_name' => 'existing.png', 'mime_type' => 'image/png', 'size' => 5, 'uploaded_by' => user_id(), ]); $this->writeWorkbook($updatedPath, [ ['收入', '顾家、索菲亚过账', '系统搭建,系统搭建好之后交接给林冲', '晨曦', '蕙兰', '2026-11-23', '2026-11-25', '转接', null], [null, null, '新增系统交接说明', '晨曦', '蕙兰', '2026-10-26', '2026-10-27', null, null], ]); $preview = app(SystemizationTodoWorkbookImporter::class) ->sync($updatedPath, (int) company_id(), user_id(), true); $this->assertSame(1, $preview['created']); $this->assertSame(1, $preview['updated']); $this->assertSame(0, $preview['retained']); $this->assertSame(1, SystemizationTodo::query()->count()); $result = app(SystemizationTodoWorkbookImporter::class) ->sync($updatedPath, (int) company_id(), user_id()); $this->assertSame(1, $result['created']); $this->assertSame(1, $result['updated']); $this->assertSame(0, $result['retained']); $todo->refresh(); $this->assertSame('系统搭建,系统搭建好之后交接给林冲', $todo->title); $this->assertSame('蕙兰', $todo->assignee); $this->assertSame('2026-11-23', $todo->start_date?->format('Y-m-d')); $this->assertSame('2026-11-25', $todo->due_date?->format('Y-m-d')); $this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status); $this->assertSame(1, $todo->attachments()->count()); $this->assertTrue(SystemizationTodo::query()->where('title', '新增系统交接说明')->exists()); $secondResult = app(SystemizationTodoWorkbookImporter::class) ->sync($updatedPath, (int) company_id(), user_id()); $this->assertSame(0, $secondResult['created']); $this->assertSame(0, $secondResult['updated']); $this->assertSame(2, SystemizationTodo::query()->count()); } finally { @unlink($initialPath); @unlink($updatedPath); } } public function test_uploaded_workbook_uses_original_filename_when_temp_path_has_no_extension(): void { $this->loginAs(); $sourcePath = sys_get_temp_dir() . '/systemization-upload-source-' . uniqid() . '.xlsx'; $uploadPath = sys_get_temp_dir() . '/systemization-upload-' . uniqid(); try { $this->writeWorkbook($sourcePath, [[ '收入', '上传测试', '浏览器上传的工作簿', '晨曦', '栎阳', '2026-10-27', '2026-10-30', null, null, ]]); copy($sourcePath, $uploadPath); $upload = new UploadedFile( $uploadPath, '10.19工作梳理.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', null, true, ); $this->post(route('business.systemization-todos.import'), [ 'source' => $upload, ])->assertRedirect() ->assertSessionHas('success'); $this->assertDatabaseHas('business_systemization_todos', [ 'company_id' => (int) company_id(), 'title' => '浏览器上传的工作簿', ]); $this->assertDatabaseHas('business_systemization_todo_audit_logs', [ 'company_id' => (int) company_id(), 'action' => 'import', ]); } finally { @unlink($sourcePath); @unlink($uploadPath); } } public function test_manual_changes_are_listed_in_operation_history_and_csv(): void { $this->loginAs(); $this->post(route('business.systemization-todos.store'), [ 'category' => '收入', 'section' => '操作记录测试', 'title' => '原待办', 'owner' => '晨曦', 'assignee' => '栎阳', 'start_date' => '2026-10-27', 'due_date' => '2026-10-30', 'status' => SystemizationTodo::STATUS_INCOMPLETE, ])->assertRedirect(); $todo = SystemizationTodo::query()->firstOrFail(); $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [ 'category' => '收入', 'section' => '操作记录测试', 'title' => '修改后的待办', 'owner' => '晨曦、人参', 'assignee' => '栎阳', 'start_date' => '2026-10-27', 'due_date' => '2026-10-31', 'status' => SystemizationTodo::STATUS_COMPLETED, 'progress' => '已完成并留档', ])->assertRedirect(); $this->delete(route('business.systemization-todos.destroy', ['todo' => $todo->id])) ->assertRedirect(); $history = $this->getJson(route('business.systemization-todos.history')) ->assertOk() ->json(); $this->assertSame(['delete', 'update', 'create'], array_column($history['events'], 'action')); $this->assertSame($this->user->name, $history['events'][0]['actor']); $this->assertSame('修改后的待办', $history['events'][1]['title']); $this->assertContains('待办事项:原待办 → 修改后的待办', $history['events'][1]['changes']); $this->assertContains('状态:未完成 → 已完成', $history['events'][1]['changes']); $this->assertStringContainsString('删除前:收入 · 操作记录测试 · 修改后的待办', $history['events'][0]['changes'][0]); $csv = $this->get( route('business.systemization-todos.history.export') . '?action=update' )->streamedContent(); $this->assertStringContainsString('时间,操作,类型,内容,待办事项,变更内容,操作人', $csv); $this->assertStringContainsString('修改,收入,操作记录测试,修改后的待办', $csv); $this->assertStringNotContainsString('删除前:', $csv); $this->assertStringNotContainsString('新增:', $csv); } public function test_operation_history_starts_from_today(): void { $this->loginAs(); try { $this->travelTo(Carbon::parse('2026-10-27 23:59:00', 'Asia/Shanghai')); $this->post(route('business.systemization-todos.store'), [ 'category' => '收入', 'section' => '操作记录日期', 'title' => '昨天记录', 'owner' => '晨曦', 'assignee' => '栎阳', 'status' => SystemizationTodo::STATUS_INCOMPLETE, ])->assertRedirect(); $this->travelTo(Carbon::parse('2026-10-28 09:00:00', 'Asia/Shanghai')); $this->post(route('business.systemization-todos.store'), [ 'category' => '收入', 'section' => '操作记录日期', 'title' => '今天记录', 'owner' => '晨曦', 'assignee' => '栎阳', 'status' => SystemizationTodo::STATUS_INCOMPLETE, ])->assertRedirect(); $history = $this->getJson(route('business.systemization-todos.history')) ->assertOk() ->assertJsonPath('from', '2026-10-28 00:00:00') ->json(); $titles = array_column($history['events'], 'title'); $this->assertContains('今天记录', $titles); $this->assertNotContains('昨天记录', $titles); $csv = $this->get(route('business.systemization-todos.history.export')) ->streamedContent(); $this->assertStringContainsString('今天记录', $csv); $this->assertStringNotContainsString('昨天记录', $csv); } finally { $this->travelBack(); } } public function test_excel_sync_audits_changes_and_matches_numbered_group_labels_without_duplicates(): void { $this->loginAs(); $todo = $this->createTodo( '收入', '平台收入核对', '平台收入核对', '2026-11-30', 10, ); $todo->update(['imported_at' => now()]); $path = sys_get_temp_dir() . '/systemization-numbered-sync-' . uniqid() . '.xlsx'; try { $this->writeWorkbook($path, [[ '1、收入', '2、平台收入核对', '1、平台收入核对(已更新)', '晨曦', '栎阳', '2026-10-27', '2026-11-30', null, null, ]]); $preview = app(SystemizationTodoWorkbookImporter::class) ->sync($path, (int) company_id(), user_id(), true); $this->assertSame(0, $preview['created']); $this->assertSame(1, $preview['updated']); $this->assertSame(0, $preview['retained']); $this->assertDatabaseCount('business_systemization_todo_audit_logs', 0); $result = app(SystemizationTodoWorkbookImporter::class) ->sync($path, (int) company_id(), user_id()); $this->assertSame(0, $result['created']); $this->assertSame(1, $result['updated']); $this->assertSame(0, $result['retained']); $this->assertSame(1, SystemizationTodo::query()->count()); $todo->refresh(); $this->assertSame('收入', $todo->category); $this->assertSame('平台收入核对', $todo->section); $this->assertSame('平台收入核对(已更新)', $todo->title); $this->assertDatabaseHas('business_systemization_todo_audit_logs', [ 'company_id' => (int) company_id(), 'todo_id' => $todo->id, 'action' => 'import', ]); $event = $this->getJson(route('business.systemization-todos.history')) ->assertOk() ->json('events.0'); $this->assertSame('import', $event['action']); $this->assertContains( '待办事项:平台收入核对 → 平台收入核对(已更新)', $event['changes'] ); } finally { @unlink($path); } } public function test_excel_sync_ignores_placeholder_category_when_section_has_one_category(): void { $this->loginAs(); $todo = $this->createTodo('收入', '平台收入核对', '平台收入核对', '2026-11-01', 10); $todo->update(['imported_at' => now()]); $path = sys_get_temp_dir() . '/systemization-category-guard-' . uniqid() . '.xlsx'; try { $this->writeWorkbook($path, [[ ';lkj', '平台收入核对', '平台收入核对', '晨曦', '秋香', '2026-10-28', '2026-10-29', '已完成', null, ]]); $result = app(SystemizationTodoWorkbookImporter::class) ->sync($path, (int) company_id(), user_id()); $this->assertSame(0, $result['created']); $this->assertSame(1, $result['updated']); $this->assertSame('收入', $todo->fresh()->category); $this->assertSame('秋香', $todo->fresh()->assignee); } finally { @unlink($path); } } public function test_excel_sync_matches_repeated_titles_to_their_own_section_first(): void { $this->loginAs(); $warehouse = $this->createTodo('聚水潭', '京仓费用', '配置订单利润自动分摊规则', '2026-10-30', 900); $warehouse->update(['imported_at' => now()]); $invoice = $this->createTodo('聚水潭', '发票费用', '配置订单利润自动分摊规则', '2026-10-30', 100); $invoice->update(['imported_at' => now()]); $path = sys_get_temp_dir() . '/systemization-repeated-title-' . uniqid() . '.xlsx'; try { $this->writeWorkbook($path, [ ['2', '京仓费用', '配置订单利润自动分摊规则', '人参', '人参', '2026-10-30', '2026-10-30', null, null], ['2', '发票费用', '配置订单利润自动分摊规则', '人参', '秋香', '2026-10-30', '2026-10-30', null, null], ]); $result = app(SystemizationTodoWorkbookImporter::class) ->sync($path, (int) company_id(), user_id()); $this->assertSame(0, $result['created']); $this->assertSame(2, $result['updated']); $this->assertSame('京仓费用', $warehouse->fresh()->section); $this->assertSame('发票费用', $invoice->fresh()->section); $this->assertSame('人参', $warehouse->fresh()->assignee); $this->assertSame('秋香', $invoice->fresh()->assignee); } finally { @unlink($path); } } public function test_excel_sync_splits_numbered_titles_and_keeps_attachment_on_first_item(): void { Storage::fake('uploads'); $this->loginAs(); $combinedTitle = "1、核对平台收入\n\n2、核对订单收入\n3,核对特殊订单"; $historical = SystemizationTodo::query()->create([ 'company_id' => (int) company_id(), 'category' => '收入', 'section' => '平台收入核对', 'title' => $combinedTitle, 'owner' => '晨曦', 'assignee' => '栎阳', 'start_date' => '2026-10-20', 'due_date' => '2026-10-21', 'status' => SystemizationTodo::STATUS_COMPLETED, 'completed_at' => '2026-10-21', 'progress' => '已完成', 'sort_order' => 2, 'source_key' => 'legacy-combined-row', 'imported_at' => now(), 'created_by' => user_id(), 'updated_by' => user_id(), ]); Storage::disk('uploads')->put('systemization-todos/combined.png', 'image'); SystemizationTodoAttachment::query()->create([ 'company_id' => (int) company_id(), 'systemization_todo_id' => $historical->id, 'path' => 'systemization-todos/combined.png', 'original_name' => 'combined.png', 'mime_type' => 'image/png', 'size' => 5, 'uploaded_by' => user_id(), ]); $path = sys_get_temp_dir() . '/systemization-split-' . uniqid() . '.xlsx'; try { $this->writeWorkbook($path, [ ['收入', '平台收入核对', $combinedTitle, '晨曦', '栎阳', '2026-10-20', '2026-10-21', '已完成', null], ]); $preview = app(SystemizationTodoWorkbookImporter::class) ->sync($path, (int) company_id(), user_id(), true); $this->assertSame(2, $preview['created']); $this->assertSame(1, $preview['updated']); $this->assertSame(0, $preview['retained']); $result = app(SystemizationTodoWorkbookImporter::class) ->sync($path, (int) company_id(), user_id()); $this->assertSame(2, $result['created']); $this->assertSame(1, $result['updated']); $this->assertSame(0, $result['retained']); $todos = SystemizationTodo::query()->orderBy('sort_order')->get(); $this->assertSame([ '核对平台收入', '核对订单收入', '核对特殊订单', ], $todos->pluck('title')->all()); $this->assertSame($historical->id, $todos->first()->id); $this->assertSame(1, $todos->first()->attachments()->count()); $secondResult = app(SystemizationTodoWorkbookImporter::class) ->sync($path, (int) company_id(), user_id()); $this->assertSame(0, $secondResult['created']); $this->assertSame(0, $secondResult['updated']); $this->assertSame(3, SystemizationTodo::query()->count()); } finally { @unlink($path); } } 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 writeWorkbook(string $path, array $rows): void { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->fromArray([ ['类型', '内容', '待办事项(具体工作计划)', '总负责人', '责任人', '开始时间', '完成时间', '完成情况(放截图)', '备注'], ...$rows, ]); (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); } private function pngUpload(string $name): UploadedFile { return UploadedFile::fake()->createWithContent( $name, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='), ); } }