feat: 增加系统化待办类 Excel 表格编辑
This commit is contained in:
parent
3eba7507cb
commit
df46217685
@ -43,6 +43,18 @@ class SystemizationTodos extends Controller
|
||||
'status',
|
||||
'progress_notes',
|
||||
];
|
||||
private const GRID_EDITABLE_FIELDS = [
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'due_date',
|
||||
'status',
|
||||
'progress',
|
||||
'notes',
|
||||
];
|
||||
private const OWNER_ALIASES = [
|
||||
'人参' => '人参',
|
||||
'苏木' => '人参',
|
||||
@ -242,6 +254,9 @@ class SystemizationTodos extends Controller
|
||||
'history' => route('business.systemization-todos.history'),
|
||||
'historyExport' => route('business.systemization-todos.history.export'),
|
||||
'columnWidths' => route('business.systemization-todos.column-widths'),
|
||||
'gridCells' => route('business.systemization-todos.grid-cells'),
|
||||
'gridMove' => route('business.systemization-todos.grid-move', ['todo' => '__TODO__']),
|
||||
'gridInsert' => route('business.systemization-todos.grid-insert', ['todo' => '__TODO__']),
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -730,6 +745,279 @@ class SystemizationTodos extends Controller
|
||||
return $this->backToPage('待办事项已删除。');
|
||||
}
|
||||
|
||||
public function batchUpdateCells(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'changes' => ['required', 'array', 'min:1', 'max:1000'],
|
||||
'changes.*.id' => ['required', 'integer'],
|
||||
'changes.*.field' => ['required', Rule::in(self::GRID_EDITABLE_FIELDS)],
|
||||
'changes.*.value' => ['present', 'nullable', 'string', 'max:10000'],
|
||||
'changes.*.original' => ['nullable', 'string', 'max:10000'],
|
||||
'changes.*.scope' => ['nullable', Rule::in(['row', 'group'])],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(function () use ($data): array {
|
||||
$todos = SystemizationTodo::query()
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy(static fn (SystemizationTodo $todo): int => (int) $todo->id);
|
||||
$affectedIds = [];
|
||||
$changes = [];
|
||||
foreach (array_values($data['changes']) as $change) {
|
||||
$todo = $todos->get((int) $change['id']);
|
||||
if (! $todo instanceof SystemizationTodo) {
|
||||
return ['ok' => false, 'status' => 404, 'message' => '待办不存在。'];
|
||||
}
|
||||
|
||||
$field = (string) $change['field'];
|
||||
$scope = (string) ($change['scope'] ?? 'row');
|
||||
if (! $this->gridFieldSupportsGroupScope($field)) {
|
||||
$scope = 'row';
|
||||
}
|
||||
$original = $change['original'] ?? null;
|
||||
if ($original !== null && ! $this->gridOriginalStillMatches($todo, $field, (string) $original)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 409,
|
||||
'message' => '待办已被其他人修改,请刷新后重试。',
|
||||
];
|
||||
}
|
||||
|
||||
$targetIds = [(int) $todo->id];
|
||||
if ($scope === 'group') {
|
||||
$targetIds = $this->gridGroupIds($todos, $todo, $field);
|
||||
}
|
||||
foreach ($targetIds as $targetId) {
|
||||
$affectedIds[$targetId] = true;
|
||||
}
|
||||
$changes[] = [
|
||||
'field' => $field,
|
||||
'value' => $change['value'],
|
||||
'scope' => $scope,
|
||||
'todo' => $todo,
|
||||
];
|
||||
}
|
||||
|
||||
$before = [];
|
||||
foreach (array_keys($affectedIds) as $todoId) {
|
||||
$before[$todoId] = $this->audit->snapshot($todos->get($todoId));
|
||||
}
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$targets = $change['scope'] === 'group'
|
||||
? $this->gridGroupIds($todos, $change['todo'], $change['field'])
|
||||
: [(int) $change['todo']->id];
|
||||
foreach ($targets as $targetId) {
|
||||
$todo = $todos->get($targetId);
|
||||
if (! $todo instanceof SystemizationTodo) {
|
||||
continue;
|
||||
}
|
||||
$error = $this->applyGridCellValue($todo, $change['field'], $change['value']);
|
||||
if ($error !== null) {
|
||||
return ['ok' => false, 'status' => 422, 'message' => $error];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$changedTodos = [];
|
||||
foreach (array_keys($affectedIds) as $todoId) {
|
||||
$todo = $todos->get($todoId);
|
||||
if (! $todo instanceof SystemizationTodo) {
|
||||
continue;
|
||||
}
|
||||
$error = $this->normalizeGridTodo($todo, $todoId);
|
||||
if ($error !== null) {
|
||||
return ['ok' => false, 'status' => 422, 'message' => $error];
|
||||
}
|
||||
if (! $todo->isDirty()) {
|
||||
continue;
|
||||
}
|
||||
$changedTodos[$todoId] = $todo;
|
||||
}
|
||||
|
||||
$events = [];
|
||||
foreach ($changedTodos as $todoId => $todo) {
|
||||
$todo->updated_by = user_id();
|
||||
$todo->save();
|
||||
$events[] = [
|
||||
'company_id' => (int) company_id(),
|
||||
'todo_id' => (int) $todo->id,
|
||||
'action' => 'update',
|
||||
'before' => $before[$todoId],
|
||||
'after' => $this->audit->snapshot($todo),
|
||||
'user_id' => user_id(),
|
||||
'meta' => ['source' => 'grid'],
|
||||
];
|
||||
}
|
||||
$this->audit->recordMany($events);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'updated_cells' => count($changes),
|
||||
'updated_rows' => count($changedTodos),
|
||||
'audit_events' => count($events),
|
||||
];
|
||||
});
|
||||
|
||||
if (($result['ok'] ?? false) !== true) {
|
||||
return response()->json(
|
||||
['message' => (string) ($result['message'] ?? '保存失败。')],
|
||||
(int) ($result['status'] ?? 422),
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function moveGridRow(Request $request, SystemizationTodo $todo): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'direction' => ['required', Rule::in(['up', 'down'])],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(function () use ($todo, $data): array {
|
||||
$lockedTodo = SystemizationTodo::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($todo->id);
|
||||
$siblings = $this->contentSiblings($lockedTodo->category, $lockedTodo->section);
|
||||
$currentIndex = $siblings->search(
|
||||
static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $lockedTodo->id
|
||||
);
|
||||
if ($currentIndex === false) {
|
||||
return ['ok' => false, 'status' => 404, 'message' => '待办不存在于当前内容中。'];
|
||||
}
|
||||
$targetIndex = $data['direction'] === 'up' ? $currentIndex - 1 : $currentIndex + 1;
|
||||
if ($targetIndex < 0) {
|
||||
return ['ok' => false, 'status' => 422, 'message' => '当前待办已经是首行。'];
|
||||
}
|
||||
if ($targetIndex >= $siblings->count()) {
|
||||
return ['ok' => false, 'status' => 422, 'message' => '当前待办已经是末行。'];
|
||||
}
|
||||
|
||||
/** @var SystemizationTodo $target */
|
||||
$target = $siblings->get($targetIndex);
|
||||
$beforeTodo = $this->audit->snapshot($lockedTodo);
|
||||
$beforeTarget = $this->audit->snapshot($target);
|
||||
$todoOrder = (int) $lockedTodo->sort_order;
|
||||
$targetOrder = (int) $target->sort_order;
|
||||
|
||||
if ($todoOrder === $targetOrder) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 409,
|
||||
'message' => '当前内容存在重复排序位置,请先通过编辑窗口调整顺序。',
|
||||
];
|
||||
}
|
||||
|
||||
$lockedTodo->sort_order = $targetOrder;
|
||||
$target->sort_order = $todoOrder;
|
||||
$lockedTodo->updated_by = user_id();
|
||||
$target->updated_by = user_id();
|
||||
$lockedTodo->save();
|
||||
$target->save();
|
||||
$this->audit->recordMany([
|
||||
[
|
||||
'company_id' => (int) company_id(),
|
||||
'todo_id' => (int) $lockedTodo->id,
|
||||
'action' => 'update',
|
||||
'before' => $beforeTodo,
|
||||
'after' => $this->audit->snapshot($lockedTodo),
|
||||
'user_id' => user_id(),
|
||||
'meta' => ['source' => 'grid', 'operation' => 'move_' . $data['direction']],
|
||||
],
|
||||
[
|
||||
'company_id' => (int) company_id(),
|
||||
'todo_id' => (int) $target->id,
|
||||
'action' => 'update',
|
||||
'before' => $beforeTarget,
|
||||
'after' => $this->audit->snapshot($target),
|
||||
'user_id' => user_id(),
|
||||
'meta' => ['source' => 'grid', 'operation' => 'move_' . $data['direction']],
|
||||
],
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'direction' => $data['direction']];
|
||||
});
|
||||
|
||||
if (($result['ok'] ?? false) !== true) {
|
||||
return response()->json(
|
||||
['message' => (string) ($result['message'] ?? '移动失败。')],
|
||||
(int) ($result['status'] ?? 422),
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function insertGridRowAfter(SystemizationTodo $todo): JsonResponse
|
||||
{
|
||||
$createdId = DB::transaction(function () use ($todo): int {
|
||||
$lockedTodo = SystemizationTodo::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($todo->id);
|
||||
$siblings = $this->contentSiblings($lockedTodo->category, $lockedTodo->section);
|
||||
$currentIndex = $siblings->search(
|
||||
static fn (SystemizationTodo $item): bool => (int) $item->id === (int) $lockedTodo->id
|
||||
);
|
||||
if ($currentIndex === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$next = $siblings->get($currentIndex + 1);
|
||||
$insertOrder = $next instanceof SystemizationTodo
|
||||
? (int) $next->sort_order
|
||||
: (int) $siblings->max('sort_order') + 1;
|
||||
if ($next instanceof SystemizationTodo && $insertOrder <= (int) $lockedTodo->sort_order) {
|
||||
$insertOrder = (int) $lockedTodo->sort_order + 1;
|
||||
}
|
||||
SystemizationTodo::query()
|
||||
->where('sort_order', '>=', $insertOrder)
|
||||
->increment('sort_order');
|
||||
|
||||
$created = SystemizationTodo::query()->create([
|
||||
'company_id' => (int) company_id(),
|
||||
'category' => $lockedTodo->category,
|
||||
'section' => $lockedTodo->section,
|
||||
'title' => '新待办',
|
||||
'owner' => '待定',
|
||||
'assignee' => null,
|
||||
'start_date' => null,
|
||||
'start_note' => null,
|
||||
'due_date' => null,
|
||||
'due_note' => null,
|
||||
'status' => SystemizationTodo::STATUS_INCOMPLETE,
|
||||
'completed_at' => null,
|
||||
'progress' => null,
|
||||
'notes' => null,
|
||||
'sort_order' => $insertOrder,
|
||||
'created_by' => user_id(),
|
||||
'updated_by' => user_id(),
|
||||
]);
|
||||
$this->audit->record(
|
||||
'create',
|
||||
(int) company_id(),
|
||||
(int) $created->id,
|
||||
null,
|
||||
$this->audit->snapshot($created),
|
||||
user_id(),
|
||||
null,
|
||||
['source' => 'grid', 'operation' => 'insert_after'],
|
||||
);
|
||||
|
||||
return (int) $created->id;
|
||||
});
|
||||
|
||||
if ($createdId <= 0) {
|
||||
return response()->json(['message' => '无法在当前内容中插入新行。'], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'id' => $createdId,
|
||||
'message' => '已插入空白待办行。',
|
||||
]);
|
||||
}
|
||||
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$from = $this->historyStartAt();
|
||||
@ -1068,6 +1356,161 @@ class SystemizationTodos extends Controller
|
||||
: '';
|
||||
}
|
||||
|
||||
private function gridFieldSupportsGroupScope(string $field): bool
|
||||
{
|
||||
return in_array($field, ['category', 'section'], true);
|
||||
}
|
||||
|
||||
private function gridOriginalStillMatches(
|
||||
SystemizationTodo $todo,
|
||||
string $field,
|
||||
string $original,
|
||||
): bool {
|
||||
$current = match ($field) {
|
||||
'category', 'section', 'title' => $this->hierarchyLabel((string) ($todo->{$field} ?? '')),
|
||||
'owner' => implode('、', $this->ownerNames($todo->owner)),
|
||||
'start_date' => $todo->start_date?->format('Y-m-d') ?? (string) ($todo->start_note ?? ''),
|
||||
'due_date' => $todo->due_date?->format('Y-m-d') ?? (string) ($todo->due_note ?? ''),
|
||||
'status' => $todo->statusLabel(),
|
||||
default => (string) ($todo->{$field} ?? ''),
|
||||
};
|
||||
$expected = in_array($field, ['category', 'section', 'title'], true)
|
||||
? $this->hierarchyLabel($original)
|
||||
: $original;
|
||||
|
||||
return $current === $expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, SystemizationTodo> $todos
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function gridGroupIds(
|
||||
Collection $todos,
|
||||
SystemizationTodo $todo,
|
||||
string $field,
|
||||
): array {
|
||||
$categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category));
|
||||
$sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section));
|
||||
|
||||
return $todos
|
||||
->filter(function (SystemizationTodo $candidate) use (
|
||||
$field,
|
||||
$categoryKey,
|
||||
$sectionKey,
|
||||
): bool {
|
||||
$sameCategory = $this->hierarchyGroupKey(
|
||||
$this->hierarchyLabel($candidate->category)
|
||||
) === $categoryKey;
|
||||
|
||||
return $sameCategory
|
||||
&& ($field === 'category'
|
||||
|| $this->hierarchyGroupKey(
|
||||
$this->hierarchyLabel($candidate->section)
|
||||
) === $sectionKey);
|
||||
})
|
||||
->keys()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applyGridCellValue(
|
||||
SystemizationTodo $todo,
|
||||
string $field,
|
||||
?string $value,
|
||||
): ?string {
|
||||
$value = str_replace(["\r\n", "\r"], "\n", (string) ($value ?? ''));
|
||||
if (in_array($field, ['category', 'section', 'title', 'owner', 'assignee'], true)) {
|
||||
$value = trim($value);
|
||||
}
|
||||
|
||||
if (in_array($field, ['start_date', 'due_date'], true)) {
|
||||
$dateField = $field;
|
||||
$noteField = $field === 'start_date' ? 'start_note' : 'due_note';
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
$todo->{$dateField} = null;
|
||||
$todo->{$noteField} = null;
|
||||
return null;
|
||||
}
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
if ($date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value) {
|
||||
$todo->{$dateField} = $value;
|
||||
$todo->{$noteField} = null;
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $value) === 1) {
|
||||
return $field === 'start_date' ? '开始时间日期格式无效。' : '完成时间日期格式无效。';
|
||||
}
|
||||
$todo->{$dateField} = null;
|
||||
$todo->{$noteField} = $value;
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($field === 'status') {
|
||||
$status = trim($value);
|
||||
if (in_array($status, SystemizationTodo::LEGACY_INCOMPLETE_STATUSES, true)) {
|
||||
$status = SystemizationTodo::STATUS_INCOMPLETE;
|
||||
}
|
||||
if (! in_array($status, SystemizationTodo::STATUSES, true)) {
|
||||
return '状态只能填写“未完成”或“已完成”。';
|
||||
}
|
||||
$todo->status = $status;
|
||||
return null;
|
||||
}
|
||||
|
||||
$todo->{$field} = $value === '' ? null : $value;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeGridTodo(SystemizationTodo $todo, int $todoId): ?string
|
||||
{
|
||||
$title = trim((string) ($todo->title ?? ''));
|
||||
if ($title === '') {
|
||||
return '待办事项不能为空。';
|
||||
}
|
||||
$limits = [
|
||||
'category' => 100,
|
||||
'section' => 255,
|
||||
'title' => 5000,
|
||||
'owner' => 255,
|
||||
'assignee' => 255,
|
||||
'progress' => 10000,
|
||||
'notes' => 10000,
|
||||
'start_note' => 50,
|
||||
'due_note' => 50,
|
||||
];
|
||||
foreach ($limits as $field => $limit) {
|
||||
if (mb_strlen((string) ($todo->{$field} ?? '')) > $limit) {
|
||||
return '第 ' . $todoId . ' 条待办的字段内容过长。';
|
||||
}
|
||||
}
|
||||
|
||||
$todo->title = $title;
|
||||
$todo->owner = implode('、', $this->ownerNames($todo->owner));
|
||||
$assignees = $this->personNames($todo->assignee);
|
||||
$todo->assignee = $assignees === [] ? null : implode('、', $assignees);
|
||||
|
||||
$startDate = $todo->start_date?->format('Y-m-d');
|
||||
$dueDate = $todo->due_date?->format('Y-m-d');
|
||||
if ($startDate !== null && $dueDate !== null && $dueDate < $startDate) {
|
||||
return '第 ' . $todoId . ' 条待办的完成时间不能早于开始时间。';
|
||||
}
|
||||
|
||||
if ($todo->statusLabel() === SystemizationTodo::STATUS_COMPLETED) {
|
||||
$todo->status = SystemizationTodo::STATUS_COMPLETED;
|
||||
$todo->completed_at = $todo->completed_at?->format('Y-m-d')
|
||||
?: ($dueDate ?: now()->toDateString());
|
||||
} else {
|
||||
$todo->status = SystemizationTodo::STATUS_INCOMPLETE;
|
||||
$todo->completed_at = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -795,6 +795,14 @@ Route::prefix('workspace/other/systemization-todos')
|
||||
->name('column-widths');
|
||||
Route::post('/', [SystemizationTodos::class, 'store'])->name('store');
|
||||
Route::get('history', [SystemizationTodos::class, 'history'])->name('history');
|
||||
Route::patch('grid/cells', [SystemizationTodos::class, 'batchUpdateCells'])
|
||||
->name('grid-cells');
|
||||
Route::patch('grid/{todo}/move', [SystemizationTodos::class, 'moveGridRow'])
|
||||
->whereNumber('todo')
|
||||
->name('grid-move');
|
||||
Route::post('grid/{todo}/insert-after', [SystemizationTodos::class, 'insertGridRowAfter'])
|
||||
->whereNumber('todo')
|
||||
->name('grid-insert');
|
||||
Route::patch('{todo}/category', [SystemizationTodos::class, 'updateCategory'])
|
||||
->whereNumber('todo')
|
||||
->name('category');
|
||||
|
||||
@ -91,6 +91,9 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
->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)
|
||||
->assertSeeText('单击单元格直接编辑')
|
||||
->assertSeeText('将 Excel 内容粘贴进来')
|
||||
->assertSee('data-sys-todo-image-prev', false)
|
||||
->assertSee('data-sys-todo-image-next', false)
|
||||
->assertDontSee('playlist_add', false)
|
||||
@ -141,6 +144,13 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
->assertOk()
|
||||
->assertSee('data-sys-todo-row-edit', false)
|
||||
->assertSee('data-sys-todo-image-gallery', false)
|
||||
->assertSee('data-sys-todo-grid-cell', false)
|
||||
->assertSee('data-sys-todo-grid-field="title"', false)
|
||||
->assertSee('data-sys-todo-grid-field="progress"', 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();
|
||||
@ -327,18 +337,20 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
$html,
|
||||
);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<td class="sys-todo-table__people">\s*<span class="sys-todo-table__people-list">\s*'
|
||||
'/<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*<\/td>/s',
|
||||
. '<\/span>\s*<\/span>\s*<\/td>/s',
|
||||
$html,
|
||||
);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<td class="sys-todo-table__people">\s*<span class="sys-todo-table__people-list">\s*'
|
||||
'/<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*<\/td>/s',
|
||||
. '<\/span>\s*<\/span>\s*<\/td>/s',
|
||||
$html,
|
||||
);
|
||||
}
|
||||
@ -560,7 +572,7 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
'section' => 'other',
|
||||
'page' => 'systemization-todos',
|
||||
]))->assertOk()
|
||||
->assertSee('title="在第 3 条后新增一条"', false)
|
||||
->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',
|
||||
@ -709,6 +721,226 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
$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',
|
||||
],
|
||||
[
|
||||
'id' => $first->id,
|
||||
'field' => 'notes',
|
||||
'value' => '周会复核',
|
||||
'original' => '',
|
||||
'scope' => 'row',
|
||||
],
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJson([
|
||||
'ok' => true,
|
||||
'updated_cells' => 8,
|
||||
'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->assertSame('周会复核', $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_excel_import_preserves_hierarchy_and_does_not_overwrite_existing_rows(): void
|
||||
{
|
||||
$path = sys_get_temp_dir() . '/systemization-todos-' . uniqid() . '.xlsx';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user