CaiWuXiTong/tests/Feature/Business/SystemizationTodosTest.php

1895 lines
79 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 Carbon\Carbon;
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');
$pageResponse = $this->loginAs()
->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))
->assertOk()
->assertSeeText('财务系统化待办')
->assertDontSeeText('从 Excel 同步待办')
->assertSeeText('总负责人')
->assertSeeText('责任人')
->assertSeeText('待办事项序号')
->assertSeeText('已逾期')
->assertSeeText('开始时间')
->assertSeeText('完成时间')
->assertSeeText('待定')
->assertSee('name="reminder"', false)
->assertSeeText('全部提醒')
->assertSee('>查询</button>', false)
->assertSeeText('完成情况')
->assertDontSeeText('完成情况 / 备注')
->assertSeeText('提醒')
->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-date-picker', false)
->assertSee('window.CaiWuDatePicker', false)
->assertSee('placeholder="YYYY-MM-DD"', false)
->assertSee('data-sys-todo-owner-filter', false)
->assertSee('data-sys-todo-assignee-filter', false)
->assertSee('data-assignee-owners', false)
->assertDontSee('id="sysTodoAssignees"', false)
->assertDontSeeText('无提醒')
->assertSee('name="owner[]"', false)
->assertSee('name="assignee[]"', 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('id="sysTodoDialog"', false)
->assertSee('id="sysTodoGroupDialog"', false)
->assertDontSee('data-sys-todo-row-edit="', false)
->assertDontSee('data-sys-todo-group-edit="', false)
->assertDontSee('sys-todo-table__clamp', false)
->assertSeeText('+ 新增类型')
->assertSeeText('+ 新增内容')
->assertSeeText('在类型、内容下方的+直接新增')
->assertSee('data-sys-todo-grid-upload-input', 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)
->assertSee('data-sys-todo-grid-create-url', false)
->assertDontSeeText('单击单元格直接编辑')
->assertDontSeeText('恢复全部默认列宽')
->assertSee('.sys-todo-table__type .sys-todo-table__inline-actions', false)
->assertSee('.sys-todo-table th:nth-child(2)', false)
->assertSee('.sys-todo-table__section { position:sticky', false)
->assertSee("todoTable.style.setProperty('--sys-todo-content-width'", false)
->assertSee('tr:hover .sys-todo-table__index-actions > .sys-todo-table__insert-after', false)
->assertSee('tr:has(.sys-todo-grid-value.is-selected) .sys-todo-table__row-actions', false)
->assertSee('.sys-todo-table [data-sys-todo-grid-upload] { display:none;', false)
->assertSee('tr:hover [data-sys-todo-grid-upload]', false)
->assertSee('tr:has(.sys-todo-grid-value.is-selected) [data-sys-todo-grid-upload]', false)
->assertSee("deleteForm.addEventListener('submit', saveScrollSnapshot)", false)
->assertSee('scrollUrlKey(storedScroll.url) === scrollUrlKey(window.location.href)', false)
->assertSee("if (field === 'start_date' || field === 'due_date')", false)
->assertSee('sys-todo-grid-date-editor__pending', false)
->assertSee('window.CaiWuDatePicker.setup(wrapper)', false)
->assertSee("if (pendingInput.checked) return '待定'", false)
->assertSee('data-sys-todo-image-prev', false)
->assertSee('data-sys-todo-image-next', false)
->assertDontSee('playlist_add', false)
->assertDontSee('<th scope="col">操作</th>', false)
->assertDontSeeText('下一页')
->assertSee('id="sysTodoImageViewer"', false);
$pageHtml = (string) $pageResponse->getContent();
$this->assertMatchesRegularExpression('/id="sysTodoDialog"[^>]*\shidden(?:\s|>)/s', $pageHtml);
$this->assertMatchesRegularExpression('/id="sysTodoGroupDialog"[^>]*\shidden(?:\s|>)/s', $pageHtml);
$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("已拉取账单,待核对订单\n周会跟进", $todo->progress);
$this->assertNull($todo->notes);
$this->assertSame(2, $todo->attachments()->count());
$this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))
->assertOk()
->assertSee('data-sys-todo-image-gallery', false)
->assertSee('data-sys-todo-grid-upload', false)
->assertSee('data-sys-todo-grid-cell', false)
->assertSee('data-sys-todo-grid-field="title"', false)
->assertSee('data-sys-todo-grid-field="progress"', false)
->assertDontSee('data-sys-todo-grid-field="notes"', 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("已完成核对并留档\n周会确认关闭", $todo->progress);
$this->assertNull($todo->notes);
$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('<div class="sys-todo-table__progress-notes-content">', false)
->assertSee(
'<span class="sys-todo-table__detail-text">' . e($progress . "\n" . $notes) . '</span>',
false,
)
->assertDontSee('<span class="sys-todo-table__detail-label">备注</span>', 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(
'/<td class="sys-todo-table__progress-notes">\s*<div class="sys-todo-table__progress-notes-content">.*?'
. preg_quote(e($progress), '/')
. '\s*' . 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(
'<span class="sys-todo-table__people-list">',
$html,
);
$this->assertMatchesRegularExpression(
'/<td class="sys-todo-table__people">\s*<span class="sys-todo-grid-value"[^>]*>\s*'
. '<span class="sys-todo-table__people-list">\s*'
. '<span class="sys-todo-table__person">晨曦<\/span>\s*'
. '<span class="sys-todo-table__person">人参<\/span>\s*'
. '<\/span>\s*<\/span>\s*<\/td>/s',
$html,
);
$this->assertMatchesRegularExpression(
'/<td class="sys-todo-table__people">\s*<span class="sys-todo-grid-value"[^>]*>\s*'
. '<span class="sys-todo-table__people-list">\s*'
. '<span class="sys-todo-table__person">人参<\/span>\s*'
. '<span class="sys-todo-table__person">秋香<\/span>\s*'
. '<span class="sys-todo-table__person">常仪<\/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',
],
],
])->assertOk()
->assertJson([
'ok' => true,
'updated_cells' => 7,
'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->assertNull($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_grid_inline_add_creates_type_content_and_todo_rows_without_a_form(): void
{
$this->loginAs();
$existing = $this->createTodo('收入', '平台收入核对', '原待办', '2026-11-30', 1);
$typeResponse = $this->postJson(route('business.systemization-todos.grid-create'), [
'mode' => 'type',
])->assertOk()->assertJsonPath('ok', true);
$typeRow = SystemizationTodo::query()->findOrFail((int) $typeResponse->json('id'));
$this->assertSame('新类型 1', $typeRow->category);
$this->assertSame('新内容 1', $typeRow->section);
$this->assertSame('新待办', $typeRow->title);
$contentResponse = $this->postJson(route('business.systemization-todos.grid-create'), [
'mode' => 'content',
'category' => '收入',
])->assertOk();
$contentRow = SystemizationTodo::query()->findOrFail((int) $contentResponse->json('id'));
$this->assertSame('收入', $contentRow->category);
$this->assertSame('新内容 1', $contentRow->section);
$todoResponse = $this->postJson(route('business.systemization-todos.grid-create'), [
'mode' => 'todo',
'category' => '收入',
'section' => '平台收入核对',
])->assertOk();
$todoRow = SystemizationTodo::query()->findOrFail((int) $todoResponse->json('id'));
$this->assertSame('收入', $todoRow->category);
$this->assertSame('平台收入核对', $todoRow->section);
$this->assertGreaterThan($existing->sort_order, $todoRow->sort_order);
}
public function test_grid_can_upload_and_delete_completion_screenshots_inline(): void
{
Storage::fake('uploads');
$this->loginAs();
$todo = $this->createTodo('收入', '截图测试', '直接上传截图', '2026-11-30', 1);
$this->post(route('business.systemization-todos.grid-upload', ['todo' => $todo->id]), [
'screenshots' => [
$this->pngUpload('inline-1.png'),
$this->pngUpload('inline-2.png'),
],
], ['Accept' => 'application/json'])->assertOk()
->assertJsonPath('attachment_count', 2);
$this->assertSame(2, $todo->attachments()->count());
$attachment = $todo->attachments()->firstOrFail();
Storage::disk('uploads')->assertExists($attachment->path);
$this->deleteJson(route('business.systemization-todos.grid-attachment-destroy', [
'todo' => $todo->id,
'attachment' => $attachment->id,
]))->assertOk()
->assertJsonPath('attachment_count', 1);
Storage::disk('uploads')->assertMissing($attachment->path);
$this->assertSame(1, $todo->attachments()->count());
}
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</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(
[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 . '</span>',
),
);
}
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')
->assertDontSeeText('共 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();
$this->assertSame('原备注', $todo->progress);
$this->assertNull($todo->notes);
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='),
);
}
}