{"record":{"id":"d5c18a7fff553327","repo":"Leantime/leantime","slug":"task-has-timesheets-attached-delete-all-timesheet","errorCode":null,"errorMessage":"Task has timesheets attached, delete all timesheets first or consider archiving the task","messagePattern":"Task has timesheets attached, delete all timesheets first or consider archiving the task","errorType":"exception","errorClass":"Exception","httpStatus":null,"severity":"error","filePath":"app/Domain/Tickets/Services/Tickets.php","lineNumber":3717,"sourceCode":"            return true;\n        }\n\n        return false;\n    }\n\n    public function canDelete($id)\n    {\n\n        $ticket = $this->getTicket($id);\n\n        if (empty($ticket)) {\n            throw new \\Exception('Task does not exist');\n        }\n\n        $hasLoggedHours = $this->timesheetsRepo->getTimesheetsByTicket($id);\n\n        if ($hasLoggedHours) {\n            throw new \\Exception('Task has timesheets attached, delete all timesheets first or consider archiving the task');\n        }\n\n        return true;\n\n    }\n\n    /**\n     * @return bool|string[]\n     *\n     * @throws BindingResolutionException\n     *\n     * @api\n     */\n    #[RequiresPermission(TicketsPermissions::DELETE, entityScoped: true)]\n    public function deleteMilestone($id): array|bool\n    {\n\n        $ticket = $this->getTicket($id);","sourceCodeStart":3699,"sourceCodeEnd":3735,"githubUrl":"https://github.com/Leantime/leantime/blob/9a9f49f1008f4782b30f6723c54228f4f992e636/app/Domain/Tickets/Services/Tickets.php#L3699-L3735","documentation":"The second guard in Tickets::canDelete($id): the ticket exists, but timesheetsRepo->getTimesheetsByTicket($id) returns rows, so deleting would orphan tracked time in zp_timesheets. Leantime blocks the delete and instructs: remove all logged hours first, or archive the task instead. This protects reporting/invoicing integrity - archived tasks keep their history, deleted ones do not.","triggerScenarios":"Deleting any task with at least one timesheet row via /tickets/delTicket/{id} or an RPC flow that calls canDelete first: cleaning up subtasks that received tracked work, deleting finished tasks at project close, bulk-cleanup scripts that assume empty tasks.","commonSituations":"End-of-project cleanup where tasks carry historical logged hours; users unaware that archive is the intended path for worked-on tasks; mistakenly logged hours on a task someone then wants gone.","solutions":["Archive the task instead of deleting it - this is the intended path and preserves the time history.","Delete the timesheet entries first (Timesheets UI / the row set in zp_timesheets WHERE ticket_id = {id}), then retry the delete.","If the hours are simply wrong, edit/correct them via the timesheet functionality rather than destroying the task.","As a last resort with a DB backup in hand: DELETE FROM zp_timesheets WHERE ticket_id = {id}, then delete the task."],"exampleFix":"// before: plain delete attempt on a task with logged time\n$this->ticketService->canDelete($id);        // throws 'Task has timesheets attached...'\n$this->ticketService->delete($id);\n\n// after: archive when time is logged, delete only when clean\nif (! empty($this->timesheetsRepo->getTimesheetsByTicket($id))) {\n    $this->ticketService->updateTicket(['id' => $id, 'tags' => 'archived']); // or set an archived-type status\n    return redirect()->back()->with('message', 'Task archived because timesheets exist');\n}\n$this->ticketService->canDelete($id);\n$this->ticketService->delete($id);","handlingStrategy":"validation","validationCode":"if (! empty($timesheetsRepo->getTimesheetsByTicket($ticketId))) {\n    // time is booked: archive instead of delete, preserving reporting history\n    $ticketService->updateTicket(['id' => $ticketId, 'editorType' => 'task', 'tags' => 'archived']);\n    return redirect()->back()->with('message', 'Task archived because timesheets are attached');\n}\n$ticketService->canDelete($ticketId);","typeGuard":"/** True when the ticket can be safely deleted (exists and has no logged hours). */\nfunction isTicketDeletable(\\Leantime\\Domain\\Tickets\\Services\\Tickets $tickets, mixed $id): bool\n{\n    try {\n        return $tickets->canDelete($id) === true;\n    } catch (\\Exception) {\n        return false;\n    }\n}","tryCatchPattern":"try {\n    $ticketService->canDelete($id);\n    $ticketService->delete($id);\n} catch (\\Exception $e) {\n    if (str_contains($e->getMessage(), 'timesheets attached')) {\n        return back()->with('message', 'This task has logged hours - it was archived instead of deleted.');\n        // archive flow: $ticketService->updateTicket(['id' => $id, ...archived status...]);\n    }\n    throw $e;\n}","preventionTips":["Train users to archive (not delete) tasks that carry tracked time.","Check getTimesheetsByTicket($id) before offering the delete action in custom UIs.","Correct wrongly logged hours via the timesheet editor instead of deleting the task.","Before bulk deletes, pre-filter with a query for tickets without zp_timesheets rows."],"tags":["tickets","timesheets","delete","referential-integrity"],"backgroundTag":"referential-integrity-delete-blocked","analyzedSha":"9a9f49f1008f4782b30f6723c54228f4f992e636","analyzedAt":"2026-08-21T02:37:38.966Z","schemaVersion":2},"datasetVersion":"2026-08-21T03:17:12.404Z"}