diff --git a/composer.json b/composer.json index 44057e75..1d5abb34 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "laravel/socialite": "^5.16", "laravel/tinker": "^2.7", "laravel/ui": "^4.0", - "phpmailer/phpmailer": "^6.9", + "phpmailer/phpmailer": "^7.1", "phpoffice/phpspreadsheet": "^4.1", "proengsoft/laravel-jsvalidation": "^4.0.0", "robmorgan/phinx": "^0.16.12", diff --git a/composer.lock b/composer.lock index 4d3ae474..64acd6fe 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "557df76fdde549210349be369d479f5d", + "content-hash": "e54cf3e0bbc04b94b563b01620ac3416", "packages": [ { "name": "akaunting/laravel-setting", @@ -4166,16 +4166,16 @@ }, { "name": "phpmailer/phpmailer", - "version": "v6.9.3", + "version": "v7.1.1", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e" + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/2f5c94fe7493efc213f643c23b1b1c249d40f47e", - "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15", "shasum": "" }, "require": { @@ -4189,13 +4189,14 @@ "doctrine/annotations": "^1.2.6 || ^1.13.3", "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.3.5", - "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.7.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "squizlabs/php_codesniffer": "^3.13.5", "yoast/phpunit-polyfills": "^1.0.4" }, "suggest": { "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example", + "ext-imap": "Needed to support advanced email address parsing according to RFC822", "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", "ext-openssl": "Needed for secure SMTP sending and DKIM signing", "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", @@ -4235,7 +4236,7 @@ "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.3" + "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1" }, "funding": [ { @@ -4243,7 +4244,7 @@ "type": "github" } ], - "time": "2024-11-24T18:04:13+00:00" + "time": "2026-05-18T08:06:14+00:00" }, { "name": "phpoffice/phpspreadsheet", diff --git a/db/migrations/20260923062342_add_notify_export_email_to_auth_users.php b/db/migrations/20260923062342_add_notify_export_email_to_auth_users.php new file mode 100644 index 00000000..2521b84e --- /dev/null +++ b/db/migrations/20260923062342_add_notify_export_email_to_auth_users.php @@ -0,0 +1,20 @@ +table('auth_users'); + if (!$table->hasColumn('notify_export_email')) { + $table->addColumn('notify_export_email', 'boolean', [ + 'default' => true, // di default ricevono la mail + 'null' => false, + 'after' => 'lims_global_user_id', + ])->update(); + } + } +} diff --git a/db/migrations/20260923065449_add_analysis_price_to_part_analyses.php b/db/migrations/20260923065449_add_analysis_price_to_part_analyses.php new file mode 100644 index 00000000..d31ffb7c --- /dev/null +++ b/db/migrations/20260923065449_add_analysis_price_to_part_analyses.php @@ -0,0 +1,21 @@ +table('identification_parts_analyses') + ->addColumn('analysis_price', 'decimal', [ + 'precision' => 10, + 'scale' => 2, + 'null' => true, + 'default' => null, + 'after' => 'analysis_level', + ]) + ->update(); + } +} diff --git a/public/userarea/analysisModal.js b/public/userarea/analysisModal.js index 77d9c400..4bf9059f 100644 --- a/public/userarea/analysisModal.js +++ b/public/userarea/analysisModal.js @@ -166,6 +166,7 @@ is_web_selectable: rowEl.getAttribute("data-web") === "1" ? 1 : 0, is_accredited: rowEl.getAttribute("data-accredited") === "1" ? 1 : 0, + analysis_price: rowEl.getAttribute("data-price") || "0", }; } @@ -196,6 +197,10 @@ : null, is_web_selectable: payload.is_web_selectable, is_accredited: payload.is_accredited, + analysis_price: + payload.analysis_price !== "" + ? parseFloat(payload.analysis_price) + : null, }); } } @@ -247,6 +252,7 @@ analysis_level: payload.analysis_level, is_web_selectable: payload.is_web_selectable, is_accredited: payload.is_accredited, + analysis_price: payload.analysis_price, }, }) .done(function (resp) { @@ -389,6 +395,17 @@ const selectable = item.SelezionabileSuWeb === true; const accredited = item.Accreditato === true; const level = item.Livello ?? ""; + + const rawPrice = item.Prezzo; + const priceNum = + rawPrice === null || + rawPrice === undefined || + rawPrice === "" + ? 0 + : parseFloat(String(rawPrice).replace(",", ".")); + const price = isNaN(priceNum) ? 0 : priceNum; + const hasPrice = price > 0; + const searchText = ( analysisName + " " + @@ -414,6 +431,10 @@ ""; } + if (!hasPrice) { + badges += 'No price'; + } + return `
${escapeHtml(analysisName)}
@@ -440,7 +462,6 @@ filterAnalysisList(); syncSelectedAnalysisRows(); } - function filterAnalysisList() { const modal = document.getElementById("analysisModal"); if (!modal) return; diff --git a/public/userarea/class/Mailer.class.php b/public/userarea/class/Mailer.class.php new file mode 100644 index 00000000..6e2fc819 --- /dev/null +++ b/public/userarea/class/Mailer.class.php @@ -0,0 +1,50 @@ +isSMTP(); + $mail->Host = env('MAIL_HOST'); + $mail->SMTPAuth = true; + $mail->Username = env('MAIL_USERNAME'); + $mail->Password = env('MAIL_PASSWORD'); + $mail->Port = (int) env('MAIL_PORT', 465); + $enc = env('MAIL_ENCRYPTION', 'ssl'); + $mail->SMTPSecure = ($enc === 'tls') + ? PHPMailer::ENCRYPTION_STARTTLS + : PHPMailer::ENCRYPTION_SMTPS; + $mail->CharSet = 'UTF-8'; + + $mail->setFrom(env('MAIL_FROM_ADDRESS'), env('MAIL_FROM_NAME', 'SmartTRF')); + + foreach ($to as $addr) { + $addr = trim((string)$addr); + if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) { + $mail->addAddress($addr); + } + } + if (!$mail->getToAddresses()) { + return ['ok' => false, 'error' => 'Nessun destinatario valido']; + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlBody; + $mail->AltBody = $altBody ?: strip_tags($htmlBody); + + $mail->send(); + return ['ok' => true, 'error' => null]; + } catch (PHPMailerException $e) { + error_log('Mailer error: ' . $mail->ErrorInfo); + return ['ok' => false, 'error' => $mail->ErrorInfo]; + } + } +} diff --git a/public/userarea/exportLims_gridData.js b/public/userarea/exportLims_gridData.js index 44bb57ad..dbb85d0d 100644 --- a/public/userarea/exportLims_gridData.js +++ b/public/userarea/exportLims_gridData.js @@ -24,6 +24,24 @@ if (overlay) overlay.style.display = "none"; } + function escHtml(v) { + return String(v ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // Badge "No price" con tooltip dei nomi analisi (vuoto se count = 0) + function noPriceBadgeHTML(count, names) { + const n = parseInt(count, 10) || 0; + if (n <= 0) return ""; + const title = (names || []).join("\n"); + return ` No price (${n})`; + } + window.noPriceBadgeHTML = noPriceBadgeHTML; + function getGridRow(iddatadb) { return document.querySelector(`.grid-row[data-id="${iddatadb}"]`); } @@ -34,6 +52,29 @@ ); } + // Invia la mail di riepilogo per gli iddatadb processati (fire-and-forget). + // Una sola chiamata a fine export (singolo o batch). + function sendExportSummaryMail(iddatadbList) { + const ids = (iddatadbList || []) + .map((v) => parseInt(v, 10)) + .filter(Boolean); + if (!ids.length) return; + + const body = new URLSearchParams(); + ids.forEach((id) => body.append("ids[]", id)); + const tid = window.gridMeta && window.gridMeta.templateId; + if (tid) body.append("template_id", tid); + + fetch("send_export_summary.php", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }) + .then((r) => r.json()) + .then((res) => console.log("Summary mail:", res)) + .catch((err) => console.warn("Summary mail failed:", err)); + } + // ── Validation ────────────────────────────────────────────────────── async function validateRows(rowsToValidate) { @@ -142,6 +183,9 @@ if (idx >= 0) { window.gridData[idx].status = "l"; window.gridData[idx].commessaweb = data.commessaweb; + window.gridData[idx].noPriceCount = data.noPriceCount || 0; + window.gridData[idx].noPriceAnalyses = + data.noPriceAnalyses || []; } // Update visible DOM row @@ -168,6 +212,11 @@ statusCell.appendChild(cwSpan); } cwSpan.textContent = data.commessaweb; + const npHtml = noPriceBadgeHTML( + data.noPriceCount, + data.noPriceAnalyses, + ); + if (npHtml) cwSpan.insertAdjacentHTML("beforeend", npHtml); } const exportBtn = gridRow.querySelector(".export-lims-btn"); if (exportBtn) { @@ -195,8 +244,17 @@ msg.innerHTML = `${data.message.replace(/\n/g, "
")}` + `
ID CommessaWeb: ${data.idcommessaweb}` + - `
Codice CommessaWeb: ${data.commessaweb}` + - (data.totalPhotos > 0 ? `
Foto: ${data.totalPhotos}` : ""); + `
Codice CommessaWeb: ${escHtml(data.commessaweb)}` + + noPriceBadgeHTML(data.noPriceCount, data.noPriceAnalyses) + + (data.totalPhotos > 0 ? `
Foto: ${data.totalPhotos}` : "") + + (data.noPriceCount > 0 + ? `
Analisi senza prezzo:
` + + `` + : ""); label.textContent = "Esportazione Completata"; } else { msg.textContent = `Errore: ${data.message}`; @@ -386,6 +444,8 @@ data.message || "Unknown error", ); showExportResult(data); + // Mail di riepilogo per la singola commessa + sendExportSummaryMail([iddatadb]); } catch (error) { if (gridRow) { setRowExporting(gridRow, false); @@ -633,6 +693,8 @@ let processed = 0, succeeded = 0, failed = 0; + const exportedList = []; // { commessaweb, noPriceCount, noPriceAnalyses } + const processedIds = []; // iddatadb processati (ok o errore) per la mail disableAllRowButtons(); @@ -666,11 +728,17 @@ const gridRow = row || getGridRow(iddatadb); if (gridRow) setRowExporting(gridRow, true); + processedIds.push(iddatadb); try { const data = await sendExport(iddatadb, batchUuid); processed++; if (data.success) { succeeded++; + exportedList.push({ + commessaweb: data.commessaweb, + noPriceCount: data.noPriceCount || 0, + noPriceAnalyses: data.noPriceAnalyses || [], + }); } else { failed++; const errIdx = getRowIndexByIddatadb(iddatadb); @@ -703,6 +771,9 @@ enableAllRowButtons(); if (bar) bar.style.display = "none"; + // Mail di riepilogo unica per tutto il batch processato + sendExportSummaryMail(processedIds); + // Re-render to reflect status changes, then restore error indicators const gr = window.gridRenderer; if (gr) gr.renderVisibleRows(); @@ -730,6 +801,31 @@ msgEl.innerHTML = `Exported: ${succeeded}
Errors: ${failed}`; } + if (exportedList.length) { + const npCommesse = exportedList.filter( + (x) => x.noPriceCount > 0, + ).length; + msgEl.innerHTML += + `
` + + (npCommesse > 0 + ? `
${npCommesse} commesse con analisi senza prezzo
` + : "") + + `
Commesse esportate:
` + + ``; + } + const modalEl = document.getElementById("exportResponseModal"); new bootstrap.Modal(modalEl, { keyboard: false }).show(); modalEl.addEventListener("hidden.bs.modal", cleanupBackdrop, { diff --git a/public/userarea/export_to_lims.php b/public/userarea/export_to_lims.php index 1d8e2752..bbd3b7e0 100644 --- a/public/userarea/export_to_lims.php +++ b/public/userarea/export_to_lims.php @@ -585,7 +585,7 @@ try { // 🔹 STEP 6.3: Add Analyses (AnalisiCampione) via Campione({id})/AddAnalisi bound action $stmt = $pdo->prepare(" - SELECT part_id, analysis_recordkey, analysis_name, analysis_method + SELECT part_id, analysis_recordkey, analysis_name, analysis_method, analysis_price FROM identification_parts_analyses WHERE iddatadb = :iddatadb ORDER BY part_id, id @@ -593,6 +593,17 @@ try { $stmt->execute(['iddatadb' => $iddatadb]); $analysesRows = $stmt->fetchAll(PDO::FETCH_ASSOC); + // Analisi senza prezzo (NULL o 0), una sola volta per recordkey + $noPriceAnalyses = []; + foreach ($analysesRows as $a) { + $price = $a['analysis_price']; + if ($price === null || (float)$price <= 0) { + $rk = (string)($a['analysis_recordkey'] ?? ''); + $noPriceAnalyses[$rk] = $a['analysis_name'] ?: 'Unnamed analysis'; + } + } + $noPriceAnalyses = array_values($noPriceAnalyses); + $partIdToIndex = []; foreach ($parts as $idx => $part) { $partIdToIndex[(int)$part['part_id']] = $idx; @@ -810,6 +821,8 @@ try { "totalAnalyses" => $totalAnalyses, "addedAnalyses" => $addedAnalyses, "failedAnalyses" => $failedAnalyses, + "noPriceCount" => count($noPriceAnalyses), + "noPriceAnalyses" => $noPriceAnalyses, "message" => "Export successful", "logFiles" => [ "step5_create" => $logFileStep5, diff --git a/public/userarea/gridRenderer.js b/public/userarea/gridRenderer.js index 288dbc59..d08eb4a2 100644 --- a/public/userarea/gridRenderer.js +++ b/public/userarea/gridRenderer.js @@ -588,7 +588,14 @@ : "To LIMS"; let html = `${label}`; if (row.commessaweb) { - html += `${esc(row.commessaweb)}`; + const npBadge = + typeof window.noPriceBadgeHTML === "function" + ? window.noPriceBadgeHTML( + row.noPriceCount, + row.noPriceAnalyses, + ) + : ""; + html += `${esc(row.commessaweb)}${npBadge}`; } div.innerHTML = html; div.classList.remove("editable-cell"); diff --git a/public/userarea/include/topbar.php b/public/userarea/include/topbar.php index f37e0d9d..3c505d87 100644 --- a/public/userarea/include/topbar.php +++ b/public/userarea/include/topbar.php @@ -108,6 +108,8 @@