diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index b486001c..31356fc8 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -28,6 +28,22 @@ class SystemizationTodos extends Controller 'pending' => '时间待定', 'none' => '无提醒', ]; + private const OWNER_OPTIONS = ['人参', '晨曦', '常仪', '待定']; + private const OWNER_ALIASES = [ + '人参' => '人参', + '苏木' => '人参', + '李斯' => '人参', + '周生辰' => '人参', + '晨曦' => '晨曦', + '栎阳' => '晨曦', + '秋香' => '晨曦', + '常仪' => '常仪', + '玉兔' => '常仪', + '山时' => '常仪', + '林冲' => '常仪', + '杳杳' => '常仪', + '待定' => '待定', + ]; public function __construct( private readonly SystemizationTodoAuditLogger $audit, @@ -74,11 +90,7 @@ class SystemizationTodos extends Controller if ($filters['owners'] !== []) { $query->where(function ($builder) use ($filters): void { foreach ($filters['owners'] as $owner) { - $builder->orWhere( - 'owner', - 'like', - '%' . addcslashes($owner, '%_\\') . '%', - ); + $this->applyOwnerFilter($builder, $owner); } }); } @@ -138,6 +150,7 @@ class SystemizationTodos extends Controller $todo->setAttribute('type_count', $sequence['type_count']); $todo->setAttribute('content_count', $sequence['content_count']); $todo->setAttribute('status_label', $todo->statusLabel()); + $todo->setAttribute('owner_display', implode('、', $this->ownerNames($todo->owner))); $reminder = $todo->reminder(); $todo->setAttribute('reminder_label', $reminder['label']); $todo->setAttribute('reminder_tone', $reminder['tone']); @@ -178,7 +191,7 @@ class SystemizationTodos extends Controller 'todoFilters' => $filters, 'todoCategories' => $categories, 'todoContentOptions' => $this->contentOptions(), - 'todoOwners' => $this->peopleOptions('owner'), + 'todoOwners' => collect(self::OWNER_OPTIONS), 'todoAssignees' => $this->peopleOptions('assignee'), 'todoStatuses' => SystemizationTodo::STATUSES, 'todoStatusOptions' => $statusOptions, @@ -376,6 +389,7 @@ class SystemizationTodos extends Controller { $data = $this->validated($request); $this->normalizePendingDates($request, $data); + $this->normalizeOwnerData($data); $position = max(1, (int) ($data['position'] ?? 1)); unset($data['position']); $data['company_id'] = (int) company_id(); @@ -580,6 +594,7 @@ class SystemizationTodos extends Controller $before = $this->audit->snapshot($todo, true); $data = $this->validated($request); $this->normalizePendingDates($request, $data); + $this->normalizeOwnerData($data); $targetPosition = max(0, (int) ($data['position'] ?? 0)); unset($data['position']); $data['updated_by'] = user_id(); @@ -765,7 +780,7 @@ class SystemizationTodos extends Controller 'status' => $this->normalizeStatusValue(request('status')), 'reminder' => $this->normalizeReminderValue(request('reminder')), 'category' => trim((string) request('category', '')), - 'owners' => $this->peopleFilter('owner'), + 'owners' => $this->normalizeOwnerFilterValues($this->peopleFilter('owner')), 'assignees' => $this->peopleFilter('assignee'), 'start_date' => $this->normalizeDateValue(request('start_date')), 'due_date' => $this->normalizeDateValue(request('due_date')), @@ -821,6 +836,80 @@ class SystemizationTodos extends Controller ->values(); } + private function normalizeOwnerFilterValues(array $owners): array + { + return collect($owners) + ->map(static fn (string $owner): ?string => self::OWNER_ALIASES[$owner] ?? null) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function applyOwnerFilter($builder, string $owner): void + { + $aliases = array_keys(array_filter( + self::OWNER_ALIASES, + static fn (string $canonical): bool => $canonical === $owner, + )); + + if ($aliases !== []) { + $builder->orWhere(function ($aliasQuery) use ($aliases): void { + foreach ($aliases as $alias) { + $aliasQuery->orWhere( + 'owner', + 'like', + '%' . addcslashes($alias, '%_\\') . '%', + ); + } + }); + } + + if ($owner !== '待定') { + return; + } + + $knownAliases = array_values(array_diff(array_keys(self::OWNER_ALIASES), ['待定'])); + $builder->orWhere(function ($pendingQuery) use ($knownAliases): void { + $pendingQuery->whereNull('owner')->orWhere('owner', ''); + if ($knownAliases === []) { + return; + } + + $pendingQuery->orWhere(function ($unknownQuery) use ($knownAliases): void { + foreach ($knownAliases as $alias) { + $unknownQuery->where( + 'owner', + 'not like', + '%' . addcslashes($alias, '%_\\') . '%', + ); + } + }); + }); + } + + private function ownerNames(?string $value): array + { + $names = collect(preg_split('/[、,,;;\/]+/u', trim((string) $value)) ?: []) + ->map(static fn (string $name): string => trim($name)) + ->filter(static fn (string $name): bool => $name !== '' && $name !== '—') + ->map(static fn (string $name): string => self::OWNER_ALIASES[$name] ?? '待定') + ->unique() + ->values() + ->all(); + + return $names === [] ? ['待定'] : $names; + } + + private function normalizeOwnerData(array &$data): void + { + if (! array_key_exists('owner', $data)) { + return; + } + + $data['owner'] = implode('、', $this->ownerNames($data['owner'])); + } + private function contentOptions(): array { $options = []; @@ -979,6 +1068,7 @@ class SystemizationTodos extends Controller ->take(50) ->values() ->all(); + $owners = $this->normalizeOwnerFilterValues($owners); $assignees = collect((array) ($filters['assignees'] ?? [])) ->filter(static fn (mixed $value): bool => is_string($value)) ->map(static fn (string $value): string => trim($value)) diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index f21d55a3..7caf45be 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -498,7 +498,7 @@ 'category' => $item->category ?? '', 'section' => $sectionLabels[$loop->index] ?? '', 'title' => $titleLabels[$loop->index] ?? '', - 'owner' => $item->owner ?? '', + 'owner' => $item->owner_display ?? '', 'assignee' => $item->assignee ?? '', 'start_date' => $item->start_date?->format('Y-m-d') ?? '', 'start_note' => $item->start_note ?? '', @@ -599,7 +599,7 @@ @endif - @php($ownerNames = $personNames($item->owner)) + @php($ownerNames = $personNames($item->owner_display ?? null)) @if ($ownerNames === []) — diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index ea692c5e..d82edefd 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -277,11 +277,140 @@ class SystemizationTodosTest extends FeatureTestCase 'page' => 'systemization-todos', ]))->assertOk()->viewData('content'); - foreach (['todoOwners', 'todoAssignees'] as $key) { - $this->assertContains('人参', $content[$key]); - $this->assertContains('周生辰', $content[$key]); - $this->assertNotContains('人参 / 周生辰', $content[$key]); - } + $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_can_insert_a_todo_at_a_selected_sequence_and_shift_existing_items(): void