CaiWuXiTong/app/Business/SystemizationTodoWorkbookImporter.php

694 lines
25 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Business;
use App\Models\Business\SystemizationTodo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
class SystemizationTodoWorkbookImporter
{
private const REQUIRED_HEADERS = [
'category' => '类型',
'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<int,int> $columns
* @return array<int,array{
* row:int,
* title_index:int,
* title_count:int,
* source_title:string,
* 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;
}
$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<int,string>
*/
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<int,array{
* row:int,
* title_index:int,
* title_count:int,
* source_title:string,
* 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) {
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<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
*/
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<int,mixed> $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;
}
}