同步最新系统化待办工作簿 #583
@ -139,6 +139,393 @@ class SystemizationTodoWorkbookImporter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新版工作簿同步到已有来源记录。保留截图和来源中已不在工作簿的事项,只新增和更新。
|
||||
*
|
||||
* @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))
|
||||
->values();
|
||||
$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,
|
||||
$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) {
|
||||
if (($oldSourceKeys[$todo->id] ?? null) !== null) {
|
||||
SystemizationTodo::withoutGlobalScopes()
|
||||
->whereKey($todo->id)
|
||||
->update(['source_key' => $oldSourceKeys[$todo->id]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'retained' => $retained->count(),
|
||||
'sheet' => $sheetName,
|
||||
];
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,int> $columns
|
||||
* @return array<int,array{row:int,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;
|
||||
}
|
||||
|
||||
$title = $this->cell($cells, $columns['title'] ?? null);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$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);
|
||||
|
||||
$rows[] = [
|
||||
'row' => $rowNumber,
|
||||
'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' => $rowNumber,
|
||||
'source_key' => hash(
|
||||
'sha256',
|
||||
implode('|', [
|
||||
$companyId,
|
||||
$sheetName,
|
||||
$rowNumber,
|
||||
$category,
|
||||
$section,
|
||||
$title,
|
||||
$owner,
|
||||
$assignee,
|
||||
])
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{row:int,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) {
|
||||
$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['row']) <=> abs($b->sort_order - $row['row'])
|
||||
);
|
||||
$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['row']) / 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
|
||||
*/
|
||||
|
||||
@ -11,30 +11,46 @@ class ImportSystemizationTodos extends Command
|
||||
protected $signature = 'systemization-todos:import
|
||||
{file : 待办 Excel 文件路径}
|
||||
{--company=1 : 写入的 company_id}
|
||||
{--user= : 操作人用户 ID}';
|
||||
{--user= : 操作人用户 ID}
|
||||
{--sync : 新增并更新现有来源事项,保留截图}
|
||||
{--dry-run : 只预览同步结果,不写入数据库}';
|
||||
|
||||
protected $description = '导入财务系统化待办清单';
|
||||
protected $description = '导入或同步财务系统化待办清单';
|
||||
|
||||
public function handle(SystemizationTodoWorkbookImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
max((int) $this->option('company'), 1),
|
||||
$this->option('user') !== null ? (int) $this->option('user') : null,
|
||||
);
|
||||
$file = (string) $this->argument('file');
|
||||
$companyId = max((int) $this->option('company'), 1);
|
||||
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
|
||||
if ($this->option('sync')) {
|
||||
$result = $importer->sync($file, $companyId, $userId, (bool) $this->option('dry-run'));
|
||||
} else {
|
||||
$result = $importer->import($file, $companyId, $userId);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
));
|
||||
if ($this->option('sync')) {
|
||||
$this->info(sprintf(
|
||||
'%s工作表 %s:新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。',
|
||||
$this->option('dry-run') ? '同步预检:' : '同步完成:',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['retained'],
|
||||
));
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@ -239,7 +239,7 @@ class SystemizationTodos extends Controller
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
$result = $importer->sync(
|
||||
$data['source']->getRealPath(),
|
||||
(int) company_id(),
|
||||
user_id(),
|
||||
@ -251,10 +251,11 @@ class SystemizationTodos extends Controller
|
||||
}
|
||||
|
||||
return $this->backToPage(sprintf(
|
||||
'导入完成:%s,新增 %d 条,跳过已存在 %d 条。',
|
||||
'同步完成:%s,新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
$result['updated'],
|
||||
$result['retained'],
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -177,11 +177,11 @@
|
||||
</div>
|
||||
|
||||
<details class="sys-todo-import">
|
||||
<summary>从 Excel 导入现有待办(只新增,不覆盖系统内已有记录)</summary>
|
||||
<summary>从 Excel 同步待办(新增并更新工作内容,保留完成截图)</summary>
|
||||
<form method="POST" action="{{ $todoRoutes['import'] }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<label>待办 Excel<input type="file" name="source" accept=".xlsx" required></label>
|
||||
<button type="submit" class="sys-todo-page__button">导入</button>
|
||||
<button type="submit" class="sys-todo-page__button">同步</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
@ -311,6 +311,15 @@
|
||||
$lastSectionLabel = $items->isNotEmpty() ? ($sectionLabels[$items->count() - 1] ?? '') : '';
|
||||
@endphp
|
||||
|
||||
<div class="sys-todo-table__add">
|
||||
<span>共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。</span>
|
||||
<button type="button" data-sys-todo-create
|
||||
data-sys-todo-default-category="{{ $lastItem?->category ?? '' }}"
|
||||
data-sys-todo-default-section="{{ $lastSectionLabel }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">playlist_add</span>新增一条
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sys-todo-table-wrap" tabindex="0" aria-label="待办列表,可横向滚动查看全部内容" data-sys-todo-table-wrap>
|
||||
<table class="sys-todo-table" aria-label="财务系统化待办清单">
|
||||
<thead>
|
||||
@ -409,15 +418,6 @@
|
||||
<div class="sys-todo-scrollrail__spacer" data-sys-todo-scrollrail-spacer></div>
|
||||
</div>
|
||||
|
||||
<div class="sys-todo-table__add">
|
||||
<span>共 {{ number_format($items->count()) }} 条,已全部显示。点击任意事项行可直接编辑。</span>
|
||||
<button type="button" data-sys-todo-create
|
||||
data-sys-todo-default-category="{{ $lastItem?->category ?? '' }}"
|
||||
data-sys-todo-default-section="{{ $lastSectionLabel }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">playlist_add</span>在列表底部新增一条
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dialog class="sys-todo-dialog" id="sysTodoDialog" aria-labelledby="sysTodoDialogTitle">
|
||||
<form method="POST" enctype="multipart/form-data" data-sys-todo-form>
|
||||
@csrf
|
||||
|
||||
@ -25,13 +25,13 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
->assertOk()
|
||||
->assertSeeText('财务系统化待办')
|
||||
->assertSeeText('新增待办')
|
||||
->assertSeeText('从 Excel 导入现有待办')
|
||||
->assertSeeText('从 Excel 同步待办')
|
||||
->assertSeeText('总负责人')
|
||||
->assertSeeText('责任人')
|
||||
->assertSeeText('已逾期')
|
||||
->assertSeeText('开始时间')
|
||||
->assertSeeText('完成时间')
|
||||
->assertSeeText('在列表底部新增一条')
|
||||
->assertSeeText('新增一条')
|
||||
->assertSee('data-sys-todo-create', false)
|
||||
->assertSee('data-sys-todo-owner-filter', false)
|
||||
->assertSee('data-sys-todo-assignee-filter', false)
|
||||
@ -293,6 +293,71 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
->assertDontSeeText('上一页');
|
||||
}
|
||||
|
||||
public function test_excel_sync_updates_changed_rows_and_preserves_attachments(): void
|
||||
{
|
||||
Storage::fake('uploads');
|
||||
$this->loginAs();
|
||||
|
||||
$initialPath = sys_get_temp_dir() . '/systemization-sync-initial-' . uniqid() . '.xlsx';
|
||||
$updatedPath = sys_get_temp_dir() . '/systemization-sync-updated-' . uniqid() . '.xlsx';
|
||||
|
||||
try {
|
||||
$this->writeWorkbook($initialPath, [
|
||||
['收入', '顾家、索菲亚过账', '系统搭建', '常仪', '晨曦', '2026-11-20', '2026-11-22', null, '原备注'],
|
||||
]);
|
||||
app(SystemizationTodoWorkbookImporter::class)
|
||||
->import($initialPath, (int) company_id(), user_id());
|
||||
|
||||
$todo = SystemizationTodo::query()->firstOrFail();
|
||||
Storage::disk('uploads')->put('systemization-todos/test.png', 'image');
|
||||
SystemizationTodoAttachment::query()->create([
|
||||
'company_id' => (int) company_id(),
|
||||
'systemization_todo_id' => $todo->id,
|
||||
'path' => 'systemization-todos/test.png',
|
||||
'original_name' => 'existing.png',
|
||||
'mime_type' => 'image/png',
|
||||
'size' => 5,
|
||||
'uploaded_by' => user_id(),
|
||||
]);
|
||||
|
||||
$this->writeWorkbook($updatedPath, [
|
||||
['收入', '顾家、索菲亚过账', '系统搭建,系统搭建好之后交接给林冲', '晨曦', '蕙兰', '2026-11-23', '2026-11-25', '转接', null],
|
||||
[null, null, '新增系统交接说明', '晨曦', '蕙兰', '2026-10-26', '2026-10-27', null, null],
|
||||
]);
|
||||
|
||||
$preview = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($updatedPath, (int) company_id(), user_id(), true);
|
||||
$this->assertSame(1, $preview['created']);
|
||||
$this->assertSame(1, $preview['updated']);
|
||||
$this->assertSame(0, $preview['retained']);
|
||||
$this->assertSame(1, SystemizationTodo::query()->count());
|
||||
|
||||
$result = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($updatedPath, (int) company_id(), user_id());
|
||||
$this->assertSame(1, $result['created']);
|
||||
$this->assertSame(1, $result['updated']);
|
||||
$this->assertSame(0, $result['retained']);
|
||||
|
||||
$todo->refresh();
|
||||
$this->assertSame('系统搭建,系统搭建好之后交接给林冲', $todo->title);
|
||||
$this->assertSame('蕙兰', $todo->assignee);
|
||||
$this->assertSame('2026-11-23', $todo->start_date?->format('Y-m-d'));
|
||||
$this->assertSame('2026-11-25', $todo->due_date?->format('Y-m-d'));
|
||||
$this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status);
|
||||
$this->assertSame(1, $todo->attachments()->count());
|
||||
$this->assertTrue(SystemizationTodo::query()->where('title', '新增系统交接说明')->exists());
|
||||
|
||||
$secondResult = app(SystemizationTodoWorkbookImporter::class)
|
||||
->sync($updatedPath, (int) company_id(), user_id());
|
||||
$this->assertSame(0, $secondResult['created']);
|
||||
$this->assertSame(0, $secondResult['updated']);
|
||||
$this->assertSame(2, SystemizationTodo::query()->count());
|
||||
} finally {
|
||||
@unlink($initialPath);
|
||||
@unlink($updatedPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function createTodo(
|
||||
string $category,
|
||||
string $section,
|
||||
@ -318,6 +383,18 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function writeWorkbook(string $path, array $rows): void
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->fromArray([
|
||||
['类型', '内容', '待办事项(具体工作计划)', '总负责人', '责任人', '开始时间', '完成时间', '完成情况(放截图)', '备注'],
|
||||
...$rows,
|
||||
]);
|
||||
(new Xlsx($spreadsheet))->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
|
||||
private function pngUpload(string $name): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->createWithContent(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user