fix: 统一系统化待办总负责人映射 #694

Merged
yueyang merged 1 commits from fix/yueyang-systemization-owner-mapping into main 2026-10-27 16:59:08 +08:00
3 changed files with 233 additions and 14 deletions

View File

@ -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))

View File

@ -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
</div>
</td>
@php($ownerNames = $personNames($item->owner))
@php($ownerNames = $personNames($item->owner_display ?? null))
<td class="sys-todo-table__people">
@if ($ownerNames === [])
—

View File

@ -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