修正系统化待办内容合并与三级序号 (#594)
This commit is contained in:
parent
3ae7bf7dc8
commit
4089cdca93
@ -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', '')),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user