694 lines
25 KiB
PHP
694 lines
25 KiB
PHP
<?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;
|
||
}
|
||
}
|