'类型', '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']; $created = 0; $skipped = 0; DB::transaction(function () use ( $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]) ); $exists = SystemizationTodo::withoutGlobalScopes() ->where('company_id', $companyId) ->where('source_key', $sourceKey) ->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([ '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, ]); $created++; } }); return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName]; } throw new RuntimeException( '未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。' ); } /** * @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; } }