修正系统化待办内容合并与三级序号 (#594)

This commit is contained in:
yueyang 2026-10-26 14:39:45 +08:00
parent 3ae7bf7dc8
commit 4089cdca93
3 changed files with 261 additions and 41 deletions

View File

@ -79,33 +79,45 @@ class SystemizationTodos extends Controller
$items = $query
->select($query->qualifyColumn('*'))
->selectRaw(
"ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__')
ORDER BY
CASE WHEN status = '已完成' THEN 1 ELSE 0 END,
CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END,
section,
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
due_date,
sort_order,
id
) AS category_sequence"
)
->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END")
->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END")
->orderBy('category')
->orderByRaw("CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END")
->orderBy('section')
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END')
->orderBy('due_date')
->orderBy('sort_order')
->orderBy('id')
->get();
if ($filters['sequence'] > 0) {
$sequenceMap = $this->hierarchySequenceMap();
$items->each(function (SystemizationTodo $todo) use ($sequenceMap): void {
$sequence = $sequenceMap[$todo->id] ?? [
'type_sequence' => 0,
'content_sequence' => 0,
'item_sequence' => 0,
'order' => PHP_INT_MAX,
'category_display' => '',
'section_display' => '',
];
$todo->setAttribute('type_sequence', $sequence['type_sequence']);
$todo->setAttribute('content_sequence', $sequence['content_sequence']);
$todo->setAttribute('item_sequence', $sequence['item_sequence']);
$todo->setAttribute('hierarchy_order', $sequence['order']);
$todo->setAttribute('category_display', $sequence['category_display']);
$todo->setAttribute('section_display', $sequence['section_display']);
});
$items = $items->sortBy('hierarchy_order')->values();
if ($filters['type_sequence'] > 0) {
$items = $items
->filter(static fn (SystemizationTodo $todo): bool =>
(int) $todo->category_sequence === $filters['sequence']
(int) $todo->type_sequence === $filters['type_sequence']
)
->values();
}
if ($filters['content_sequence'] > 0) {
$items = $items
->filter(static fn (SystemizationTodo $todo): bool =>
(int) $todo->content_sequence === $filters['content_sequence']
)
->values();
}
if ($filters['item_sequence'] > 0) {
$items = $items
->filter(static fn (SystemizationTodo $todo): bool =>
(int) $todo->item_sequence === $filters['item_sequence']
)
->values();
}
@ -172,6 +184,121 @@ class SystemizationTodos extends Controller
];
}
private function hierarchySequenceMap(): array
{
$todos = SystemizationTodo::query()
->orderBy('sort_order')
->orderBy('id')
->get(['id', 'category', 'section', 'sort_order']);
$rows = $todos->map(function (SystemizationTodo $todo): array {
$category = $this->hierarchyLabel($todo->category);
$section = $this->hierarchyLabel($todo->section);
return [
'category_key' => $category === '' ? "\0" : $category,
'category_label' => $category,
'section_key' => $section === '' ? "\0" : $section,
'section_label' => $section,
'sort_order' => (int) $todo->sort_order,
'id' => (int) $todo->id,
];
})->all();
$categoryOrder = [];
$categoryLabels = [];
$sectionOrder = [];
$sectionLabels = [];
$groupedRows = [];
foreach ($rows as $row) {
$categoryKey = $row['category_key'];
$sectionKey = $row['section_key'];
$categoryOrder[$categoryKey] = min(
$categoryOrder[$categoryKey] ?? PHP_INT_MAX,
$row['sort_order'],
);
$categoryLabels[$categoryKey] = $row['category_label'];
$sectionOrder[$categoryKey][$sectionKey] = min(
$sectionOrder[$categoryKey][$sectionKey] ?? PHP_INT_MAX,
$row['sort_order'],
);
$sectionLabels[$categoryKey][$sectionKey] = $row['section_label'];
$groupedRows[$categoryKey][$sectionKey][] = $row;
}
$categoryKeys = array_keys($categoryOrder);
usort($categoryKeys, function (string $left, string $right) use (
$categoryOrder,
$categoryLabels,
): int {
return $this->compareHierarchyGroups(
$left,
$right,
$categoryOrder,
$categoryLabels,
);
});
$sequenceMap = [];
$hierarchyOrder = 0;
foreach ($categoryKeys as $categoryIndex => $categoryKey) {
$contentKeys = array_keys($sectionOrder[$categoryKey]);
usort($contentKeys, function (string $left, string $right) use (
$categoryKey,
$sectionOrder,
$sectionLabels,
): int {
return $this->compareHierarchyGroups(
$left,
$right,
$sectionOrder[$categoryKey],
$sectionLabels[$categoryKey],
);
});
foreach ($contentKeys as $contentIndex => $contentKey) {
$contentRows = $groupedRows[$categoryKey][$contentKey];
usort($contentRows, static fn (array $left, array $right): int =>
[$left['sort_order'], $left['id']] <=> [$right['sort_order'], $right['id']]
);
foreach ($contentRows as $itemIndex => $row) {
$sequenceMap[$row['id']] = [
'type_sequence' => $categoryIndex + 1,
'content_sequence' => $contentIndex + 1,
'item_sequence' => $itemIndex + 1,
'order' => $hierarchyOrder++,
'category_display' => $row['category_label'],
'section_display' => $row['section_label'],
];
}
}
}
return $sequenceMap;
}
private function compareHierarchyGroups(
string $left,
string $right,
array $sortOrders,
array $labels,
): int {
$comparison = ($sortOrders[$left] ?? PHP_INT_MAX) <=> ($sortOrders[$right] ?? PHP_INT_MAX);
return $comparison !== 0
? $comparison
: strnatcasecmp($labels[$left] ?? '', $labels[$right] ?? '');
}
private function hierarchyLabel(?string $value): string
{
return trim((string) preg_replace(
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
'',
(string) $value,
));
}
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
@ -280,7 +407,9 @@ class SystemizationTodos extends Controller
'assignees' => $this->peopleFilter('assignee'),
'start_date' => $this->normalizeDateFilter(request('start_date')),
'due_date' => $this->normalizeDateFilter(request('due_date')),
'sequence' => max(0, (int) request('sequence', 0)),
'type_sequence' => max(0, (int) request('type_sequence', 0)),
'content_sequence' => max(0, (int) request('content_sequence', 0)),
'item_sequence' => max(0, (int) request('item_sequence', 0)),
'keyword' => trim((string) request('keyword', '')),
];
}

