同步最新系统化待办工作簿 #583

Merged
yueyang merged 2 commits from feature/yueyang-systemization-todo-workbook-sync into main 2026-10-26 11:44:04 +08:00
5 changed files with 510 additions and 29 deletions

View File

@ -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<int,int> $columns
* @return array<int,array{row:int,category:string,section:string,title:string,data:array<string,mixed>}>
*/
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<int,array{row:int,category:string,section:string,title:string,data:array<string,mixed>}> $rows
* @return array{0:array<int,int>,1:array<int,int>}
*/
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<string,mixed> $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<string,int>}|null
*/

View File

@ -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;
}

View File

@ -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'],
));
}

View File

@ -177,11 +177,11 @@
</div>
<details class="sys-todo-import">
<summary>从 Excel 导入现有待办(只新增,不覆盖系统内已有记录)</summary>
<summary>从 Excel 同步待办(新增并更新工作内容,保留完成截图)</summary>
<form method="POST" action="{{ $todoRoutes['import'] }}" enctype="multipart/form-data">
@csrf
<label>待办 Excel<input type="file" name="source" accept=".xlsx" required></label>
<button type="submit" class="sys-todo-page__button">导入</button>
<button type="submit" class="sys-todo-page__button">同步</button>
</form>
</details>
@ -311,6 +311,15 @@
$lastSectionLabel = $items->isNotEmpty() ? ($sectionLabels[$items->count() - 1] ?? '') : '';
@endphp
<div class="sys-todo-table__add">
<span>共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。</span>
<button type="button" data-sys-todo-create
data-sys-todo-default-category="{{ $lastItem?->category ?? '' }}"
data-sys-todo-default-section="{{ $lastSectionLabel }}">
<span class="material-icons-outlined" aria-hidden="true">playlist_add</span>新增一条
</button>
</div>
<div class="sys-todo-table-wrap" tabindex="0" aria-label="待办列表,可横向滚动查看全部内容" data-sys-todo-table-wrap>
<table class="sys-todo-table" aria-label="财务系统化待办清单">
<thead>
@ -409,15 +418,6 @@
<div class="sys-todo-scrollrail__spacer" data-sys-todo-scrollrail-spacer></div>
</div>
<div class="sys-todo-table__add">
<span>共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。</span>
<button type="button" data-sys-todo-create
data-sys-todo-default-category="{{ $lastItem?->category ?? '' }}"
data-sys-todo-default-section="{{ $lastSectionLabel }}">
<span class="material-icons-outlined" aria-hidden="true">playlist_add</span>在列表底部新增一条
</button>
</div>
<dialog class="sys-todo-dialog" id="sysTodoDialog" aria-labelledby="sysTodoDialogTitle">
<form method="POST" enctype="multipart/form-data" data-sys-todo-form>
@csrf

View File

@ -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(