diff --git a/ChangeLog.md b/ChangeLog.md index 4c79d5c..9647e6c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,11 @@ # CHANGELOG MODULE TIMESHEETWEEK FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) +## 1.4.1 +- Met à jour automatiquement la durée effective des tâches après validation d'une feuille en se basant sur les temps consommés. + / Automatically updates tasks' effective duration after approving a sheet based on consumed times. +- Réajuste la durée effective des tâches lors du retour d'une feuille approuvée en brouillon pour refléter les temps restants. + / Readjusts tasks' effective duration when reverting an approved sheet to draft to reflect remaining times. + ## 1.4.0 - Ajoute un interrupteur d'administration pour activer le sélecteur quart de jour des salariés au forfait jour. / Adds an admin switch to enable the quarter-day selector for daily-rate employees. - Affiche les durées forfait jour (0.25, 0.5 et 1 jour) dans les PDF standard et de synthèse lorsque le quart de jour est actif. / Displays the daily-rate durations (0.25, 0.5 and 1 day) inside the standard and summary PDFs whenever quarter-day mode is enabled. diff --git a/class/timesheetweek.class.php b/class/timesheetweek.class.php index 57b2c88..a717276 100644 --- a/class/timesheetweek.class.php +++ b/class/timesheetweek.class.php @@ -781,53 +781,79 @@ public function submit($user) return 1; } - /** - * Revert to draft - * @param User $user - * @return int - */ - public function revertToDraft($user) - { - $now = dol_now(); - - if ((int) $this->status === self::STATUS_DRAFT) { - $this->error = 'AlreadyDraft'; - return 0; - } - - $this->db->begin(); - - $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element. - " SET status=".(int) self::STATUS_DRAFT.", tms='".$this->db->idate($now)."', date_validation=NULL WHERE rowid=".(int) $this->id; - // EN: Enforce the entity scope while reverting to draft. - // FR: Impose la portée d'entité lors du retour en brouillon. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; - if (!$this->db->query($sql)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - - // Remove synced time entries to keep the ERP aligned (EN) - // Supprime les temps synchronisés pour garder l'ERP aligné (FR) - if ($this->deleteElementTimeRecords() < 0) { - $this->db->rollback(); - return -1; - } - - $this->status = self::STATUS_DRAFT; - $this->tms = $now; - $this->date_validation = null; - - if (!$this->createAgendaEvent($user, 'TSWK_REOPEN', 'TimesheetWeekAgendaReopened', array($this->ref))) { - $this->db->rollback(); - return -1; - } - - $this->db->commit(); - - return 1; - } + /** + * Revert to draft + * @param User $user + * @return int + */ + public function revertToDraft($user) + { + $now = dol_now(); + $previousStatus = (int) $this->status; + + if ((int) $this->status === self::STATUS_DRAFT) { + $this->error = 'AlreadyDraft'; + return 0; + } + + $taskIds = array(); + $lines = $this->getLines(); + if (!empty($lines)) { + foreach ($lines as $line) { + if (empty($line->fk_task)) { + continue; + } + + // EN: Track tasks with recorded hours to refresh effective durations after rollback. + // FR: Suit les tâches ayant des heures saisies pour rafraîchir les durées effectives après retour en brouillon. + if (((float) $line->hours) > 0) { + $taskIds[] = (int) $line->fk_task; + } + } + } + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element. + " SET status=".(int) self::STATUS_DRAFT.", tms='".$this->db->idate($now)."', date_validation=NULL WHERE rowid=".(int) $this->id; + // EN: Enforce the entity scope while reverting to draft. + // FR: Impose la portée d'entité lors du retour en brouillon. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + if (!$this->db->query($sql)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; + } + + // Remove synced time entries to keep the ERP aligned (EN) + // Supprime les temps synchronisés pour garder l'ERP aligné (FR) + if ($this->deleteElementTimeRecords() < 0) { + $this->db->rollback(); + return -1; + } + + if (!empty($taskIds) && ($previousStatus === self::STATUS_APPROVED || $previousStatus === self::STATUS_SEALED)) { + // EN: Recompute effective duration on related tasks after removing approved times. + // FR: Recalcule la durée effective des tâches concernées après suppression des temps approuvés. + if ($this->updateTasksDurationEffective($taskIds) < 0) { + $this->db->rollback(); + return -1; + } + } + + $this->status = self::STATUS_DRAFT; + $this->tms = $now; + $this->date_validation = null; + + if (!$this->createAgendaEvent($user, 'TSWK_REOPEN', 'TimesheetWeekAgendaReopened', array($this->ref))) { + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + + return 1; + } /** * Approve @@ -976,92 +1002,168 @@ public function unseal($user) return 1; } - /** - * Synchronize element_time rows with the validated timesheet lines. - * EN: Inserts dedicated rows into llx_element_time when approving a sheet, removing stale ones first. - * FR: Insère les lignes dans llx_element_time lors de l'approbation, après avoir supprimé les enregistrements obsolètes. - * @return int - */ - protected function syncElementTimeRecords() - { - if (empty($this->id)) { - return 0; - } + /** + * Synchronize element_time rows with the validated timesheet lines. + * EN: Inserts dedicated rows into llx_element_time when approving a sheet, removing stale ones first. + * FR: Insère les lignes dans llx_element_time lors de l'approbation, après avoir supprimé les enregistrements obsolètes. + * @return int + */ + protected function syncElementTimeRecords() + { + if (empty($this->id)) { + return 0; + } - // Clean previous entries tied to this sheet to avoid duplicates (EN) - // Nettoie les entrées existantes liées à cette fiche pour éviter les doublons (FR) - if ($this->deleteElementTimeRecords() < 0) { - return -1; - } + // Clean previous entries tied to this sheet to avoid duplicates (EN) + // Nettoie les entrées existantes liées à cette fiche pour éviter les doublons (FR) + if ($this->deleteElementTimeRecords() < 0) { + return -1; + } - $lines = $this->getLines(); - if (empty($lines)) { - return 1; - } + $lines = $this->getLines(); + if (empty($lines)) { + return 1; + } - // Determine employee hourly rate for THM column (EN) - // Détermine le taux horaire salarié pour la colonne THM (FR) - $employeeThm = $this->resolveEmployeeThm(); + // Determine employee hourly rate for THM column (EN) + // Détermine le taux horaire salarié pour la colonne THM (FR) + $employeeThm = $this->resolveEmployeeThm(); - // Prepare multilingual helper for note composition (EN) - // Prépare l'assistant multilingue pour composer la note (FR) - global $langs; - if (is_object($langs)) { - $langs->load('timesheetweek@timesheetweek'); - } + // Prepare multilingual helper for note composition (EN) + // Prépare l'assistant multilingue pour composer la note (FR) + global $langs; + if (is_object($langs)) { + $langs->load('timesheetweek@timesheetweek'); + } - foreach ($lines as $line) { - $durationSeconds = (int) round(((float) $line->hours) * 3600); - if ($durationSeconds <= 0) { - continue; + $taskIds = array(); + + foreach ($lines as $line) { + $durationSeconds = (int) round(((float) $line->hours) * 3600); + if ($durationSeconds <= 0) { + continue; + } + + if (!empty($line->fk_task)) { + $taskIds[] = (int) $line->fk_task; + } + + $taskTimestamp = $this->normalizeLineDate($line->day_date); + $noteDateLabel = $taskTimestamp ? dol_print_date($taskTimestamp, '%d/%m/%Y') : dol_print_date(dol_now(), '%d/%m/%Y'); + // Try Dolibarr helper then fallback to module formatter (EN) + // Tente l'assistant Dolibarr puis bascule sur le formateur du module (FR) + if (function_exists('dol_print_duration')) { + $noteDurationLabel = dol_print_duration($durationSeconds, 'allhourmin'); + } else { + $noteDurationLabel = $this->formatDurationLabel($durationSeconds); + } + $noteDurationLabel = trim(str_replace(' ', ' ', $noteDurationLabel)); + if ($noteDurationLabel === '') { + $noteDurationValue = price2num($line->hours, 'MT'); + $noteDurationLabel = ($noteDurationValue !== '' ? number_format((float) $noteDurationValue, 2, '.', ' ') : '0.00').' h'; + } + + // Build the readable note with translations and fallback (EN) + // Construit la note lisible avec traductions et repli (FR) + if (is_object($langs)) { + $noteMessage = $langs->trans('TimesheetWeekElementTimeNote', (string) $this->ref, $noteDateLabel, $noteDurationLabel); + } else { + $noteMessage = 'Feuille de temps '.(string) $this->ref.' - '.$noteDateLabel.' - '.$noteDurationLabel; + } + + // Store a readable note for auditors and managers (EN) + // Enregistre une note lisible pour les contrôleurs et managers (FR) + $sql = "INSERT INTO ".MAIN_DB_PREFIX."element_time("; + $sql .= " fk_user, fk_element, elementtype, element_duration, element_date, thm, note, import_key"; + $sql .= ") VALUES ("; + $sql .= " ".(!empty($this->fk_user) ? (int) $this->fk_user : "NULL").","; + $sql .= " ".(!empty($line->fk_task) ? (int) $line->fk_task : "NULL").","; + $sql .= " 'task',"; + $sql .= " ".$durationSeconds.","; + $sql .= $taskTimestamp ? " '".$this->db->idate($taskTimestamp)."'," : " NULL,"; + $sql .= ($employeeThm !== null ? " ".$employeeThm : " NULL").","; + $sql .= " '".$this->db->escape($noteMessage)."',"; + $sql .= " '".$this->db->escape($this->buildElementTimeImportKey($line))."'"; + $sql .= ")"; + + if (!$this->db->query($sql)) { + $this->error = $this->db->lasterror(); + return -1; + } } - $taskTimestamp = $this->normalizeLineDate($line->day_date); - $noteDateLabel = $taskTimestamp ? dol_print_date($taskTimestamp, '%d/%m/%Y') : dol_print_date(dol_now(), '%d/%m/%Y'); - // Try Dolibarr helper then fallback to module formatter (EN) - // Tente l'assistant Dolibarr puis bascule sur le formateur du module (FR) - if (function_exists('dol_print_duration')) { - $noteDurationLabel = dol_print_duration($durationSeconds, 'allhourmin'); - } else { - $noteDurationLabel = $this->formatDurationLabel($durationSeconds); + if (!empty($taskIds)) { + $taskIds = array_unique($taskIds); + if ($this->updateTasksDurationEffective($taskIds) < 0) { + return -1; + } } - $noteDurationLabel = trim(str_replace(' ', ' ', $noteDurationLabel)); - if ($noteDurationLabel === '') { - $noteDurationValue = price2num($line->hours, 'MT'); - $noteDurationLabel = ($noteDurationValue !== '' ? number_format((float) $noteDurationValue, 2, '.', ' ') : '0.00').' h'; + + return 1; + } + + /** + * EN: Update project task effective durations based on aggregated element_time entries. + * FR: Met à jour les durées effectives des tâches projet à partir des enregistrements element_time agrégés. + * + * @param array $taskIds + * @return int + */ + protected function updateTasksDurationEffective($taskIds) + { + if (empty($taskIds)) { + return 1; } - // Build the readable note with translations and fallback (EN) - // Construit la note lisible avec traductions et repli (FR) - if (is_object($langs)) { - $noteMessage = $langs->trans('TimesheetWeekElementTimeNote', (string) $this->ref, $noteDateLabel, $noteDurationLabel); - } else { - $noteMessage = 'Feuille de temps '.(string) $this->ref.' - '.$noteDateLabel.' - '.$noteDurationLabel; + // EN: Prepare unique task identifiers list for querying. + // FR: Prépare la liste unique des identifiants de tâches pour la requête. + $cleanTaskIds = array_unique(array_map('intval', $taskIds)); + if (empty($cleanTaskIds)) { + return 1; } - // Store a readable note for auditors and managers (EN) - // Enregistre une note lisible pour les contrôleurs et managers (FR) - $sql = "INSERT INTO ".MAIN_DB_PREFIX."element_time("; - $sql .= " fk_user, fk_element, elementtype, element_duration, element_date, thm, note, import_key"; - $sql .= ") VALUES ("; - $sql .= " ".(!empty($this->fk_user) ? (int) $this->fk_user : "NULL").","; - $sql .= " ".(!empty($line->fk_task) ? (int) $line->fk_task : "NULL").","; - $sql .= " 'task',"; - $sql .= " ".$durationSeconds.","; - $sql .= $taskTimestamp ? " '".$this->db->idate($taskTimestamp)."'," : " NULL,"; - $sql .= ($employeeThm !== null ? " ".$employeeThm : " NULL").","; - $sql .= " '".$this->db->escape($noteMessage)."',"; - $sql .= " '".$this->db->escape($this->buildElementTimeImportKey($line))."'"; - $sql .= ")"; - - if (!$this->db->query($sql)) { + $idList = implode(',', $cleanTaskIds); + + $sql = "SELECT fk_element, SUM(element_duration) AS total_duration"; + $sql .= " FROM ".MAIN_DB_PREFIX."element_time"; + $sql .= " WHERE elementtype='task'"; + $sql .= " AND fk_element IN (".$idList.")"; + $sql .= " GROUP BY fk_element"; + + $resql = $this->db->query($sql); + if (!$resql) { $this->error = $this->db->lasterror(); return -1; } + + $taskDurations = array(); + foreach ($cleanTaskIds as $taskId) { + $taskDurations[$taskId] = 0; + } + + while ($obj = $this->db->fetch_object($resql)) { + $taskDurations[(int) $obj->fk_element] = (int) $obj->total_duration; + } + $this->db->free($resql); + + foreach ($taskDurations as $taskId => $durationSeconds) { + // EN: Propagate consolidated duration into the project task record. + // FR: Propage la durée consolidée dans l'enregistrement de la tâche projet. + $sqlUpdate = "UPDATE ".MAIN_DB_PREFIX."projet_task SET"; + $sqlUpdate .= " duration_effective=".(int) $durationSeconds; + $sqlUpdate .= " WHERE rowid=".(int) $taskId; + $sqlUpdate .= " AND entity IN (".getEntity('project').")"; + + if (!$this->db->query($sqlUpdate)) { + $this->error = $this->db->lasterror(); + return -1; + } + } + + return 1; } - return 1; - } + /** * Delete synced rows from llx_element_time for this sheet. diff --git a/core/modules/modTimesheetWeek.class.php b/core/modules/modTimesheetWeek.class.php index 4ab82ea..490f280 100644 --- a/core/modules/modTimesheetWeek.class.php +++ b/core/modules/modTimesheetWeek.class.php @@ -112,7 +112,7 @@ public function __construct($db) } // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' - $this->version = '1.4.0'; // EN: Enables PDF generation with document model management like native Dolibarr cards. + $this->version = '1.4.1'; // EN: Enables PDF generation with document model management like native Dolibarr cards. // FR: Active la génération PDF avec gestion des modèles de documents comme sur les fiches Dolibarr natives. // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt';