Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
334 changes: 218 additions & 116 deletions class/timesheetweek.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('&nbsp;', ' ', $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('&nbsp;', ' ', $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.
Expand Down
2 changes: 1 addition & 1 deletion core/modules/modTimesheetWeek.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down