diff --git a/app/Console/Commands/Correction/CorrectionDeleteTimeEntriesWithEndBeforeStartCommand.php b/app/Console/Commands/Correction/CorrectionDeleteTimeEntriesWithEndBeforeStartCommand.php new file mode 100644 index 00000000..adc93823 --- /dev/null +++ b/app/Console/Commands/Correction/CorrectionDeleteTimeEntriesWithEndBeforeStartCommand.php @@ -0,0 +1,62 @@ +comment('Deletes all time entries where the end is before the start...'); + $dryRun = (bool) $this->option('dry-run'); + if ($dryRun) { + $this->comment('Running in dry-run mode. Nothing will be saved to the database.'); + } + + $timeEntries = TimeEntry::query() + ->whereNotNull('end') + ->whereColumn('end', '<', 'start') + ->with(['project', 'task']) + ->get(); + foreach ($timeEntries as $timeEntry) { + /** @var TimeEntry $timeEntry */ + if (! $dryRun) { + // Delete via Eloquent, so that the deletion is audited + $timeEntry->delete(); + if ($timeEntry->project !== null) { + RecalculateSpentTimeForProject::dispatch($timeEntry->project); + } + if ($timeEntry->task !== null) { + RecalculateSpentTimeForTask::dispatch($timeEntry->task); + } + } + $this->line('Deleted time entry (id='.$timeEntry->getKey().', start='.$timeEntry->start->toIso8601ZuluString().', end='.$timeEntry->end?->toIso8601ZuluString().')'); + } + + return self::SUCCESS; + } +} diff --git a/tests/Unit/Console/Commands/Correction/CorrectionDeleteTimeEntriesWithEndBeforeStartCommandTest.php b/tests/Unit/Console/Commands/Correction/CorrectionDeleteTimeEntriesWithEndBeforeStartCommandTest.php new file mode 100644 index 00000000..030094ed --- /dev/null +++ b/tests/Unit/Console/Commands/Correction/CorrectionDeleteTimeEntriesWithEndBeforeStartCommandTest.php @@ -0,0 +1,88 @@ +create(); + $member = Member::factory()->forOrganization($organization)->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $task = Task::factory()->forOrganization($organization)->forProject($project)->create(); + $invalidTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->forProject($project)->forTask($task)->create([ + 'start' => Carbon::parse('2026-08-01T03:00:00Z'), + 'end' => Carbon::parse('2026-08-01T02:59:55Z'), + ]); + $validTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create([ + 'start' => Carbon::parse('2026-08-01T04:00:00Z'), + 'end' => Carbon::parse('2026-08-01T04:00:00Z'), + ]); + $runningTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->active()->create(); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:delete-time-entries-with-end-before-start'); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertSame("Deletes all time entries where the end is before the start...\n". + 'Deleted time entry (id='.$invalidTimeEntry->getKey().", start=2026-08-01T03:00:00Z, end=2026-08-01T02:59:55Z)\n", $output); + $this->assertDatabaseMissing(TimeEntry::class, ['id' => $invalidTimeEntry->getKey()]); + $this->assertDatabaseHas(TimeEntry::class, ['id' => $validTimeEntry->getKey()]); + $this->assertDatabaseHas(TimeEntry::class, ['id' => $runningTimeEntry->getKey()]); + $this->assertSame(1, Audit::query() + ->where('auditable_type', $invalidTimeEntry->getMorphClass()) + ->where('auditable_id', $invalidTimeEntry->getKey()) + ->where('event', 'deleted') + ->count()); + Queue::assertPushed(RecalculateSpentTimeForProject::class, 1); + Queue::assertPushed(RecalculateSpentTimeForTask::class, 1); + } + + public function test_does_not_delete_time_entries_in_dry_run(): void + { + // Arrange + $organization = Organization::factory()->create(); + $member = Member::factory()->forOrganization($organization)->create(); + $invalidTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create([ + 'start' => Carbon::parse('2026-08-01T03:00:00Z'), + 'end' => Carbon::parse('2026-08-01T02:59:55Z'), + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:delete-time-entries-with-end-before-start --dry-run'); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertSame("Deletes all time entries where the end is before the start...\n". + "Running in dry-run mode. Nothing will be saved to the database.\n". + 'Deleted time entry (id='.$invalidTimeEntry->getKey().", start=2026-08-01T03:00:00Z, end=2026-08-01T02:59:55Z)\n", $output); + $this->assertDatabaseHas(TimeEntry::class, ['id' => $invalidTimeEntry->getKey()]); + } +}