'类型', 'section' => '内容', 'title' => '待办事项', 'owner' => '总负责人', 'assignee' => '责任人', 'start' => '开始时间', 'finish' => '完成时间', 'progress' => '完成情况', 'notes' => '备注', ]; /** * 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。 * * @return array{created:int,skipped:int,sheet:string} */ public function import(string $path, int $companyId, ?int $userId = null): 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; } $header = $headerInfo['columns']; $headerRow = $headerInfo['row']; $rows = $this->workbookRows( $reader, $sheetIndex, $sheetName, $header, $headerRow, $companyId, ); $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', $row['data']['source_key']) ->exists(); if ($exists) { $skipped++; continue; } SystemizationTodo::withoutGlobalScopes()->create([ ...$row['data'], 'company_id' => $companyId, 'imported_at' => now(), 'created_by' => $userId, 'updated_by' => $userId, ]); $created++; } }); return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName]; } throw new RuntimeException( '未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。' ); } /** * 将新版工作簿同步到已有来源记录。保留截图和来源中已不在工作簿的事项,只新增和更新。 * * @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)) ->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) { $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, $retainedSortOrder, $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) { SystemizationTodo::withoutGlobalScopes() ->whereKey($todo->id) ->update([ 'sort_order' => $retainedSortOrder++, 'source_key' => $oldSourceKeys[$todo->id] ?? null, ]); } }); 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; } $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); $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); 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, '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; } /** * 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 { $matches = []; $usedIds = []; $candidates = []; foreach ($existing as $todo) { $candidates[$this->matchTitleKey($todo->title)][] = $todo; } 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] ?? [], 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['data']['sort_order']) <=> abs($b->sort_order - $row['data']['sort_order']) ); $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['data']['sort_order']) / 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 */ private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array { foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) { $normalized = []; foreach ($cells as $column => $value) { $value = $this->normalizeHeader($this->text($value)); if ($value !== '') { $normalized[$value] = $column; } } $mapped = []; foreach (self::REQUIRED_HEADERS as $field => $label) { $needle = $this->normalizeHeader($label); foreach ($normalized as $header => $column) { if ($header === $needle || str_starts_with($header, $needle)) { $mapped[$field] = $column; break; } } } if (isset($mapped['title']) && isset($mapped['owner']) && isset($mapped['assignee'])) { return ['row' => $rowNumber, 'columns' => $mapped]; } } return null; } private function normalizeHeader(string $value): string { return preg_replace('/[\s ()()]+/u', '', $value) ?? ''; } /** * @param array $cells */ private function cell(array $cells, ?int $column): string { return $column === null ? '' : $this->text($cells[$column] ?? null); } private function text(mixed $value): string { if ($value === null) { return ''; } if (is_bool($value)) { return $value ? '1' : '0'; } return trim(str_replace("\u{00A0}", ' ', (string) $value)); } /** * @return array{0:string|null,1:string} */ private function dateParts(string $value): array { $value = trim($value); if ($value === '' || $value === '已完成') { return [null, '']; } if (is_numeric($value)) { $serial = (float) $value; if ($serial >= 20000 && $serial <= 80000) { return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), '']; } } foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) { try { $date = Carbon::createFromFormat($format, $value); if ($date !== false) { return [$date->format('Y-m-d'), '']; } } catch (\Throwable) { // 不是标准日期,保留原值作为时间备注。 } } return [null, mb_substr($value, 0, 100)]; } private function status(string $progress, string $startNote, string $finishNote): string { if (str_contains($progress, '已完成')) { return SystemizationTodo::STATUS_COMPLETED; } if ($progress !== '') { return str_contains($progress, '延期') ? SystemizationTodo::STATUS_DELAYED : SystemizationTodo::STATUS_IN_PROGRESS; } if (str_contains($startNote, '延期') || str_contains($finishNote, '延期')) { return SystemizationTodo::STATUS_DELAYED; } return SystemizationTodo::STATUS_PENDING; } }