484 lines
16 KiB
PHP
484 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Manutenzioni — shared helpers.
|
|
*
|
|
* Included by every page and ajax endpoint of the module. Assumes a PDO
|
|
* connection is available through DBHandlerSelect (class/db-functions.php).
|
|
*/
|
|
|
|
require_once __DIR__ . '/../../class/db-functions.php';
|
|
|
|
if (!function_exists('mnt_pdo')) {
|
|
function mnt_pdo(): PDO
|
|
{
|
|
return DBHandlerSelect::getInstance()->getConnection();
|
|
}
|
|
}
|
|
|
|
if (!function_exists('mnt_h')) {
|
|
function mnt_h($value): string
|
|
{
|
|
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Minimal HTML email body, same visual language as the scadenzario mails.
|
|
*
|
|
* Lives here rather than in cron/send_notifications.php so that the preview
|
|
* (make mail-preview-html) renders exactly what the cron sends, instead of a
|
|
* copy that drifts.
|
|
*/
|
|
if (!function_exists('mnt_mail_body')) {
|
|
function mnt_mail_body(string $heading, string $subject, string $message, string $color, string $url): string
|
|
{
|
|
$safeHeading = htmlspecialchars($heading, ENT_QUOTES, 'UTF-8');
|
|
$safeSubject = htmlspecialchars($subject, ENT_QUOTES, 'UTF-8');
|
|
|
|
return '<!doctype html><html><body style="margin:0;padding:24px;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;color:#1e3a44">'
|
|
. '<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.06)">'
|
|
. '<div style="background:' . $color . ';color:#fff;padding:16px 20px;font-size:17px;font-weight:bold">' . $safeHeading . '</div>'
|
|
. '<div style="padding:20px">'
|
|
. '<p style="font-size:15px;font-weight:bold;margin:0 0 10px">' . $safeSubject . '</p>'
|
|
. '<p style="font-size:14px;line-height:1.55;margin:0 0 18px">' . $message . '</p>'
|
|
. '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#2f7d8f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:14px">Apri la scheda</a>'
|
|
. '</div></div></body></html>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subject and body of a notification, for both kinds.
|
|
*
|
|
* @param array $maintenance needs equipment_name, next_due_date, is_critical
|
|
* @return array{subject:string, body:string}
|
|
*/
|
|
if (!function_exists('mnt_notification_mail')) {
|
|
function mnt_notification_mail(array $maintenance, string $label, int $daysLeft, string $detailUrl, ?string $today = null): array
|
|
{
|
|
$today = $today ?? date('Y-m-d');
|
|
$isOverdue = $maintenance['next_due_date'] < $today;
|
|
$due = date('d/m/Y', strtotime($maintenance['next_due_date']));
|
|
|
|
$criticalNote = (int)($maintenance['is_critical'] ?? 0) === 1
|
|
? '<br><strong style="color:#b02a37">Manutenzione critica.</strong>'
|
|
: '';
|
|
|
|
if ($isOverdue) {
|
|
return [
|
|
'subject' => '⚠️ Manutenzione scaduta: ' . $maintenance['equipment_name'],
|
|
'body' => mnt_mail_body(
|
|
'Manutenzione scaduta',
|
|
$maintenance['equipment_name'] . ' — ' . $label,
|
|
'Era prevista per il <strong>' . $due . '</strong>, in ritardo di <strong>'
|
|
. abs($daysLeft) . ' giorni</strong>.' . $criticalNote,
|
|
'#dc3545',
|
|
$detailUrl
|
|
),
|
|
];
|
|
}
|
|
|
|
$daysText = $daysLeft === 0 ? '<strong>oggi</strong>' : 'tra <strong>' . $daysLeft . ' giorni</strong>';
|
|
|
|
return [
|
|
'subject' => '🔧 Manutenzione in scadenza: ' . $maintenance['equipment_name'],
|
|
'body' => mnt_mail_body(
|
|
'Manutenzione in scadenza',
|
|
$maintenance['equipment_name'] . ' — ' . $label,
|
|
'È prevista per il <strong>' . $due . '</strong> (' . $daysText . ').' . $criticalNote,
|
|
'#2f7d8f',
|
|
$detailUrl
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads ?page and ?per_page for the paged lists.
|
|
*
|
|
* per_page is a whitelist, not a free number: it lands in a LIMIT clause and
|
|
* an open value would let anyone ask for the whole table in one request.
|
|
* Returns [page, perPage] with page still unbounded above — the caller clamps
|
|
* it once it knows how many rows the filters actually match.
|
|
*/
|
|
if (!function_exists('mnt_page_params')) {
|
|
function mnt_page_params(int $default = 25): array
|
|
{
|
|
$allowed = [25, 50, 100];
|
|
$perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : $default;
|
|
if (!in_array($perPage, $allowed, true)) {
|
|
$perPage = $default;
|
|
}
|
|
|
|
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
|
|
|
return [max(1, $page), $perPage];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Frequency units offered in the maintenance form.
|
|
* 'on_demand' = no automatic due date (the client's "al bisogno" cases).
|
|
*/
|
|
if (!function_exists('mnt_frequency_units')) {
|
|
function mnt_frequency_units(): array
|
|
{
|
|
return [
|
|
'day' => 'Giorni',
|
|
'week' => 'Settimane',
|
|
'month' => 'Mesi',
|
|
'year' => 'Anni',
|
|
'on_demand' => 'Al bisogno (senza scadenza automatica)',
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('mnt_intervention_types')) {
|
|
function mnt_intervention_types(): array
|
|
{
|
|
return ['scheduled' => 'Programmata', 'extraordinary' => 'Straordinaria'];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('mnt_execution_types')) {
|
|
function mnt_execution_types(): array
|
|
{
|
|
return ['internal' => 'Interna', 'external' => 'Esterna'];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('mnt_equipment_statuses')) {
|
|
function mnt_equipment_statuses(): array
|
|
{
|
|
return [
|
|
'active' => 'Attivo',
|
|
'out_of_service' => 'Fuori servizio',
|
|
'decommissioned' => 'Dismesso',
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Options managed through the existing lookup_values.php CRUD.
|
|
* Falls back to sensible defaults if the category has no rows yet.
|
|
*/
|
|
if (!function_exists('mnt_lookup')) {
|
|
function mnt_lookup(PDO $pdo, string $category, array $fallback = []): array
|
|
{
|
|
static $cache = [];
|
|
|
|
if (!isset($cache[$category])) {
|
|
$stmt = $pdo->prepare("
|
|
SELECT value, label
|
|
FROM ws_lookup_options
|
|
WHERE category = ? AND is_active = 1
|
|
ORDER BY sort_order ASC, label ASC
|
|
");
|
|
$stmt->execute([$category]);
|
|
|
|
$options = [];
|
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
|
$options[$row['value']] = $row['label'];
|
|
}
|
|
|
|
$cache[$category] = $options ?: $fallback;
|
|
}
|
|
|
|
return $cache[$category];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Next due date = last completed intervention + frequency.
|
|
* Returns null when the maintenance has no automatic schedule or was never done.
|
|
*/
|
|
if (!function_exists('mnt_calc_next_due')) {
|
|
function mnt_calc_next_due(?string $lastDoneDate, ?int $frequencyValue, string $frequencyUnit): ?string
|
|
{
|
|
if ($lastDoneDate === null || $frequencyUnit === 'on_demand' || !$frequencyValue) {
|
|
return null;
|
|
}
|
|
|
|
$map = ['day' => 'day', 'week' => 'week', 'month' => 'month', 'year' => 'year'];
|
|
if (!isset($map[$frequencyUnit])) {
|
|
return null;
|
|
}
|
|
|
|
$date = date_create($lastDoneDate);
|
|
if (!$date) {
|
|
return null;
|
|
}
|
|
|
|
$dayOfMonth = (int)$date->format('j');
|
|
$date->modify('+' . $frequencyValue . ' ' . $map[$frequencyUnit]);
|
|
|
|
// PHP fa traboccare i mesi corti: 31/01 +1 mese diventa 03/03.
|
|
// Per una manutenzione la data attesa è l'ultimo giorno del mese.
|
|
if (in_array($frequencyUnit, ['month', 'year'], true) && (int)$date->format('j') !== $dayOfMonth) {
|
|
$date->modify('last day of previous month');
|
|
}
|
|
|
|
return $date->format('Y-m-d');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recompute last_done_date / next_due_date of a maintenance from its
|
|
* completed interventions. Call after any intervention insert/update/delete.
|
|
*/
|
|
if (!function_exists('mnt_recalc_maintenance')) {
|
|
function mnt_recalc_maintenance(PDO $pdo, int $maintenanceId): void
|
|
{
|
|
$stmt = $pdo->prepare("SELECT frequency_value, frequency_unit FROM maint_maintenances WHERE id = ?");
|
|
$stmt->execute([$maintenanceId]);
|
|
$maintenance = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$maintenance) {
|
|
return;
|
|
}
|
|
|
|
// Only completed interventions move the schedule; planned/in-progress do not.
|
|
$stmt = $pdo->prepare("
|
|
SELECT MAX(performed_at)
|
|
FROM maint_interventions
|
|
WHERE maintenance_id = ? AND status = 'completed'
|
|
");
|
|
$stmt->execute([$maintenanceId]);
|
|
$lastDone = $stmt->fetchColumn() ?: null;
|
|
|
|
$nextDue = mnt_calc_next_due(
|
|
$lastDone,
|
|
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
|
|
(string)$maintenance['frequency_unit']
|
|
);
|
|
|
|
$stmt = $pdo->prepare("UPDATE maint_maintenances SET last_done_date = ?, next_due_date = ? WHERE id = ?");
|
|
$stmt->execute([$lastDone, $nextDue, $maintenanceId]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule state of a maintenance, used for badges and filters.
|
|
* Returns one of: overdue | due_soon | ok | none
|
|
*/
|
|
if (!function_exists('mnt_due_state')) {
|
|
function mnt_due_state(?string $nextDueDate, int $alertDays = 0, ?string $today = null): string
|
|
{
|
|
if (empty($nextDueDate)) {
|
|
return 'none';
|
|
}
|
|
|
|
$today = $today ?: date('Y-m-d');
|
|
|
|
if ($nextDueDate < $today) {
|
|
return 'overdue';
|
|
}
|
|
|
|
$threshold = date('Y-m-d', strtotime($today . ' +' . max(0, $alertDays) . ' days'));
|
|
|
|
return $nextDueDate <= $threshold ? 'due_soon' : 'ok';
|
|
}
|
|
}
|
|
|
|
if (!function_exists('mnt_due_badge')) {
|
|
/**
|
|
* Lo stato "none" copre due casi diversi che non vanno confusi:
|
|
* una manutenzione al bisogno (per scelta senza scadenza) e una
|
|
* programmata mai eseguita (la scadenza arriverà al primo intervento).
|
|
*/
|
|
function mnt_due_badge(string $state, ?string $frequencyUnit = null): array
|
|
{
|
|
switch ($state) {
|
|
case 'overdue':
|
|
return ['label' => 'Scaduta', 'class' => 'mnt-badge-overdue'];
|
|
case 'due_soon':
|
|
return ['label' => 'In scadenza', 'class' => 'mnt-badge-soon'];
|
|
case 'ok':
|
|
return ['label' => 'In regola', 'class' => 'mnt-badge-ok'];
|
|
default:
|
|
if ($frequencyUnit !== null && $frequencyUnit !== 'on_demand') {
|
|
return ['label' => 'Mai eseguita', 'class' => 'mnt-badge-none'];
|
|
}
|
|
|
|
return ['label' => 'Al bisogno', 'class' => 'mnt-badge-none'];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!function_exists('mnt_format_date')) {
|
|
function mnt_format_date(?string $date): string
|
|
{
|
|
if (empty($date) || $date === '0000-00-00') {
|
|
return '—';
|
|
}
|
|
|
|
$ts = strtotime($date);
|
|
|
|
return $ts ? date('d/m/Y', $ts) : '—';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Human-readable frequency ("Ogni 3 mesi", "Al bisogno").
|
|
*/
|
|
if (!function_exists('mnt_format_frequency')) {
|
|
function mnt_format_frequency(?int $value, string $unit, ?string $note = null): string
|
|
{
|
|
$units = [
|
|
'day' => ['giorno', 'giorni'],
|
|
'week' => ['settimana', 'settimane'],
|
|
'month' => ['mese', 'mesi'],
|
|
'year' => ['anno', 'anni'],
|
|
];
|
|
|
|
if ($unit === 'on_demand' || !$value || !isset($units[$unit])) {
|
|
$text = 'Al bisogno';
|
|
} else {
|
|
$word = $value === 1 ? $units[$unit][0] : $units[$unit][1];
|
|
$text = 'Ogni ' . $value . ' ' . $word;
|
|
}
|
|
|
|
if (!empty($note)) {
|
|
$text .= ' — ' . $note;
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Audit log entry. All ids are optional so the same call works for
|
|
* equipment, maintenance and intervention events.
|
|
*/
|
|
if (!function_exists('mnt_log')) {
|
|
function mnt_log(
|
|
PDO $pdo,
|
|
string $action,
|
|
?int $equipmentId = null,
|
|
?int $maintenanceId = null,
|
|
?int $interventionId = null,
|
|
?int $userId = null,
|
|
?array $changes = null,
|
|
?string $notes = null
|
|
): void {
|
|
$stmt = $pdo->prepare("
|
|
INSERT INTO maint_histories
|
|
(equipment_id, maintenance_id, intervention_id, user_id, action, changes, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
");
|
|
$stmt->execute([
|
|
$equipmentId,
|
|
$maintenanceId,
|
|
$interventionId,
|
|
$userId,
|
|
$action,
|
|
$changes !== null ? json_encode($changes, JSON_UNESCAPED_UNICODE) : null,
|
|
$notes,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store one uploaded file into attachments/ and return its metadata.
|
|
* Throws RuntimeException on a rejected file.
|
|
*/
|
|
if (!function_exists('mnt_store_upload')) {
|
|
function mnt_store_upload(array $file, string $prefix = 'mnt'): array
|
|
{
|
|
$allowedExtensions = [
|
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'odt', 'ods',
|
|
'jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'bmp', 'zip',
|
|
];
|
|
$maxBytes = 20 * 1024 * 1024;
|
|
|
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
throw new RuntimeException('Errore durante il caricamento del file.');
|
|
}
|
|
|
|
if (($file['size'] ?? 0) > $maxBytes) {
|
|
throw new RuntimeException('File troppo grande (massimo 20 MB).');
|
|
}
|
|
|
|
$originalName = (string)($file['name'] ?? 'file');
|
|
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
|
|
|
if (!in_array($extension, $allowedExtensions, true)) {
|
|
throw new RuntimeException('Tipo di file non consentito: .' . $extension);
|
|
}
|
|
|
|
$uploadDir = __DIR__ . '/../attachments/';
|
|
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
|
|
throw new RuntimeException('Cartella allegati non scrivibile.');
|
|
}
|
|
|
|
$storedName = uniqid($prefix . '_', true) . '.' . $extension;
|
|
$safeStored = preg_replace('/[^A-Za-z0-9._-]/', '_', $storedName);
|
|
|
|
if (!move_uploaded_file($file['tmp_name'], $uploadDir . $safeStored)) {
|
|
throw new RuntimeException('Impossibile salvare il file.');
|
|
}
|
|
|
|
return [
|
|
'original_name' => $originalName,
|
|
'stored_name' => $safeStored,
|
|
'mime_type' => $file['type'] ?? null,
|
|
'size' => (int)($file['size'] ?? 0),
|
|
'is_image' => in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true),
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('mnt_delete_stored_file')) {
|
|
function mnt_delete_stored_file(string $storedName): void
|
|
{
|
|
$path = __DIR__ . '/../attachments/' . basename($storedName);
|
|
|
|
if (is_file($path)) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Employees / suppliers / departments pickers, shared by several forms.
|
|
*/
|
|
if (!function_exists('mnt_form_data')) {
|
|
function mnt_form_data(PDO $pdo): array
|
|
{
|
|
return [
|
|
'categories' => $pdo->query("
|
|
SELECT id, name, color, requires_line
|
|
FROM inv_categories
|
|
WHERE is_active = 1
|
|
ORDER BY sort_order ASC, name ASC
|
|
")->fetchAll(PDO::FETCH_ASSOC),
|
|
|
|
'lines' => $pdo->query("
|
|
SELECT id, line_number, name, color
|
|
FROM production_lines
|
|
ORDER BY line_number ASC
|
|
")->fetchAll(PDO::FETCH_ASSOC),
|
|
|
|
'departments' => $pdo->query("
|
|
SELECT id, name
|
|
FROM departments
|
|
WHERE is_active = 1
|
|
ORDER BY sort_order ASC, name ASC
|
|
")->fetchAll(PDO::FETCH_ASSOC),
|
|
|
|
'employees' => $pdo->query("
|
|
SELECT id, CONCAT(first_name, ' ', last_name) AS full_name
|
|
FROM employees
|
|
WHERE status = 'active'
|
|
ORDER BY first_name ASC, last_name ASC
|
|
")->fetchAll(PDO::FETCH_ASSOC),
|
|
|
|
'suppliers' => $pdo->query("
|
|
SELECT idsupplier AS id, supplier_name AS name
|
|
FROM suppliers
|
|
ORDER BY supplier_name ASC
|
|
")->fetchAll(PDO::FETCH_ASSOC),
|
|
];
|
|
}
|
|
}
|