loginAs()
->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))
->assertOk()
->assertSeeText('财务系统化待办')
->assertSeeText('新增待办')
->assertSeeText('从 Excel 同步待办')
->assertSeeText('总负责人')
->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)
->assertSee('data-sys-todo-section-select', false)
->assertSee('data-sys-todo-new-section-input', false)
->assertSee('data-sys-todo-position', 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('+ 新增内容')
->assertSee('data-sys-todo-choose-files', 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)
->assertDontSee('
操作 | ', false)
->assertDontSeeText('下一页')
->assertSee('id="sysTodoImageViewer"', false);
$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_IN_PROGRESS,
'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_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_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,
);
}
$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_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_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('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->assertSame(
['__overdue__', '待办'],
array_column($pageContent['todoStatusOptions']->all(), 'value'),
);
$pageItems = $pageContent['items'];
$this->assertSame(
[1, 1],
$pageItems->where('category', '收入')
->pluck('type_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$this->assertSame(
[1, 2],
$pageItems->where('category', '收入')
->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(
[$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_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_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='),
);
}
}