250 lines
9.2 KiB
PHP
250 lines
9.2 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'];
|
||
|
||
$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<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;
|
||
}
|
||
}
|