diff --git a/ChangeLog.md b/ChangeLog.md index cc390c6..8cbd24b 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,9 +1,12 @@ # CHANGELOG MODULE TIMESHEETWEEK FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) +## 1.5.0 (01/12/2025) +- Ajoute une action de masse pour générer un PDF unique fusionnant les feuilles sélectionnées en utilisant le modèle par défaut. / Adds a mass action to generate a single merged PDF for selected timesheets using the default template. + ## 1.4.2 (01/12/2025) - Corrige le masquage par défaut des tâches dont la progression est de 100% dans les feuilles. / Fixes the default hiding of tasks with 100% progress in sheets. - + ## 1.4.1 (26/11/2025) - 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. diff --git a/core/modules/modTimesheetWeek.class.php b/core/modules/modTimesheetWeek.class.php index 1ea5b04..c11dd0c 100644 --- a/core/modules/modTimesheetWeek.class.php +++ b/core/modules/modTimesheetWeek.class.php @@ -112,8 +112,8 @@ 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.2'; - + $this->version = '1.5.0'; + // Url to the file with your last numberversion of this module $this->url_last_version = 'https://moduleversion.lesmetiersdubatiment.fr/ver.php?m=timesheetweek'; diff --git a/langs/en_US/timesheetweek.lang b/langs/en_US/timesheetweek.lang index 1762c7b..abe3a41 100644 --- a/langs/en_US/timesheetweek.lang +++ b/langs/en_US/timesheetweek.lang @@ -130,6 +130,13 @@ TimesheetWeekMassSealSuccess = %s timesheet(s) sealed. TimesheetWeekMassActionErrors = Unable to process: %s. TimesheetWeekMassDeleteOnlyDraft = Only draft timesheets can be deleted in bulk. GenerateSummaryPdf = Generate summary PDF +TimesheetWeekMassMergePdf = Generate merged PDF for selected timesheets +TimesheetWeekMassMergeForbidden = You are not allowed to access timesheet %s +TimesheetWeekMassMergeGenerationFailed = Failed to generate PDF for %s +TimesheetWeekMassMergeMissingFile = Unable to find the generated PDF for %s +TimesheetWeekMassMergeNoEligible = No PDF could be generated from the selected timesheets +TimesheetWeekMassMergeError = Failed to merge the selected timesheets into a single PDF +TimesheetWeekMassMergeSuccess = Merged PDF generated successfully TimesheetWeekSummaryNoSelection = Please select at least one timesheet to create the summary. TimesheetWeekSummaryUnauthorizedSheet = Some selected timesheets were ignored because you are not allowed to read them. TimesheetWeekSummaryMissingUser = The employee linked to a selected timesheet no longer exists. diff --git a/langs/fr_FR/timesheetweek.lang b/langs/fr_FR/timesheetweek.lang index c9bd0f9..e0b0a9b 100644 --- a/langs/fr_FR/timesheetweek.lang +++ b/langs/fr_FR/timesheetweek.lang @@ -129,6 +129,13 @@ TimesheetWeekMassSealSuccess = %s feuille(s) de temps scellée(s). TimesheetWeekMassActionErrors = Impossible de traiter : %s. TimesheetWeekMassDeleteOnlyDraft = Seules les feuilles en brouillon peuvent être supprimées en masse. GenerateSummaryPdf = Générer le PDF de synthèse +TimesheetWeekMassMergePdf = Générer un PDF fusionné des feuilles sélectionnées +TimesheetWeekMassMergeForbidden = Vous n'avez pas l'autorisation d'accéder à la feuille %s +TimesheetWeekMassMergeGenerationFailed = Impossible de générer le PDF pour %s +TimesheetWeekMassMergeMissingFile = Impossible de trouver le PDF généré pour %s +TimesheetWeekMassMergeNoEligible = Aucun PDF n'a pu être généré depuis les feuilles sélectionnées +TimesheetWeekMassMergeError = Échec de la fusion des feuilles sélectionnées en un seul PDF +TimesheetWeekMassMergeSuccess = PDF fusionné généré avec succès TimesheetWeekSummaryNoSelection = Merci de sélectionner au moins une feuille de temps pour créer la synthèse. TimesheetWeekSummaryUnauthorizedSheet = Certaines feuilles sélectionnées ont été ignorées faute d'autorisation de lecture. TimesheetWeekSummaryMissingUser = Le salarié lié à une feuille sélectionnée n'existe plus. diff --git a/lib/timesheetweek_pdf.lib.php b/lib/timesheetweek_pdf.lib.php index 5ab7196..df41bc6 100644 --- a/lib/timesheetweek_pdf.lib.php +++ b/lib/timesheetweek_pdf.lib.php @@ -33,6 +33,68 @@ dol_include_once('/timesheetweek/class/timesheetweek.class.php'); defined('TIMESHEETWEEK_PDF_SUMMARY_SUBDIR') || define('TIMESHEETWEEK_PDF_SUMMARY_SUBDIR', 'summaries'); +defined('TIMESHEETWEEK_PDF_MERGED_SUBDIR') || define('TIMESHEETWEEK_PDF_MERGED_SUBDIR', 'merged'); + +/** + * EN: Generate the PDF of a single timesheet in the standard directory and copy it to the provided temporary folder. + * FR: Génère le PDF d'une feuille dans le répertoire standard et le copie vers le dossier temporaire fourni. + * + * @param TimesheetWeek $sheet Timesheet to render / Feuille à rendre + * @param Conf $conf Dolibarr global configuration / Configuration globale Dolibarr + * @param Translate $langs Translation handler / Gestionnaire de traductions + * @param string $modelId PDF model identifier / Identifiant du modèle PDF + * @param string $tempDir Destination temporary directory / Répertoire temporaire cible + * @return array{success:bool,path?:string,errors?:string[]} + */ +function tw_generate_timesheet_pdf_to_temp(TimesheetWeek $sheet, Conf $conf, Translate $langs, $modelId, $tempDir) +{ + // EN: Ensure the temporary directory exists before writing inside it. + // FR: S'assure que le répertoire temporaire existe avant d'y écrire. + if (dol_mkdir($tempDir) < 0) { + return array('success' => false, 'errors' => array($langs->trans('ErrorCanNotCreateDir', $tempDir))); + } + + $sheet->model_pdf = $modelId; + + // EN: Generate the PDF using the configured or provided model. + // FR: Génère le PDF en utilisant le modèle configuré ou fourni. + $generationResult = $sheet->generateDocument($modelId, $langs); + if ($generationResult <= 0) { + $errorMessage = !empty($sheet->errors) ? implode(', ', (array) $sheet->errors) : (!empty($sheet->error) ? $sheet->error : $langs->trans('TimesheetWeekMassMergeGenerationFailed', ($sheet->ref ?: '#'.$sheet->id))); + return array('success' => false, 'errors' => array($errorMessage)); + } + + // EN: Locate the freshly generated PDF inside the standard output directory. + // FR: Localise le PDF fraîchement généré dans le répertoire de sortie standard. + $entityId = !empty($sheet->entity) ? (int) $sheet->entity : (int) $conf->entity; + $baseOutput = !empty($conf->timesheetweek->multidir_output[$entityId] ?? null) + ? $conf->timesheetweek->multidir_output[$entityId] + : (!empty($conf->timesheetweek->dir_output) ? $conf->timesheetweek->dir_output : DOL_DATA_ROOT.'/timesheetweek'); + $relativePath = $sheet->last_main_doc; + if (empty($relativePath)) { + $cleanRef = dol_sanitizeFileName($sheet->ref ?: 'timesheetweek-'.$sheet->id); + $relativePath = $sheet->element.'/'.$cleanRef.'/'.$cleanRef.'.pdf'; + } + + $sourcePath = rtrim($baseOutput, '/').'/'.ltrim($relativePath, '/'); + if (!dol_is_file($sourcePath)) { + return array('success' => false, 'errors' => array($langs->trans('TimesheetWeekMassMergeMissingFile', ($sheet->ref ?: '#'.$sheet->id)))); + } + + // EN: Copy the generated PDF into the temporary directory to prepare the merge. + // FR: Copie le PDF généré dans le répertoire temporaire pour préparer la fusion. + $tempFilename = dol_sanitizeFileName(($sheet->ref ?: 'timesheetweek-'.$sheet->id).'-'.dol_print_date(dol_now(), 'dayhourlog').'.pdf'); + if ($tempFilename === '') { + $tempFilename = dol_sanitizeFileName('timesheetweek-'.$sheet->id.'.pdf'); + } + $tempPath = rtrim($tempDir, '/').'/'.$tempFilename; + + if (dol_copy($sourcePath, $tempPath, 0, 1) <= 0) { + return array('success' => false, 'errors' => array($langs->trans('ErrorFailToCreateFile'))); + } + + return array('success' => true, 'path' => $tempPath); +} /** * EN: Normalise a value before sending it to TCPDF by decoding HTML entities and applying the output charset. diff --git a/timesheetweek_list.php b/timesheetweek_list.php index 2429261..ff2c02f 100644 --- a/timesheetweek_list.php +++ b/timesheetweek_list.php @@ -396,6 +396,7 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs // FR: Autorise la génération d'un PDF de synthèse à tout utilisateur habilité à lire les feuilles listées. if ($permissiontoread) { $arrayofmassactions['generate_summary_pdf'] = img_picto('', 'pdf', 'class="pictofixedwidth"').$langs->trans('GenerateSummaryPdf'); + $arrayofmassactions['builddoc_merge_pdf'] = img_picto('', 'pdf', 'class="pictofixedwidth"').$langs->trans('TimesheetWeekMassMergePdf'); } // EN: Expose the draft-only bulk deletion with Dolibarr's confirmation flow when the operator may delete sheets. // FR: Expose la suppression massive limitée aux brouillons avec la confirmation Dolibarr lorsque l'opérateur peut supprimer des feuilles. @@ -600,6 +601,162 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs } } } +if ($massaction === 'builddoc_merge_pdf') { + $massActionProcessed = true; + if (!$permissiontoread) { + // EN: Block the merge when the operator cannot read the selected sheets. + // FR: Bloque la fusion lorsque l'opérateur ne peut pas lire les feuilles sélectionnées. + setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors'); + } elseif (empty($arrayofselected)) { + // EN: Inform the user that a selection is required before launching the merge. + // FR: Informe l'utilisateur qu'une sélection est nécessaire avant de lancer la fusion. + setEventMessages($langs->trans('TimesheetWeekErrorNoSelection'), null, 'errors'); + } else { + dol_include_once('/timesheetweek/lib/timesheetweek_pdf.lib.php'); + $defaultModel = getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', 'standard_timesheetweek'); + $tempDir = !empty($conf->timesheetweek->dir_temp) ? $conf->timesheetweek->dir_temp : (rtrim($uploaddir, '/').'/temp'); + $generatedFiles = array(); + $errors = array(); + $warnings = array(); + + foreach ((array) $arrayofselected as $id) { + $id = (int) $id; + if ($id <= 0) { + continue; + } + + $sheet = new TimesheetWeek($db); + if ($sheet->fetch($id) <= 0) { + $errors[] = $langs->trans('ErrorRecordNotFound').' (#'.$id.')'; + continue; + } + + if (!tw_can_act_on_user($sheet->fk_user, $permRead, $permReadChild, ($permReadAll || !empty($user->admin)), $user)) { + // EN: Skip the timesheet when the user cannot access the employee scope. + // FR: Ignore la feuille lorsque l'utilisateur ne peut pas accéder au périmètre du salarié. + $warnings[] = $langs->trans('TimesheetWeekMassMergeForbidden', ($sheet->ref ?: '#'.$id)); + continue; + } + + $tempResult = tw_generate_timesheet_pdf_to_temp($sheet, $conf, $langs, $defaultModel, $tempDir); + if (empty($tempResult['success'])) { + // EN: Propagate detailed generation issues for transparency. + // FR: Propage les problèmes de génération pour plus de transparence. + if (!empty($tempResult['errors'])) { + $errors = array_merge($errors, (array) $tempResult['errors']); + } else { + $errors[] = $langs->trans('TimesheetWeekMassMergeGenerationFailed', ($sheet->ref ?: '#'.$id)); + } + continue; + } + + $generatedFiles[] = $tempResult['path']; + } + + if (empty($generatedFiles)) { + // EN: Stop early when no PDF could be prepared. + // FR: Arrête immédiatement lorsqu'aucun PDF n'a pu être préparé. + if (!empty($errors)) { + setEventMessages(null, array_values(array_unique($errors)), 'errors'); + } + if (!empty($warnings)) { + setEventMessages(null, array_values(array_unique($warnings)), 'warnings'); + } + setEventMessages($langs->trans('TimesheetWeekMassMergeNoEligible'), null, 'errors'); + } else { + require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php'; + + $mergedDir = rtrim($uploaddir, '/').'/'.TIMESHEETWEEK_PDF_MERGED_SUBDIR; + $mergedFilename = dol_sanitizeFileName('timesheetweek_merged_'.dol_print_date(dol_now(), 'dayhourlog').'.pdf'); + if ($mergedFilename === '') { + $mergedFilename = dol_sanitizeFileName('timesheetweek_merged.pdf'); + } + $mergedFullPath = $mergedDir.'/'.$mergedFilename; + $mergeSucceeded = false; + + if (dol_mkdir($mergedDir) < 0) { + setEventMessages($langs->trans('ErrorCanNotCreateDir', $mergedDir), null, 'errors'); + } elseif (getDolGlobalString('USE_PDFTK_FOR_PDF_CONCAT')) { + $inputFiles = ''; + foreach ($generatedFiles as $f) { + $inputFiles .= ' '.escapeshellarg($f); + } + + exec('pdftk'.$inputFiles.' cat output '.escapeshellarg($mergedFullPath)); + if (dol_is_file($mergedFullPath)) { + dolChmod($mergedFullPath); + $mergeSucceeded = true; + } else { + $errors[] = $langs->trans('ErrorPDFTkOutputFileNotFound'); + } + } else { + $formatarray = pdf_getFormat(); + $pageLargeur = $formatarray['width']; + $pageHauteur = $formatarray['height']; + $format = array($pageLargeur, $pageHauteur); + + $pdf = pdf_getInstance($format); + if (class_exists('TCPDF')) { + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); + } + $pdf->SetFont(pdf_getPDFFont($langs)); + if (getDolGlobalString('MAIN_DISABLE_PDF_COMPRESSION')) { + $pdf->SetCompression(false); + } + + $pagecount = 0; + foreach ($generatedFiles as $filePath) { + $pagecount = $pdf->setSourceFile($filePath); + for ($i = 1; $i <= $pagecount; $i++) { + $tplidx = $pdf->importPage($i); + $templatesize = $pdf->getTemplatesize($tplidx); + $pdf->AddPage($templatesize['h'] > $templatesize['w'] ? 'P' : 'L'); + $pdf->useTemplate($tplidx); + } + } + + if ($pagecount > 0) { + $pdf->Output($mergedFullPath, 'F'); + dolChmod($mergedFullPath); + $mergeSucceeded = true; + } else { + $errors[] = $langs->trans('NoPDFAvailableForDocGenAmongChecked'); + } + } + + foreach ($generatedFiles as $tempFilePath) { + if (dol_is_file($tempFilePath)) { + dol_delete_file($tempFilePath, 0, 0, 0, null, false); + } + } + + if (!$mergeSucceeded) { + // EN: Inform the operator that merging failed. + // FR: Informe l'opérateur que la fusion a échoué. + if (!empty($warnings)) { + setEventMessages(null, array_values(array_unique($warnings)), 'warnings'); + } + if (!empty($errors)) { + setEventMessages(null, array_values(array_unique($errors)), 'warnings'); + } + setEventMessages($langs->trans('TimesheetWeekMassMergeError'), null, 'errors'); + } else { + $relativeMerged = TIMESHEETWEEK_PDF_MERGED_SUBDIR.'/'.$mergedFilename; + if (!empty($warnings)) { + setEventMessages(null, array_values(array_unique($warnings)), 'warnings'); + } + if (!empty($errors)) { + setEventMessages(null, array_values(array_unique($errors)), 'warnings'); + } + setEventMessages($langs->trans('TimesheetWeekMassMergeSuccess'), null, 'mesgs'); + $downloadUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&file='.urlencode($relativeMerged).'&entity='.$conf->entity.'&permission=read'; + header('Location: '.$downloadUrl); + exit; + } + } + } +} if ($massaction === 'delete') { $massActionProcessed = true; if (!$permissiontodelete) {