View File

@ -49,7 +49,11 @@
.sys-todo-table__index-cell { width:3.6rem; min-width:3.6rem; text-align:center; }
.sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; }
.sys-todo-table__group { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.7; vertical-align:middle; }
.sys-todo-table__group-content { display:inline-flex; align-items:flex-start; gap:.38rem; min-width:0; }
.sys-todo-table__group-text { min-width:0; }
.sys-todo-table__level { display:inline-flex; flex:0 0 auto; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; margin-top:.05rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); font-size:.68rem; font-weight:700; font-variant-numeric:tabular-nums; }
.sys-todo-table__type { position:sticky; left:0; z-index:3; width:5.4rem; min-width:5.4rem; text-align:center; white-space:nowrap; box-shadow:1px 0 0 #e5ddcf; }
.sys-todo-table__type .sys-todo-table__group-text { white-space:normal; }
.sys-todo-table__section { min-width:11rem; max-width:17rem; }
.sys-todo-table__title, .sys-todo-table__progress, .sys-todo-table__notes { line-height:1.45; white-space:pre-line; overflow-wrap:anywhere; }
.sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; }
@ -199,7 +203,9 @@
<option value="{{ $category }}" @selected($todoFilters['category'] === $category)>{{ $category }}</option>
@endforeach
</select></label>
<label>类型内序号<input type="number" name="sequence" min="1" step="1" value="{{ $todoFilters['sequence'] ?: '' }}" placeholder="如:5"></label>
<label>类型序号<input type="number" name="type_sequence" min="1" step="1" value="{{ $todoFilters['type_sequence'] ?: '' }}" placeholder="如:1"></label>
<label>内容序号<input type="number" name="content_sequence" min="1" step="1" value="{{ $todoFilters['content_sequence'] ?: '' }}" placeholder="如:2"></label>
<label>待办序号<input type="number" name="item_sequence" min="1" step="1" value="{{ $todoFilters['item_sequence'] ?: '' }}" placeholder="如:3"></label>
@php
$selectedOwners = $todoOwners->filter(
static fn (string $owner): bool => in_array($owner, $todoFilters['owners'], true)
@ -266,16 +272,16 @@
));
};
$sectionLabels = $items
->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->section))
->map(static fn ($groupItem): string => trim((string) $groupItem->section_display))
->all();
$titleLabels = $items
->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->title))
->all();
$groupValues = [
'category' => $items->map(static fn ($groupItem): string => trim((string) ($groupItem->category ?? '')))->all(),
'section' => $items->map(static fn ($groupItem, int $index): string => trim((string) ($groupItem->category ?? ''))
'category' => $items->map(static fn ($groupItem): string => (string) ((int) $groupItem->type_sequence))->all(),
'section' => $items->map(static fn ($groupItem): string => (int) $groupItem->type_sequence
. "\0"
. ($sectionLabels[$index] ?? ''))->all(),
. (int) $groupItem->content_sequence)->all(),
];
$groupRowspans = ['category' => [], 'section' => []];
foreach (['category', 'section'] as $groupField) {
@ -327,7 +333,7 @@
<table class="sys-todo-table" aria-label="财务系统化待办清单">
<thead>
<tr>
<th scope="col">类型</th><th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
<th scope="col">类型</th><th scope="col">内容</th><th scope="col">待办序号</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
<th scope="col">开始时间</th><th scope="col">完成时间</th><th scope="col">状态</th><th scope="col">完成情况</th><th scope="col">完成截图</th><th scope="col">备注</th>
</tr>
</thead>
@ -371,14 +377,22 @@
@endphp
<tr id="sys-todo-row-{{ $item->id }}" class="{{ $highlightId === (int) $item->id ? 'is-highlighted' : '' }}" tabindex="0" data-sys-todo-row-edit="{{ $todoPayloadEncoded }}" title="点击整行直接编辑">
@if ($categorySpan !== 0)
<td class="sys-todo-table__group sys-todo-table__type" @if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif>{{ $item->category ?: '—' }}</td>
<td class="sys-todo-table__group sys-todo-table__type" @if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif>
<span class="sys-todo-table__group-content">
<span class="sys-todo-table__level" title="类型序号">{{ (int) $item->type_sequence }}</span>
<span class="sys-todo-table__group-text">{{ $item->category_display ?: '未分类' }}</span>
</span>
</td>
@endif
@if ($sectionSpan !== 0)
<td class="sys-todo-table__group sys-todo-table__section" @if ($sectionSpan > 1) rowspan="{{ $sectionSpan }}" @endif>
<div class="sys-todo-table__clamp" title="{{ $sectionLabels[$loop->index] ?: '—' }}">{{ $sectionLabels[$loop->index] ?: '—' }}</div>
<span class="sys-todo-table__group-content">
<span class="sys-todo-table__level" title="内容序号">{{ (int) $item->content_sequence }}</span>
<span class="sys-todo-table__group-text sys-todo-table__clamp" title="{{ $sectionLabels[$loop->index] ?: '未填写内容' }}">{{ $sectionLabels[$loop->index] ?: '未填写内容' }}</span>
</span>
</td>
@endif
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前类型内序号">{{ (int) ($item->category_sequence ?? 0) }}</span></td>
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前内容内待办序号">{{ (int) $item->item_sequence }}</span></td>
<td class="sys-todo-table__title"><div class="sys-todo-table__clamp" title="{{ $titleLabels[$loop->index] }}">{{ $titleLabels[$loop->index] }}</div></td>
<td>{{ $item->owner ?: '—' }}</td>
<td>{{ $item->assignee ?: '—' }}</td>

View File

@ -28,7 +28,9 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSeeText('从 Excel 同步待办')
->assertSeeText('总负责人')
->assertSeeText('责任人')
->assertSeeText('类型内序号')
->assertSeeText('类型序号')
->assertSeeText('内容序号')
->assertSeeText('待办序号')
->assertSeeText('已逾期')
->assertSeeText('开始时间')
->assertSeeText('完成时间')
@ -189,10 +191,11 @@ class SystemizationTodosTest extends FeatureTestCase
$pageResponse
->assertOk()
->assertSee('rowspan="2"', false)
->assertSee('<th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th>', 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="当前类型内序号">2</span>', false)
->assertSee('title="类型序号">1</span>', false)
->assertSee('title="内容序号">1</span>', false)
->assertSee('title="当前内容内待办序号">2</span>', false)
->assertDontSee('1、平台收入核对')
->assertDontSee('3、退款核销')
->assertDontSee('1、收入事项 A')
@ -206,17 +209,25 @@ class SystemizationTodosTest extends FeatureTestCase
);
$pageItems = $pageContent['items'];
$this->assertSame(
[1, 2],
[1, 1],
$pageItems->where('category', '收入')
->pluck('category_sequence')
->pluck('type_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$this->assertSame(
[1],
[1, 2],
$pageItems->where('category', '收入')
->pluck('item_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$this->assertSame(
[2],
$pageItems->where('category', '成本')
->pluck('category_sequence')
->pluck('type_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
@ -226,11 +237,18 @@ class SystemizationTodosTest extends FeatureTestCase
'section' => 'other',
'page' => 'systemization-todos',
'category' => '收入',
'sequence' => 2,
'item_sequence' => 2,
]))->assertOk();
$sequenceContent = $sequenceResponse->viewData('content');
$this->assertSame(['2、收入事项 B'], $sequenceContent['items']->pluck('title')->all());
$typeSequenceContent = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'type_sequence' => 2,
]))->assertOk()->viewData('content');
$this->assertSame(['3、成本事项'], $typeSequenceContent['items']->pluck('title')->all());
$highlightedTodo = $pageItems->firstWhere('title', '2、收入事项 B');
$this->get(route('business.pages.show', [
'section' => 'other',
@ -285,6 +303,65 @@ class SystemizationTodosTest extends FeatureTestCase
);
}
public function test_same_content_stays_merged_and_receives_scoped_hierarchy_sequences(): void
{
$this->loginAs();
$pending = $this->createTodo('收入', '钉钉流程', '待办事项 A', '2026-10-30', 1);
$completed = $this->createTodo('收入', '钉钉流程', '待办事项 B', '2026-10-10', 2);
$completed->update([
'status' => SystemizationTodo::STATUS_COMPLETED,
'completed_at' => '2026-10-10',
]);
$inProgress = $this->createTodo('收入', '钉钉流程', '待办事项 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(),
);
$contentResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'type_sequence' => 1,
'content_sequence' => 2,
]))->assertOk();
$this->assertSame(
[$secondContent->id],
$contentResponse->viewData('content')['items']->pluck('id')->all(),
);
$this->assertSame(
[$completed->id, $inProgress->id],
$items
->where('section', '钉钉流程')
->whereIn('status', [SystemizationTodo::STATUS_COMPLETED, SystemizationTodo::STATUS_IN_PROGRESS])
->pluck('id')
->all(),
);
$this->assertSame(1, substr_count((string) $response->getContent(), '>钉钉流程<'));
}
public function test_page_renders_all_todos_on_one_continuous_list(): void
{
$this->loginAs();