diff --git a/app/Business/SystemizationTodoWorkbookImporter.php b/app/Business/SystemizationTodoWorkbookImporter.php index 9442319d..e6c2b611 100644 --- a/app/Business/SystemizationTodoWorkbookImporter.php +++ b/app/Business/SystemizationTodoWorkbookImporter.php @@ -48,81 +48,30 @@ class SystemizationTodoWorkbookImporter $header = $headerInfo['columns']; $headerRow = $headerInfo['row']; - $created = 0; - $skipped = 0; - DB::transaction(function () use ( + $rows = $this->workbookRows( $reader, $sheetIndex, $sheetName, $header, $headerRow, $companyId, - $userId, - &$created, - &$skipped - ): void { - $category = ''; - $section = ''; - foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) { - if ($rowNumber <= $headerRow) { - continue; - } - $categoryValue = $this->cell($cells, $header['category'] ?? null); - if ($categoryValue !== '') { - $category = $categoryValue; - } - $sectionValue = $this->cell($cells, $header['section'] ?? null); - if ($sectionValue !== '') { - $section = $sectionValue; - } - - $title = $this->cell($cells, $header['title'] ?? null); - if ($title === '') { - continue; - } - - $owner = $this->cell($cells, $header['owner'] ?? null); - $assignee = $this->cell($cells, $header['assignee'] ?? null); - $sourceKey = hash( - 'sha256', - implode('|', [$companyId, $sheetName, $rowNumber, $category, $section, $title, $owner, $assignee]) - ); + ); + $created = 0; + $skipped = 0; + DB::transaction(function () use ($rows, $companyId, $userId, &$created, &$skipped): void { + foreach ($rows as $row) { $exists = SystemizationTodo::withoutGlobalScopes() ->where('company_id', $companyId) - ->where('source_key', $sourceKey) + ->where('source_key', $row['data']['source_key']) ->exists(); if ($exists) { $skipped++; continue; } - $startText = $this->cell($cells, $header['start'] ?? null); - $finishText = $this->cell($cells, $header['finish'] ?? null); - [$startDate, $startNote] = $this->dateParts($startText); - [$finishDate, $finishNote] = $this->dateParts($finishText); - $progress = $this->cell($cells, $header['progress'] ?? null); - $status = $this->status($progress, $startText, $finishText); - $completedAt = $status === SystemizationTodo::STATUS_COMPLETED - ? ($finishDate ?? now()->toDateString()) - : null; - SystemizationTodo::withoutGlobalScopes()->create([ + ...$row['data'], 'company_id' => $companyId, - 'category' => $category !== '' ? $category : null, - 'section' => $section !== '' ? $section : null, - 'title' => $title, - 'owner' => $owner !== '' ? $owner : null, - 'assignee' => $assignee !== '' ? $assignee : null, - 'start_date' => $startDate, - 'start_note' => $startNote !== '' ? $startNote : null, - 'due_date' => $finishDate, - 'due_note' => $finishNote !== '' ? $finishNote : null, - 'status' => $status, - 'completed_at' => $completedAt, - 'progress' => $progress !== '' ? $progress : null, - 'notes' => ($notes = $this->cell($cells, $header['notes'] ?? null)) !== '' ? $notes : null, - 'sort_order' => $rowNumber, - 'source_key' => $sourceKey, 'imported_at' => now(), 'created_by' => $userId, 'updated_by' => $userId, @@ -181,7 +130,17 @@ class SystemizationTodoWorkbookImporter $matchedIds = array_values($matches); $retained = $existing ->filter(static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $matchedIds, true)) + ->sortBy(static fn (SystemizationTodo $todo): string => + sprintf('%010d-%010d', (int) $todo->sort_order, (int) $todo->id) + ) ->values(); + $retainedSortOrder = max( + (int) $existing->max('sort_order'), + ...array_map( + static fn (array $row): int => (int) $row['data']['sort_order'], + $rows, + ), + ) + 1; $oldSourceKeys = $existing->pluck('source_key', 'id')->all(); $updated = 0; foreach ($matches as $rowIndex => $todoId) { @@ -209,6 +168,7 @@ class SystemizationTodoWorkbookImporter $existing, $retained, $oldSourceKeys, + $retainedSortOrder, $companyId, $userId, &$created, @@ -262,11 +222,12 @@ class SystemizationTodoWorkbookImporter } foreach ($retained as $todo) { - if (($oldSourceKeys[$todo->id] ?? null) !== null) { - SystemizationTodo::withoutGlobalScopes() - ->whereKey($todo->id) - ->update(['source_key' => $oldSourceKeys[$todo->id]]); - } + SystemizationTodo::withoutGlobalScopes() + ->whereKey($todo->id) + ->update([ + 'sort_order' => $retainedSortOrder++, + 'source_key' => $oldSourceKeys[$todo->id] ?? null, + ]); } }); @@ -285,7 +246,16 @@ class SystemizationTodoWorkbookImporter /** * @param array $columns - * @return array}> + * @return array + * }> */ private function workbookRows( PayablesWorkbookStreamReader $reader, @@ -311,10 +281,12 @@ class SystemizationTodoWorkbookImporter $section = $sectionValue; } - $title = $this->cell($cells, $columns['title'] ?? null); - if ($title === '') { + $sourceTitle = $this->cell($cells, $columns['title'] ?? null); + if ($sourceTitle === '') { continue; } + $titles = $this->titleSegments($sourceTitle); + $titleCount = count($titles); $owner = $this->cell($cells, $columns['owner'] ?? null); $assignee = $this->cell($cells, $columns['assignee'] ?? null); $startText = $this->cell($cells, $columns['start'] ?? null); @@ -325,50 +297,108 @@ class SystemizationTodoWorkbookImporter $notes = $this->cell($cells, $columns['notes'] ?? null); $status = $this->status($progress, $startText, $finishText); - $rows[] = [ - 'row' => $rowNumber, - 'category' => $category, - 'section' => $section, - 'title' => $title, - 'data' => [ - 'category' => $category !== '' ? $category : null, - 'section' => $section !== '' ? $section : null, + foreach ($titles as $offset => $title) { + $titleIndex = $offset + 1; + $sortOrder = ($rowNumber * 100) + $titleIndex; + $rows[] = [ + 'row' => $rowNumber, + 'title_index' => $titleIndex, + 'title_count' => $titleCount, + 'source_title' => $sourceTitle, + 'category' => $category, + 'section' => $section, 'title' => $title, - 'owner' => $owner !== '' ? $owner : null, - 'assignee' => $assignee !== '' ? $assignee : null, - 'start_date' => $startDate, - 'start_note' => $startNote !== '' ? $startNote : null, - 'due_date' => $finishDate, - 'due_note' => $finishNote !== '' ? $finishNote : null, - 'status' => $status, - 'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED - ? ($finishDate ?? now()->toDateString()) - : null, - 'progress' => $progress !== '' ? $progress : null, - 'notes' => $notes !== '' ? $notes : null, - 'sort_order' => $rowNumber, - 'source_key' => hash( - 'sha256', - implode('|', [ - $companyId, - $sheetName, - $rowNumber, - $category, - $section, - $title, - $owner, - $assignee, - ]) - ), - ], - ]; + 'data' => [ + 'category' => $category !== '' ? $category : null, + 'section' => $section !== '' ? $section : null, + 'title' => $title, + 'owner' => $owner !== '' ? $owner : null, + 'assignee' => $assignee !== '' ? $assignee : null, + 'start_date' => $startDate, + 'start_note' => $startNote !== '' ? $startNote : null, + 'due_date' => $finishDate, + 'due_note' => $finishNote !== '' ? $finishNote : null, + 'status' => $status, + 'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED + ? ($finishDate ?? now()->toDateString()) + : null, + 'progress' => $progress !== '' ? $progress : null, + 'notes' => $notes !== '' ? $notes : null, + 'sort_order' => $sortOrder, + 'source_key' => hash( + 'sha256', + implode('|', [ + $companyId, + $sheetName, + $rowNumber, + $titleIndex, + $category, + $section, + $title, + $owner, + $assignee, + ]) + ), + ], + ]; + } } return $rows; } /** - * @param array}> $rows + * Split a cell into separate tasks only when it contains multiple numbered lines. + * + * @return array + */ + private function titleSegments(string $value): array + { + $value = trim(str_replace(["\r\n", "\r"], "\n", $value)); + $marker = '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u'; + $lines = preg_split('/\n/u', $value) ?: []; + $numberedLines = array_filter( + $lines, + static fn (string $line): bool => preg_match($marker, trim($line)) === 1, + ); + if (count($numberedLines) < 2) { + return [$value]; + } + + $segments = []; + $current = ''; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + if (preg_match($marker, $line) === 1) { + if ($current !== '') { + $segments[] = trim($current); + } + $current = trim((string) preg_replace($marker, '', $line)); + continue; + } + $current = $current === '' ? $line : $current . "\n" . $line; + } + if ($current !== '') { + $segments[] = trim($current); + } + + return $segments !== [] ? $segments : [$value]; + } + + /** + * @param array + * }> $rows * @return array{0:array,1:array} */ private function matchRows(array $rows, \Illuminate\Support\Collection $existing): array @@ -381,6 +411,32 @@ class SystemizationTodoWorkbookImporter } foreach ($rows as $rowIndex => $row) { + if ($row['title_count'] < 2 || $row['title_index'] !== 1) { + continue; + } + $sourceKey = $this->matchTitleKey($row['source_title']); + $available = array_values(array_filter( + $candidates[$sourceKey] ?? [], + fn (SystemizationTodo $todo): bool => + ! in_array($todo->id, $usedIds, true) + && $this->matchText($todo->category) === $this->matchText($row['category']) + && $this->matchText($todo->section) === $this->matchText($row['section']), + )); + if ($available === []) { + continue; + } + usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int => + abs($a->sort_order - $row['data']['sort_order']) + <=> abs($b->sort_order - $row['data']['sort_order']) + ); + $matches[$rowIndex] = $available[0]->id; + $usedIds[] = $available[0]->id; + } + + foreach ($rows as $rowIndex => $row) { + if (isset($matches[$rowIndex])) { + continue; + } $key = $this->matchTitleKey($row['title']); $available = array_values(array_filter( $candidates[$key] ?? [], @@ -390,7 +446,8 @@ class SystemizationTodoWorkbookImporter continue; } usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int => - abs($a->sort_order - $row['row']) <=> abs($b->sort_order - $row['row']) + abs($a->sort_order - $row['data']['sort_order']) + <=> abs($b->sort_order - $row['data']['sort_order']) ); $todo = $available[0]; $matches[$rowIndex] = $todo->id; @@ -419,7 +476,7 @@ class SystemizationTodoWorkbookImporter $pairs[] = [ 'rowIndex' => $rowIndex, 'todoId' => $todoId, - 'score' => $similarity - abs($todo->sort_order - $row['row']) / 1000, + 'score' => $similarity - abs($todo->sort_order - $row['data']['sort_order']) / 1000, ]; } } @@ -445,7 +502,7 @@ class SystemizationTodoWorkbookImporter private function matchTitleKey(?string $value): string { $value = preg_replace( - '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u', + '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u', '', (string) $value, ) ?? ''; diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 3e4437f2..1420c6aa 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -9,6 +9,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\Rule; @@ -91,6 +92,8 @@ class SystemizationTodos extends Controller 'order' => PHP_INT_MAX, 'category_display' => '', 'section_display' => '', + 'type_count' => 0, + 'content_count' => 0, ]; $todo->setAttribute('type_sequence', $sequence['type_sequence']); $todo->setAttribute('content_sequence', $sequence['content_sequence']); @@ -98,8 +101,11 @@ class SystemizationTodos extends Controller $todo->setAttribute('hierarchy_order', $sequence['order']); $todo->setAttribute('category_display', $sequence['category_display']); $todo->setAttribute('section_display', $sequence['section_display']); + $todo->setAttribute('type_count', $sequence['type_count']); + $todo->setAttribute('content_count', $sequence['content_count']); }); $items = $items->sortBy('hierarchy_order')->values(); + $positionGroups = $this->positionGroups($sequenceMap); if ($filters['type_sequence'] > 0) { $items = $items ->filter(static fn (SystemizationTodo $todo): bool => @@ -158,6 +164,7 @@ class SystemizationTodos extends Controller 'view' => 'business.pages.systemization-todos', 'title' => '系统化待办', 'items' => $items, + 'todoPositionGroups' => $positionGroups, 'todoFilters' => $filters, 'todoCategories' => $categories, 'todoOwners' => $this->peopleOptions('owner'), @@ -174,6 +181,8 @@ class SystemizationTodos extends Controller 'todoRoutes' => [ 'store' => route('business.systemization-todos.store'), 'update' => route('business.systemization-todos.update', ['todo' => '__TODO__']), + 'category' => route('business.systemization-todos.category', ['todo' => '__TODO__']), + 'section' => route('business.systemization-todos.section', ['todo' => '__TODO__']), 'destroy' => route('business.systemization-todos.destroy', ['todo' => '__TODO__']), 'attachment' => route('business.systemization-todos.attachments.show', [ 'todo' => '__TODO__', @@ -241,6 +250,10 @@ class SystemizationTodos extends Controller $sequenceMap = []; $hierarchyOrder = 0; foreach ($categoryKeys as $categoryIndex => $categoryKey) { + $typeCount = array_sum(array_map( + static fn (array $rows): int => count($rows), + $groupedRows[$categoryKey], + )); $contentKeys = array_keys($sectionOrder[$categoryKey]); usort($contentKeys, function (string $left, string $right) use ( $categoryKey, @@ -269,6 +282,8 @@ class SystemizationTodos extends Controller 'order' => $hierarchyOrder++, 'category_display' => $row['category_label'], 'section_display' => $row['section_label'], + 'type_count' => $typeCount, + 'content_count' => count($contentRows), ]; } } @@ -277,6 +292,30 @@ class SystemizationTodos extends Controller return $sequenceMap; } + private function positionGroups(array $sequenceMap): array + { + $groups = []; + $todos = SystemizationTodo::query() + ->orderBy('sort_order') + ->orderBy('id') + ->get(['id', 'category', 'section', 'sort_order']); + foreach ($todos as $todo) { + $sequence = $sequenceMap[$todo->id] ?? null; + if ($sequence === null) { + continue; + } + $key = $sequence['type_sequence'] . ':' . $sequence['content_sequence']; + $groups[$key] ??= [ + 'category' => (string) ($todo->category ?? ''), + 'section' => $sequence['section_display'], + 'count' => 0, + ]; + $groups[$key]['count']++; + } + + return array_values($groups); + } + private function compareHierarchyGroups( string $left, string $right, @@ -293,7 +332,7 @@ class SystemizationTodos extends Controller private function hierarchyLabel(?string $value): string { return trim((string) preg_replace( - '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u', + '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u', '', (string) $value, )); @@ -314,20 +353,134 @@ class SystemizationTodos extends Controller public function store(Request $request): RedirectResponse { $data = $this->validated($request); + $position = max(1, (int) ($data['position'] ?? 1)); + unset($data['position']); $data['company_id'] = (int) company_id(); - $data['sort_order'] = (int) SystemizationTodo::query()->max('sort_order') + 1; $data['created_by'] = user_id(); $data['updated_by'] = user_id(); $this->normalizeCompletionDates($data); - $todo = SystemizationTodo::query()->create($data); + $insertedSequence = $position; + $todo = DB::transaction(function () use ($data, $position, &$insertedSequence): SystemizationTodo { + $siblings = $this->contentSiblings($data['category'] ?? null, $data['section'] ?? null); + $nextOrder = (int) SystemizationTodo::query()->max('sort_order') + 1; + if ($siblings->isEmpty()) { + $insertOrder = $nextOrder; + } elseif ($position <= $siblings->count()) { + $insertOrder = (int) $siblings[$position - 1]->sort_order; + } else { + $insertOrder = (int) $siblings->max('sort_order') + 1; + } + + SystemizationTodo::query() + ->where('sort_order', '>=', $insertOrder) + ->increment('sort_order'); + $insertedSequence = $siblings->isEmpty() + ? 1 + : min($position, $siblings->count() + 1); + + return SystemizationTodo::query()->create([ + ...$data, + 'sort_order' => $insertOrder, + ]); + }); $this->storeAttachments($request, $todo); - return $this->backToPage('待办事项已新增并定位。', 'success', $todo); + return $this->backToPage( + '待办事项已新增为当前内容第 ' . $insertedSequence . ' 条并定位。', + 'success', + $todo, + ); + } + + public function updateCategory(Request $request, SystemizationTodo $todo): RedirectResponse + { + $data = $request->validate([ + 'category' => ['required', 'string', 'max:100'], + ]); + $ids = []; + DB::transaction(function () use ($todo, $data, &$ids): void { + $ids = $this->groupTodoIds($todo, false); + $this->updateGroupField($ids, ['category' => trim($data['category'])]); + }); + + return $this->backToPage( + '类型已更新,当前类型下的 ' . count($ids) . ' 条待办已同步调整。', + 'success', + $todo, + ); + } + + public function updateSection(Request $request, SystemizationTodo $todo): RedirectResponse + { + $data = $request->validate([ + 'section' => ['required', 'string', 'max:255'], + ]); + $ids = []; + DB::transaction(function () use ($todo, $data, &$ids): void { + $ids = $this->groupTodoIds($todo, true); + $this->updateGroupField($ids, ['section' => trim($data['section'])]); + }); + + return $this->backToPage( + '内容已更新,当前内容下的 ' . count($ids) . ' 条待办已同步调整。', + 'success', + $todo, + ); + } + + private function contentSiblings(?string $category, ?string $section): Collection + { + $categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($category)); + $sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($section)); + + return SystemizationTodo::query() + ->orderBy('sort_order') + ->orderBy('id') + ->lockForUpdate() + ->get() + ->filter(fn (SystemizationTodo $todo): bool => + $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)) === $categoryKey + && $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)) === $sectionKey + ) + ->values(); + } + + private function groupTodoIds(SystemizationTodo $todo, bool $includeSection): array + { + $categoryKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->category)); + $sectionKey = $this->hierarchyGroupKey($this->hierarchyLabel($todo->section)); + + return SystemizationTodo::query() + ->lockForUpdate() + ->get(['id', 'category', 'section']) + ->filter(fn (SystemizationTodo $candidate): bool => + $this->hierarchyGroupKey($this->hierarchyLabel($candidate->category)) === $categoryKey + && (! $includeSection + || $this->hierarchyGroupKey($this->hierarchyLabel($candidate->section)) === $sectionKey) + ) + ->pluck('id') + ->map(static fn (mixed $id): int => (int) $id) + ->values() + ->all(); + } + + private function updateGroupField(array $ids, array $data): void + { + if ($ids === []) { + return; + } + SystemizationTodo::query() + ->whereIn('id', $ids) + ->update([ + ...$data, + 'updated_by' => user_id(), + ]); } public function update(Request $request, SystemizationTodo $todo): RedirectResponse { $data = $this->validated($request); + unset($data['position']); $data['updated_by'] = user_id(); $this->normalizeCompletionDates($data); @@ -471,6 +624,7 @@ class SystemizationTodos extends Controller 'category' => ['nullable', 'string', 'max:100'], 'section' => ['nullable', 'string', 'max:255'], 'title' => ['required', 'string', 'max:5000'], + 'position' => ['nullable', 'integer', 'min:1'], 'owner' => ['nullable', 'string', 'max:255'], 'assignee' => ['nullable', 'string', 'max:255'], 'start_date' => ['nullable', 'date_format:Y-m-d'], diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index c9409498..2c555b77 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -29,7 +29,7 @@ .sys-todo-filter__multi details[open] > summary { border-color:var(--sys-gold); box-shadow:0 0 0 3px rgba(133,102,50,.14); } .sys-todo-filter__multi details[open] > summary .material-icons-outlined { transform:translateY(-50%) rotate(180deg); } .sys-todo-filter__multi-label { color:#595959; font-size:.72rem; line-height:1; } - .sys-todo-filter__multi-value { overflow:hidden; font-size:.8125rem; line-height:1.2; text-overflow:ellipsis; white-space:nowrap; } + .sys-todo-filter__multi-value { font-size:.8125rem; line-height:1.35; overflow-wrap:anywhere; white-space:normal; } .sys-todo-filter__multi-menu { position:absolute; top:calc(100% + .3rem); left:0; z-index:20; display:grid; width:max(100%,13rem); max-height:17rem; padding:.35rem; border:1px solid var(--sys-border); border-radius:.55rem; background:#fff; box-shadow:0 8px 22px rgba(23,23,23,.16); overflow:auto; } .sys-todo-filter__multi-menu label { display:flex; flex:none; align-items:center; gap:.45rem; min-height:2rem; padding:.3rem .45rem; border-radius:.35rem; color:#333; font-size:.78rem; cursor:pointer; } .sys-todo-filter__multi-menu label:hover { background:var(--sys-gold-soft); } @@ -42,15 +42,16 @@ .sys-todo-table th { position:sticky; top:0; z-index:4; padding:.55rem .48rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; } .sys-todo-table th:first-child { left:0; z-index:8; width:5.4rem; min-width:5.4rem; text-align:center; box-shadow:1px 0 0 #e5ddcf; } .sys-todo-table td { padding:.72rem .6rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; line-height:1.6; vertical-align:top; } - .sys-todo-table tbody tr { cursor:pointer; } .sys-todo-table tbody tr:hover td { background:#fdfbf6; } .sys-todo-table tbody tr.is-highlighted td { background:#fff4cf !important; } - .sys-todo-table tbody tr:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; } + .sys-todo-table [data-sys-todo-row-edit], .sys-todo-table [data-sys-todo-group-edit] { cursor:pointer; } + .sys-todo-table [data-sys-todo-row-edit]:hover, .sys-todo-table [data-sys-todo-group-edit]:hover { background:#fff4cf; } + .sys-todo-table [data-sys-todo-row-edit]:focus-visible, .sys-todo-table [data-sys-todo-group-edit]:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; } .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__group-text { min-width:0; white-space:pre-line; overflow-wrap:anywhere; } .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; } @@ -59,8 +60,6 @@ .sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; } .sys-todo-table__progress { min-width:12rem; max-width:20rem; } .sys-todo-table__notes { min-width:9rem; max-width:16rem; } - .sys-todo-table__clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:3; } - .sys-todo-table__section .sys-todo-table__clamp { -webkit-line-clamp:2; } .sys-todo-badge { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; } .sys-todo-badge--progress { border-color:#dbbf7a; background:#fff7df; color:#935f07; } .sys-todo-badge--completed { border-color:#bad0ac; background:#f2f8ed; color:#53793e; } @@ -95,13 +94,14 @@ .sys-todo-dialog__body { max-height:calc(90vh - 9rem); overflow:auto; padding:1rem 1.25rem; } .sys-todo-form-grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:.75rem 1rem; } .sys-todo-field { display:grid; gap:.3rem; min-width:0; color:#595959; font-size:.75rem; } + .sys-todo-field--2 { grid-column:span 2; } .sys-todo-field--3 { grid-column:span 3; } .sys-todo-field--4 { grid-column:span 4; } .sys-todo-field--6 { grid-column:span 6; } .sys-todo-field--12 { grid-column:span 12; } .sys-todo-field input, .sys-todo-field select, .sys-todo-field textarea { width:100%; padding:.5rem .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; } .sys-todo-field input, .sys-todo-field select { min-height:2.15rem; } - .sys-todo-field textarea { min-height:5.5rem; resize:vertical; line-height:1.55; } + .sys-todo-field textarea { min-height:5.5rem; resize:vertical; line-height:1.55; overflow:hidden; } .sys-todo-new-category { margin-top:.35rem; } .sys-todo-new-category[hidden] { display:none; } .sys-todo-owner-checks { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.2rem .45rem; max-height:7rem; padding:.4rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; overflow:auto; } @@ -135,6 +135,18 @@ .sys-todo-confirm__actions button { min-height:2.1rem; padding:0 .9rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; font:inherit; font-size:.8125rem; cursor:pointer; } .sys-todo-confirm__actions .sys-todo-confirm__submit { border-color:#b80000; background:#b80000; color:#fff; } .sys-todo-confirm__actions .sys-todo-confirm__submit:hover { background:#920000; } + .sys-todo-group-dialog { width:min(34rem,calc(100vw - 2rem)); padding:0; border:1px solid var(--sys-border); border-radius:.8rem; background:#fff; color:#171717; overflow:hidden; } + .sys-todo-group-dialog::backdrop { background:rgba(23,23,23,.58); } + .sys-todo-group-dialog__head { display:flex; align-items:center; gap:.75rem; padding:.9rem 1rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; } + .sys-todo-group-dialog__head h2 { margin:0; font-size:.95rem; } + .sys-todo-group-dialog__head button { margin-left:auto; width:2rem; height:2rem; padding:0; border:1px solid var(--sys-border); border-radius:999px; background:#fff; color:#595959; cursor:pointer; } + .sys-todo-group-dialog__body { display:grid; gap:.55rem; padding:1rem; } + .sys-todo-group-dialog__body label { display:grid; gap:.35rem; color:#595959; font-size:.76rem; } + .sys-todo-group-dialog__body textarea { width:100%; min-height:5.5rem; padding:.55rem .65rem; border:1px solid var(--sys-border); border-radius:.45rem; font:inherit; font-size:.84rem; line-height:1.6; overflow:hidden; resize:vertical; } + .sys-todo-group-dialog__note { margin:0; color:#666; font-size:.73rem; line-height:1.55; } + .sys-todo-group-dialog__actions { display:flex; justify-content:flex-end; gap:.5rem; padding:.75rem 1rem; border-top:1px solid #e8e0d2; background:#fcfaf5; } + .sys-todo-group-dialog__actions button { min-height:2.1rem; padding:0 .9rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; font:inherit; font-size:.8125rem; cursor:pointer; } + .sys-todo-group-dialog__actions button[type="submit"] { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; } .sys-todo-image-dialog { width:min(72rem,calc(100vw - 2rem)); max-height:92vh; padding:0; border:1px solid var(--sys-border); border-radius:.8rem; background:#111; color:#fff; overflow:hidden; } .sys-todo-image-dialog::backdrop { background:rgba(5,5,6,.78); } .sys-todo-image-dialog__head { display:flex; align-items:center; gap:1rem; padding:.65rem .85rem; background:#1d1d1f; } @@ -146,7 +158,7 @@ .sys-todo-image-dialog__foot a { color:#f0e4bf; font-size:.75rem; } @media (max-width:1300px) { .sys-todo-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } } @media (max-width:900px) { .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:9rem; } .sys-todo-filter__actions { width:100%; } .sys-todo-filter__actions .sys-todo-page__button { flex:1; } } - @media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } } + @media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } }
@@ -266,7 +278,7 @@ @php $cleanDisplayLabel = static function (?string $value): string { return trim((string) preg_replace( - '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u', + '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u', '', (string) $value, )); @@ -321,7 +333,7 @@ @endphp
- 共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。 + 共 {{ number_format($items->count()) }} 条,已全部显示。点击待办事项编辑详情;点击类型或内容可单独修改该组名称。
- + +
总负责人 @@ -475,9 +507,9 @@ - - - + + +
完成截图
@@ -497,6 +529,28 @@ + +
+ @csrf + @method('PATCH') +
+

修改类型

+ +
+
+ +

保存后会同步修改当前分组的名称。

+
+
+ + +
+
+
+
@csrf @@ -549,6 +603,9 @@ var categoryValue = dialog.querySelector('[data-sys-todo-category-value]'); var newCategoryWrap = dialog.querySelector('[data-sys-todo-new-category]'); var newCategoryInput = dialog.querySelector('[data-sys-todo-new-category-input]'); + var sectionInput = dialog.querySelector('[name="section"]'); + var positionField = dialog.querySelector('[data-sys-todo-position-field]'); + var positionSelect = dialog.querySelector('[data-sys-todo-position]'); var ownerValue = dialog.querySelector('[data-sys-todo-owner-value]'); var ownerOptionInputs = Array.prototype.slice.call( dialog.querySelectorAll('[data-sys-todo-owner-options] input[type="checkbox"]') @@ -557,8 +614,16 @@ var deleteConfirm = document.getElementById('sysTodoDeleteConfirm'); var deleteForm = deleteConfirm.querySelector('[data-sys-todo-delete-form]'); var deleteTitle = deleteConfirm.querySelector('[data-sys-todo-delete-title]'); + var groupDialog = document.getElementById('sysTodoGroupDialog'); + var groupForm = groupDialog.querySelector('[data-sys-todo-group-form]'); + var groupTitle = groupDialog.querySelector('[data-sys-todo-group-title]'); + var groupLabel = groupDialog.querySelector('[data-sys-todo-group-label]'); + var groupInput = groupDialog.querySelector('[data-sys-todo-group-input]'); + var groupNote = groupDialog.querySelector('[data-sys-todo-group-note]'); var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + var positionGroups = @json($todoPositionGroups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); var currentTodoId = null; + var currentGroupTodoId = null; var currentTodoTitle = ''; var uploadPrompt = '点击选择、拖入此区域或直接粘贴,支持多张,每张不超过 10 MB。'; var ownerFilter = document.querySelector('[data-sys-todo-owner-filter]'); @@ -582,6 +647,58 @@ if (field) field.value = value || ''; } + function resizeTextarea(field) { + if (!field || field.offsetParent === null) return; + field.style.height = 'auto'; + field.style.height = Math.max(field.scrollHeight, 72) + 'px'; + } + + function resizeTextareas(root) { + (root || document).querySelectorAll('[data-sys-todo-auto-grow]').forEach(resizeTextarea); + } + + function normalizedGroupText(value) { + return String(value || '') + .replace(/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u, '') + .replace(/[\s\p{Z}\u200B\uFEFF]+/gu, '') + .toLowerCase(); + } + + function currentPositionGroup() { + var category = normalizedGroupText(categoryValue.value); + var section = normalizedGroupText(sectionInput.value); + return positionGroups.find(function (group) { + return normalizedGroupText(group.category) === category + && normalizedGroupText(group.section) === section; + }) || null; + } + + function populatePositionOptions() { + positionSelect.replaceChildren(); + positionSelect.disabled = !!currentTodoId; + if (currentTodoId) { + positionField.hidden = true; + return; + } + + positionField.hidden = false; + var group = currentPositionGroup(); + var count = group ? Number(group.count || 0) : 0; + for (var index = 1; index <= count; index++) { + var before = document.createElement('option'); + before.value = String(index); + before.textContent = '第 ' + index + ' 条前插入(原第 ' + index + ' 条及之后顺延)'; + positionSelect.appendChild(before); + } + var end = document.createElement('option'); + end.value = String(count + 1); + end.textContent = count > 0 + ? '添加到末尾(第 ' + (count + 1) + ' 条)' + : '新增为第 1 条'; + positionSelect.appendChild(end); + positionSelect.value = String(count + 1); + } + function syncScrollRail() { var rect = tableWrap.getBoundingClientRect(); var left = Math.max(8, rect.left); @@ -719,6 +836,28 @@ imagePreview.removeAttribute('src'); } + function openGroupDialog(type, value, count) { + var isCategory = type === 'category'; + groupForm.action = routes[isCategory ? 'category' : 'section'].replace('__TODO__', currentGroupTodoId); + groupInput.name = isCategory ? 'category' : 'section'; + groupInput.value = value || ''; + groupTitle.textContent = isCategory ? '修改类型' : '修改内容'; + groupLabel.textContent = isCategory ? '类型名称' : '内容名称'; + groupNote.textContent = '保存后会同步修改当前分组内的 ' + count + ' 条待办。'; + if (typeof groupDialog.showModal === 'function') groupDialog.showModal(); + else groupDialog.setAttribute('open', ''); + window.setTimeout(function () { + resizeTextarea(groupInput); + groupInput.focus(); + groupInput.select(); + }, 0); + } + + function closeGroupDialog() { + if (typeof groupDialog.close === 'function') groupDialog.close(); + else groupDialog.removeAttribute('open'); + } + function openDialog(payload) { currentTodoId = payload && payload.id ? payload.id : null; currentTodoTitle = payload && payload.title ? payload.title : ''; @@ -727,6 +866,7 @@ form.action = currentTodoId ? routes.update.replace('__TODO__', currentTodoId) : routes.store; title.textContent = currentTodoId ? '编辑待办' : '新增待办'; deleteButton.hidden = !currentTodoId; + positionField.hidden = !!currentTodoId; if (payload) { setCategoryValue(payload.category); setValue('section', payload.section); @@ -746,6 +886,7 @@ if (!currentTodoId && !(payload && payload.status)) { setValue('status', '待办'); } + populatePositionOptions(); existing.replaceChildren(); ((payload && payload.attachments) || []).forEach(function (attachment) { @@ -776,6 +917,7 @@ else dialog.setAttribute('open', ''); uploadNote.textContent = uploadPrompt; window.setTimeout(function () { + resizeTextareas(dialog); var titleField = form.querySelector('[name="title"]'); if (titleField) titleField.focus(); }, 0); @@ -817,6 +959,20 @@ }); return; } + var groupCell = event.target.closest('[data-sys-todo-group-edit]'); + if (groupCell) { + var groupRow = groupCell.closest('tr'); + var groupRowId = groupRow ? Number(String(groupRow.id).replace('sys-todo-row-', '')) : 0; + if (groupRowId) { + currentGroupTodoId = groupRowId; + openGroupDialog( + groupCell.getAttribute('data-sys-todo-group-edit'), + groupCell.getAttribute('data-sys-todo-group-value'), + Number(groupCell.getAttribute('data-sys-todo-group-count') || 1) + ); + } + return; + } if (event.target.closest('[data-sys-todo-open-delete]')) { openDeleteConfirm(); return; @@ -827,6 +983,7 @@ return; } if (event.target.closest('[data-sys-todo-close]')) closeDialog(); + if (event.target.closest('[data-sys-todo-group-close]')) closeGroupDialog(); if (event.target.closest('[data-sys-todo-delete-close]')) closeDeleteConfirm(); if (event.target.closest('[data-sys-todo-image-close]')) closeImageViewer(); if (event.target.closest('[data-sys-todo-choose-files]')) screenshotInput.click(); @@ -834,6 +991,9 @@ dialog.addEventListener('click', function (event) { if (event.target === dialog) closeDialog(); }); + groupDialog.addEventListener('click', function (event) { + if (event.target === groupDialog) closeGroupDialog(); + }); dialog.addEventListener('paste', function (event) { var files = Array.prototype.slice.call(event.clipboardData?.items || []) .filter(function (item) { return item.kind === 'file'; }) @@ -850,9 +1010,25 @@ deleteConfirm.addEventListener('click', function (event) { if (event.target === deleteConfirm) closeDeleteConfirm(); }); - categorySelect.addEventListener('change', syncCategoryValue); + categorySelect.addEventListener('change', function () { + syncCategoryValue(); + populatePositionOptions(); + }); newCategoryInput.addEventListener('input', function () { - if (categorySelect.value === '__new__') syncCategoryValue(); + if (categorySelect.value === '__new__') { + syncCategoryValue(); + populatePositionOptions(); + } + }); + sectionInput.addEventListener('input', function () { + resizeTextarea(sectionInput); + populatePositionOptions(); + }); + dialog.addEventListener('input', function (event) { + if (event.target.matches('[data-sys-todo-auto-grow]')) resizeTextarea(event.target); + }); + groupDialog.addEventListener('input', function (event) { + if (event.target.matches('[data-sys-todo-auto-grow]')) resizeTextarea(event.target); }); ownerOptionInputs.forEach(function (input) { input.addEventListener('change', updateOwnerValue); @@ -881,6 +1057,21 @@ closeImageViewer(); return; } + var groupCell = event.target.closest('[data-sys-todo-group-edit]'); + if (groupCell && event.target === groupCell && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + var groupRow = groupCell.closest('tr'); + var groupRowId = groupRow ? Number(String(groupRow.id).replace('sys-todo-row-', '')) : 0; + if (groupRowId) { + currentGroupTodoId = groupRowId; + openGroupDialog( + groupCell.getAttribute('data-sys-todo-group-edit'), + groupCell.getAttribute('data-sys-todo-group-value'), + Number(groupCell.getAttribute('data-sys-todo-group-count') || 1) + ); + } + return; + } var row = event.target.closest('[data-sys-todo-row-edit]'); if (row && event.target === row && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); diff --git a/routes/admin.php b/routes/admin.php index 31937370..b4899f31 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -785,6 +785,12 @@ Route::prefix('workspace/other/systemization-todos') ->middleware('import') ->name('import'); Route::post('/', [SystemizationTodos::class, 'store'])->name('store'); + Route::patch('{todo}/category', [SystemizationTodos::class, 'updateCategory']) + ->whereNumber('todo') + ->name('category'); + Route::patch('{todo}/section', [SystemizationTodos::class, 'updateSection']) + ->whereNumber('todo') + ->name('section'); Route::patch('{todo}', [SystemizationTodos::class, 'update']) ->whereNumber('todo') ->name('update'); diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index f8b310bc..4ed5ecea 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -42,6 +42,10 @@ class SystemizationTodosTest extends FeatureTestCase ->assertSee('name="assignee[]"', false) ->assertSee('data-sys-todo-category-select', false) ->assertSee('data-sys-todo-new-category-input', false) + ->assertSee('data-sys-todo-position', false) + ->assertSee('data-sys-todo-group-edit', false) + ->assertSee('id="sysTodoGroupDialog"', false) + ->assertDontSee('sys-todo-table__clamp', false) ->assertSeeText('+ 新增类型') ->assertSee('data-sys-todo-choose-files', false) ->assertSee('data-sys-todo-open-delete', false) @@ -127,6 +131,89 @@ class SystemizationTodosTest extends FeatureTestCase $this->assertSoftDeleted('business_systemization_todos', ['id' => $todo->id]); } + public function test_can_insert_a_todo_at_a_selected_sequence_and_shift_existing_items(): void + { + $this->loginAs(); + + $items = []; + foreach (range(1, 7) as $index) { + $items[$index] = $this->createTodo( + '收入', + '平台收入核对', + '原待办 ' . $index, + '2026-11-30', + $index, + ); + } + + $this->post(route('business.systemization-todos.store'), [ + 'category' => '收入', + 'section' => '平台收入核对', + 'position' => 7, + 'title' => '新增待办 7', + 'owner' => '晨曦', + 'assignee' => '栎阳', + 'status' => SystemizationTodo::STATUS_PENDING, + ])->assertRedirect() + ->assertSessionHas('success', '待办事项已新增为当前内容第 7 条并定位。'); + + $page = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ]))->assertOk()->viewData('content'); + $group = $page['items'] + ->where('category', '收入') + ->where('section', '平台收入核对') + ->values(); + + $this->assertSame([ + '原待办 1', + '原待办 2', + '原待办 3', + '原待办 4', + '原待办 5', + '原待办 6', + '新增待办 7', + '原待办 7', + ], $group->pluck('title')->all()); + $this->assertSame( + range(1, 8), + $group->pluck('item_sequence')->map(static fn (mixed $value): int => (int) $value)->all(), + ); + $this->assertGreaterThan( + $items[6]->fresh()->sort_order, + $items[7]->fresh()->sort_order, + ); + } + + public function test_type_and_content_cells_can_be_edited_for_the_whole_group(): void + { + $this->loginAs(); + + $incomeA = $this->createTodo('收入', '平台收入核对', '收入事项 A', '2026-11-30', 1); + $incomeB = $this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-11-30', 2); + $incomeOther = $this->createTodo('收入', '退款核对', '退款事项', '2026-11-30', 3); + $cost = $this->createTodo('成本', '成本核对', '成本事项', '2026-11-30', 4); + + $this->patch(route('business.systemization-todos.category', ['todo' => $incomeA->id]), [ + 'category' => '业务收入', + ])->assertRedirect(); + + foreach ([$incomeA, $incomeB, $incomeOther] as $todo) { + $this->assertSame('业务收入', $todo->fresh()->category); + } + $this->assertSame('成本', $cost->fresh()->category); + + $this->patch(route('business.systemization-todos.section', ['todo' => $incomeA->id]), [ + 'section' => '收入核对新版', + ])->assertRedirect(); + + $this->assertSame('收入核对新版', $incomeA->fresh()->section); + $this->assertSame('收入核对新版', $incomeB->fresh()->section); + $this->assertSame('退款核对', $incomeOther->fresh()->section); + $this->assertSame('成本核对', $cost->fresh()->section); + } + public function test_excel_import_preserves_hierarchy_and_does_not_overwrite_existing_rows(): void { $path = sys_get_temp_dir() . '/systemization-todos-' . uniqid() . '.xlsx'; @@ -461,6 +548,77 @@ class SystemizationTodosTest extends FeatureTestCase } } + public function test_excel_sync_splits_numbered_titles_and_keeps_attachment_on_first_item(): void + { + Storage::fake('uploads'); + $this->loginAs(); + + $combinedTitle = "1、核对平台收入\n\n2、核对订单收入\n3,核对特殊订单"; + $historical = SystemizationTodo::query()->create([ + 'company_id' => (int) company_id(), + 'category' => '收入', + 'section' => '平台收入核对', + 'title' => $combinedTitle, + 'owner' => '晨曦', + 'assignee' => '栎阳', + 'start_date' => '2026-10-20', + 'due_date' => '2026-10-21', + 'status' => SystemizationTodo::STATUS_COMPLETED, + 'completed_at' => '2026-10-21', + 'progress' => '已完成', + 'sort_order' => 2, + 'source_key' => 'legacy-combined-row', + 'imported_at' => now(), + 'created_by' => user_id(), + 'updated_by' => user_id(), + ]); + Storage::disk('uploads')->put('systemization-todos/combined.png', 'image'); + SystemizationTodoAttachment::query()->create([ + 'company_id' => (int) company_id(), + 'systemization_todo_id' => $historical->id, + 'path' => 'systemization-todos/combined.png', + 'original_name' => 'combined.png', + 'mime_type' => 'image/png', + 'size' => 5, + 'uploaded_by' => user_id(), + ]); + + $path = sys_get_temp_dir() . '/systemization-split-' . uniqid() . '.xlsx'; + try { + $this->writeWorkbook($path, [ + ['收入', '平台收入核对', $combinedTitle, '晨曦', '栎阳', '2026-10-20', '2026-10-21', '已完成', null], + ]); + $preview = app(SystemizationTodoWorkbookImporter::class) + ->sync($path, (int) company_id(), user_id(), true); + $this->assertSame(2, $preview['created']); + $this->assertSame(1, $preview['updated']); + $this->assertSame(0, $preview['retained']); + + $result = app(SystemizationTodoWorkbookImporter::class) + ->sync($path, (int) company_id(), user_id()); + $this->assertSame(2, $result['created']); + $this->assertSame(1, $result['updated']); + $this->assertSame(0, $result['retained']); + + $todos = SystemizationTodo::query()->orderBy('sort_order')->get(); + $this->assertSame([ + '核对平台收入', + '核对订单收入', + '核对特殊订单', + ], $todos->pluck('title')->all()); + $this->assertSame($historical->id, $todos->first()->id); + $this->assertSame(1, $todos->first()->attachments()->count()); + + $secondResult = app(SystemizationTodoWorkbookImporter::class) + ->sync($path, (int) company_id(), user_id()); + $this->assertSame(0, $secondResult['created']); + $this->assertSame(0, $secondResult['updated']); + $this->assertSame(3, SystemizationTodo::query()->count()); + } finally { + @unlink($path); + } + } + private function createTodo( string $category, string $section,