CaiWuXiTong/tests/Feature/Business/SystemizationTodosTest.php
yueyang e39d9b860a feat: 支持系统化待办逐条后插新增 (#637)
> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。

## 任务占用与边界

- 任务名称:
- 实际开发者:
- 任务来源/团队分工:
- 目标结果:
- 预计修改路径:
- 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件):
- 仅复用、明确不修改的公共契约:
- 数据源、表/视图和字段:
- 上游 `source_key` / `scope`:
- 权限名、菜单键或共享组件:
- 明确不修改的范围:

## 开工碰撞检查

- [ ] Codex 已执行 `scripts/codex-task-preflight.ps1`
- [ ] 已检查最新 `main` 和所有远程任务分支
- [ ] 已搜索主线既有页面、接口、Repository 和数据查询
- [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件
- [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论

相关分支、重叠项与协调结论:

## 实际改动

- 完成内容:
- Codex 实际修改的文件:
- 新增或变更的公共契约:
- 与原计划的差异:

## 验证证据

- 正常流程:
- 空数据/错误输入/无权限场景:
- 受影响旧功能:
- 自动测试及结果:
- 实际页面、接口或只读业务查询结果:

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用”

高风险影响、回滚和授权状态:

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 `WIP:`,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: #637
2026-10-26 19:38:55 +08:00

738 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use App\Business\SystemizationTodoWorkbookImporter;
use App\Models\Business\SystemizationTodo;
use App\Models\Business\SystemizationTodoAttachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class SystemizationTodosTest extends FeatureTestCase
{
public function test_page_supports_daily_items_completion_progress_and_screenshots(): void
{
Storage::fake('uploads');
$this->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)
->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-open-delete', false)
->assertSee('id="sysTodoDeleteConfirm"', false)
->assertSee('data-sys-todo-table-wrap', false)
->assertSee('data-sys-todo-scrollrail', false)
->assertDontSee('<th scope="col">操作</th>', 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,
);
}
$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_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、平台收入核对', '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('<th scope="col">内容</th><th scope="col">待办事项序号</th><th scope="col">待办事项</th>', false)
->assertSee('sys-todo-table__type', false)
->assertSee('title="类型序号">1</span>', false)
->assertSee('title="内容序号">1</span>', false)
->assertSee('title="当前内容内待办序号">2</span>', 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(
['__overdue__', '待办'],
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(
[$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 . '</span>',
),
);
}
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='),
);
}
}