From 937d187dc6219601f5ed0c7a9cabb8535a868360 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Mon, 26 Oct 2026 11:39:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9C=80=E6=96=B0=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=8C=96=E5=BE=85=E5=8A=9E=E5=B7=A5=E4=BD=9C=E7=B0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SystemizationTodoWorkbookImporter.php | 387 ++++++++++++++++++ .../Commands/ImportSystemizationTodos.php | 42 +- .../Business/SystemizationTodos.php | 7 +- .../pages/systemization-todos.blade.php | 22 +- .../Business/SystemizationTodosTest.php | 81 +++- 5 files changed, 510 insertions(+), 29 deletions(-) diff --git a/app/Business/SystemizationTodoWorkbookImporter.php b/app/Business/SystemizationTodoWorkbookImporter.php index bda80867..9442319d 100644 --- a/app/Business/SystemizationTodoWorkbookImporter.php +++ b/app/Business/SystemizationTodoWorkbookImporter.php @@ -139,6 +139,393 @@ class SystemizationTodoWorkbookImporter ); } + /** + * 将新版工作簿同步到已有来源记录。保留截图和来源中已不在工作簿的事项,只新增和更新。 + * + * @return array{created:int,updated:int,retained:int,sheet:string} + */ + public function sync(string $path, int $companyId, ?int $userId = null, bool $dryRun = false): array + { + if (! is_file($path)) { + throw new RuntimeException('找不到待办 Excel 文件。'); + } + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($extension !== 'xlsx') { + throw new RuntimeException('系统化待办仅支持 .xlsx 文件。'); + } + + $reader = PayablesWorkbookStreamReader::forFile($path, $extension); + foreach ($reader->sheetNames() as $sheetIndex => $sheetName) { + $headerInfo = $this->headerMap($reader, $sheetIndex); + if ($headerInfo === null) { + continue; + } + + $rows = $this->workbookRows( + $reader, + $sheetIndex, + $sheetName, + $headerInfo['columns'], + $headerInfo['row'], + $companyId, + ); + $existing = SystemizationTodo::withoutGlobalScopes() + ->where('company_id', $companyId) + ->whereNull('deleted_at') + ->whereNotNull('imported_at') + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + [$matches, $newIndexes] = $this->matchRows($rows, $existing); + $matchedIds = array_values($matches); + $retained = $existing + ->filter(static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $matchedIds, true)) + ->values(); + $oldSourceKeys = $existing->pluck('source_key', 'id')->all(); + $updated = 0; + foreach ($matches as $rowIndex => $todoId) { + $todo = $existing->firstWhere('id', $todoId); + if ($todo && $this->contentDiffers($todo, $rows[$rowIndex]['data'])) { + $updated++; + } + } + + if ($dryRun) { + return [ + 'created' => count($newIndexes), + 'updated' => $updated, + 'retained' => $retained->count(), + 'sheet' => $sheetName, + ]; + } + + $created = 0; + $updated = 0; + DB::transaction(function () use ( + $rows, + $matches, + $newIndexes, + $existing, + $retained, + $oldSourceKeys, + $companyId, + $userId, + &$created, + &$updated, + ): void { + SystemizationTodo::withoutGlobalScopes() + ->where('company_id', $companyId) + ->whereNull('deleted_at') + ->whereNotNull('imported_at') + ->update(['source_key' => null]); + + foreach ($matches as $rowIndex => $todoId) { + $todo = $existing->firstWhere('id', $todoId); + if (! $todo) { + continue; + } + $todo->source_key = null; + $todo->syncOriginalAttribute('source_key'); + $todo->fill($rows[$rowIndex]['data']); + $contentChanged = $todo->isDirty([ + 'category', + 'section', + 'title', + 'owner', + 'assignee', + 'start_date', + 'start_note', + 'due_date', + 'due_note', + 'status', + 'completed_at', + 'progress', + 'notes', + ]); + $todo->updated_by = $userId; + $todo->save(); + if ($contentChanged) { + $updated++; + } + } + + foreach ($newIndexes as $rowIndex) { + SystemizationTodo::withoutGlobalScopes()->create([ + ...$rows[$rowIndex]['data'], + 'company_id' => $companyId, + 'imported_at' => now(), + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + $created++; + } + + foreach ($retained as $todo) { + if (($oldSourceKeys[$todo->id] ?? null) !== null) { + SystemizationTodo::withoutGlobalScopes() + ->whereKey($todo->id) + ->update(['source_key' => $oldSourceKeys[$todo->id]]); + } + } + }); + + return [ + 'created' => $created, + 'updated' => $updated, + 'retained' => $retained->count(), + 'sheet' => $sheetName, + ]; + } + + throw new RuntimeException( + '未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。' + ); + } + + /** + * @param array $columns + * @return array}> + */ + private function workbookRows( + PayablesWorkbookStreamReader $reader, + int $sheetIndex, + string $sheetName, + array $columns, + int $headerRow, + int $companyId, + ): array { + $rows = []; + $category = ''; + $section = ''; + foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) { + if ($rowNumber <= $headerRow) { + continue; + } + $categoryValue = $this->cell($cells, $columns['category'] ?? null); + if ($categoryValue !== '') { + $category = $categoryValue; + } + $sectionValue = $this->cell($cells, $columns['section'] ?? null); + if ($sectionValue !== '') { + $section = $sectionValue; + } + + $title = $this->cell($cells, $columns['title'] ?? null); + if ($title === '') { + continue; + } + $owner = $this->cell($cells, $columns['owner'] ?? null); + $assignee = $this->cell($cells, $columns['assignee'] ?? null); + $startText = $this->cell($cells, $columns['start'] ?? null); + $finishText = $this->cell($cells, $columns['finish'] ?? null); + [$startDate, $startNote] = $this->dateParts($startText); + [$finishDate, $finishNote] = $this->dateParts($finishText); + $progress = $this->cell($cells, $columns['progress'] ?? null); + $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, + '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, + ]) + ), + ], + ]; + } + + return $rows; + } + + /** + * @param array}> $rows + * @return array{0:array,1:array} + */ + private function matchRows(array $rows, \Illuminate\Support\Collection $existing): array + { + $matches = []; + $usedIds = []; + $candidates = []; + foreach ($existing as $todo) { + $candidates[$this->matchTitleKey($todo->title)][] = $todo; + } + + foreach ($rows as $rowIndex => $row) { + $key = $this->matchTitleKey($row['title']); + $available = array_values(array_filter( + $candidates[$key] ?? [], + static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $usedIds, true), + )); + if ($available === []) { + continue; + } + usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int => + abs($a->sort_order - $row['row']) <=> abs($b->sort_order - $row['row']) + ); + $todo = $available[0]; + $matches[$rowIndex] = $todo->id; + $usedIds[] = $todo->id; + } + + $remainingIds = $existing + ->pluck('id') + ->reject(static fn (int $id): bool => in_array($id, $usedIds, true)) + ->values(); + $todoById = $existing->keyBy('id'); + $pairs = []; + foreach ($rows as $rowIndex => $row) { + if (isset($matches[$rowIndex])) { + continue; + } + foreach ($remainingIds as $todoId) { + $todo = $todoById->get($todoId); + if (! $todo + || $this->matchText($todo->category) !== $this->matchText($row['category']) + || $this->matchText($todo->section) !== $this->matchText($row['section'])) { + continue; + } + $similarity = $this->titleSimilarity((string) $todo->title, $row['title']); + if ($similarity >= 0.55) { + $pairs[] = [ + 'rowIndex' => $rowIndex, + 'todoId' => $todoId, + 'score' => $similarity - abs($todo->sort_order - $row['row']) / 1000, + ]; + } + } + } + usort($pairs, static fn (array $a, array $b): int => $b['score'] <=> $a['score']); + foreach ($pairs as $pair) { + if (isset($matches[$pair['rowIndex']]) || in_array($pair['todoId'], $usedIds, true)) { + continue; + } + $matches[$pair['rowIndex']] = $pair['todoId']; + $usedIds[] = $pair['todoId']; + } + + return [ + $matches, + array_values(array_filter( + array_keys($rows), + static fn (int $rowIndex): bool => ! isset($matches[$rowIndex]), + )), + ]; + } + + private function matchTitleKey(?string $value): string + { + $value = preg_replace( + '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u', + '', + (string) $value, + ) ?? ''; + + return $this->matchText($value); + } + + private function matchText(?string $value): string + { + return mb_strtolower( + preg_replace('/[\s “”"\',。;、,.!?::;()()【】\[\]—-]+/u', '', (string) $value) ?? '', + 'UTF-8', + ); + } + + /** + * @param array $data + */ + private function contentDiffers(SystemizationTodo $todo, array $data): bool + { + foreach ([ + 'category', + 'section', + 'title', + 'owner', + 'assignee', + 'start_date', + 'start_note', + 'due_date', + 'due_note', + 'status', + 'completed_at', + 'progress', + 'notes', + ] as $field) { + $current = $todo->{$field}; + $incoming = $data[$field] ?? null; + if ($current instanceof \DateTimeInterface && $incoming !== null) { + $current = $current->format('Y-m-d'); + } + if ((string) ($current ?? '') !== (string) ($incoming ?? '')) { + return true; + } + } + + return false; + } + + private function titleSimilarity(string $left, string $right): float + { + $left = $this->matchTitleKey($left); + $right = $this->matchTitleKey($right); + if ($left === '' || $right === '') { + return 0.0; + } + if ($left === $right) { + return 1.0; + } + + $leftChars = mb_str_split($left); + $rightChars = mb_str_split($right); + $leftPairs = []; + $rightPairs = []; + for ($index = 0; $index < count($leftChars) - 1; $index++) { + $leftPairs[$leftChars[$index] . $leftChars[$index + 1]] = true; + } + for ($index = 0; $index < count($rightChars) - 1; $index++) { + $rightPairs[$rightChars[$index] . $rightChars[$index + 1]] = true; + } + $leftPairs = array_keys($leftPairs); + $rightPairs = array_keys($rightPairs); + $containment = str_contains($left, $right) || str_contains($right, $left) ? 0.95 : 0.0; + + $dice = ($leftPairs === [] || $rightPairs === []) + ? 0.0 + : 2 * count(array_intersect($leftPairs, $rightPairs)) / (count($leftPairs) + count($rightPairs)); + + return max($containment, $dice); + } + /** * @return array{row:int,columns:array}|null */ diff --git a/app/Console/Commands/ImportSystemizationTodos.php b/app/Console/Commands/ImportSystemizationTodos.php index ac3026a1..9579e276 100644 --- a/app/Console/Commands/ImportSystemizationTodos.php +++ b/app/Console/Commands/ImportSystemizationTodos.php @@ -11,30 +11,46 @@ class ImportSystemizationTodos extends Command protected $signature = 'systemization-todos:import {file : 待办 Excel 文件路径} {--company=1 : 写入的 company_id} - {--user= : 操作人用户 ID}'; + {--user= : 操作人用户 ID} + {--sync : 新增并更新现有来源事项,保留截图} + {--dry-run : 只预览同步结果,不写入数据库}'; - protected $description = '导入财务系统化待办清单'; + protected $description = '导入或同步财务系统化待办清单'; public function handle(SystemizationTodoWorkbookImporter $importer): int { try { - $result = $importer->import( - (string) $this->argument('file'), - max((int) $this->option('company'), 1), - $this->option('user') !== null ? (int) $this->option('user') : null, - ); + $file = (string) $this->argument('file'); + $companyId = max((int) $this->option('company'), 1); + $userId = $this->option('user') !== null ? (int) $this->option('user') : null; + if ($this->option('sync')) { + $result = $importer->sync($file, $companyId, $userId, (bool) $this->option('dry-run')); + } else { + $result = $importer->import($file, $companyId, $userId); + } } catch (Throwable $exception) { $this->error($exception->getMessage()); return self::FAILURE; } - $this->info(sprintf( - '工作表 %s:新增 %d 条,跳过已存在 %d 条。', - $result['sheet'], - $result['created'], - $result['skipped'], - )); + if ($this->option('sync')) { + $this->info(sprintf( + '%s工作表 %s:新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。', + $this->option('dry-run') ? '同步预检:' : '同步完成:', + $result['sheet'], + $result['created'], + $result['updated'], + $result['retained'], + )); + } else { + $this->info(sprintf( + '工作表 %s:新增 %d 条,跳过已存在 %d 条。', + $result['sheet'], + $result['created'], + $result['skipped'], + )); + } return self::SUCCESS; } diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 4ebd4237..256d7621 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -239,7 +239,7 @@ class SystemizationTodos extends Controller ]); try { - $result = $importer->import( + $result = $importer->sync( $data['source']->getRealPath(), (int) company_id(), user_id(), @@ -251,10 +251,11 @@ class SystemizationTodos extends Controller } return $this->backToPage(sprintf( - '导入完成:%s,新增 %d 条,跳过已存在 %d 条。', + '同步完成:%s,新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。', $result['sheet'], $result['created'], - $result['skipped'], + $result['updated'], + $result['retained'], )); } diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index 345c744c..656d300f 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -177,11 +177,11 @@
- 从 Excel 导入现有待办(只新增,不覆盖系统内已有记录) + 从 Excel 同步待办(新增并更新工作内容,保留完成截图)
@csrf - +
@@ -311,6 +311,15 @@ $lastSectionLabel = $items->isNotEmpty() ? ($sectionLabels[$items->count() - 1] ?? '') : ''; @endphp +
+ 共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。 + +
+
@@ -409,15 +418,6 @@
-
- 共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。 - -
-
@csrf diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index 1665c389..5f3d471a 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -25,13 +25,13 @@ class SystemizationTodosTest extends FeatureTestCase ->assertOk() ->assertSeeText('财务系统化待办') ->assertSeeText('新增待办') - ->assertSeeText('从 Excel 导入现有待办') + ->assertSeeText('从 Excel 同步待办') ->assertSeeText('总负责人') ->assertSeeText('责任人') ->assertSeeText('已逾期') ->assertSeeText('开始时间') ->assertSeeText('完成时间') - ->assertSeeText('在列表底部新增一条') + ->assertSeeText('新增一条') ->assertSee('data-sys-todo-create', false) ->assertSee('data-sys-todo-owner-filter', false) ->assertSee('data-sys-todo-assignee-filter', false) @@ -293,6 +293,71 @@ class SystemizationTodosTest extends FeatureTestCase ->assertDontSeeText('上一页'); } + public function test_excel_sync_updates_changed_rows_and_preserves_attachments(): void + { + Storage::fake('uploads'); + $this->loginAs(); + + $initialPath = sys_get_temp_dir() . '/systemization-sync-initial-' . uniqid() . '.xlsx'; + $updatedPath = sys_get_temp_dir() . '/systemization-sync-updated-' . uniqid() . '.xlsx'; + + try { + $this->writeWorkbook($initialPath, [ + ['收入', '顾家、索菲亚过账', '系统搭建', '常仪', '晨曦', '2026-11-20', '2026-11-22', null, '原备注'], + ]); + app(SystemizationTodoWorkbookImporter::class) + ->import($initialPath, (int) company_id(), user_id()); + + $todo = SystemizationTodo::query()->firstOrFail(); + Storage::disk('uploads')->put('systemization-todos/test.png', 'image'); + SystemizationTodoAttachment::query()->create([ + 'company_id' => (int) company_id(), + 'systemization_todo_id' => $todo->id, + 'path' => 'systemization-todos/test.png', + 'original_name' => 'existing.png', + 'mime_type' => 'image/png', + 'size' => 5, + 'uploaded_by' => user_id(), + ]); + + $this->writeWorkbook($updatedPath, [ + ['收入', '顾家、索菲亚过账', '系统搭建,系统搭建好之后交接给林冲', '晨曦', '蕙兰', '2026-11-23', '2026-11-25', '转接', null], + [null, null, '新增系统交接说明', '晨曦', '蕙兰', '2026-10-26', '2026-10-27', null, null], + ]); + + $preview = app(SystemizationTodoWorkbookImporter::class) + ->sync($updatedPath, (int) company_id(), user_id(), true); + $this->assertSame(1, $preview['created']); + $this->assertSame(1, $preview['updated']); + $this->assertSame(0, $preview['retained']); + $this->assertSame(1, SystemizationTodo::query()->count()); + + $result = app(SystemizationTodoWorkbookImporter::class) + ->sync($updatedPath, (int) company_id(), user_id()); + $this->assertSame(1, $result['created']); + $this->assertSame(1, $result['updated']); + $this->assertSame(0, $result['retained']); + + $todo->refresh(); + $this->assertSame('系统搭建,系统搭建好之后交接给林冲', $todo->title); + $this->assertSame('蕙兰', $todo->assignee); + $this->assertSame('2026-11-23', $todo->start_date?->format('Y-m-d')); + $this->assertSame('2026-11-25', $todo->due_date?->format('Y-m-d')); + $this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status); + $this->assertSame(1, $todo->attachments()->count()); + $this->assertTrue(SystemizationTodo::query()->where('title', '新增系统交接说明')->exists()); + + $secondResult = app(SystemizationTodoWorkbookImporter::class) + ->sync($updatedPath, (int) company_id(), user_id()); + $this->assertSame(0, $secondResult['created']); + $this->assertSame(0, $secondResult['updated']); + $this->assertSame(2, SystemizationTodo::query()->count()); + } finally { + @unlink($initialPath); + @unlink($updatedPath); + } + } + private function createTodo( string $category, string $section, @@ -318,6 +383,18 @@ class SystemizationTodosTest extends FeatureTestCase ]); } + private function writeWorkbook(string $path, array $rows): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([ + ['类型', '内容', '待办事项(具体工作计划)', '总负责人', '责任人', '开始时间', '完成时间', '完成情况(放截图)', '备注'], + ...$rows, + ]); + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + } + private function pngUpload(string $name): UploadedFile { return UploadedFile::fake()->createWithContent( -- 2.47.2