'date', 'returned_at' => 'date', 'counted_in_campaign' => 'boolean', 'link_sent' => 'boolean', 'link_da_inviare' => 'boolean', ]; } protected static function booted(): void { // Assegnare un territorio ne azzera il flag "prioritario": una volta in // carico a un proclamatore (assegnazione aperta) non ha più senso // segnalarlo come prioritario. La priorità automatica per soglia di // giacenza riguarda solo i territori in reparto, quindi resta il solo // flag manuale da spegnere. Vale per ogni via di creazione (pagina // Assegna, CRUD del Registro, import XML di assegnazioni aperte). static::created(function (self $assegnazione): void { if ($assegnazione->returned_at !== null) { return; } $territorio = $assegnazione->territorio; if ($territorio && $territorio->prioritario) { $territorio->update(['prioritario' => false]); } }); } /** * L'assegnazione dello stesso territorio il cui periodo si sovrappone a * quello indicato, oppure null. Un'assegnazione aperta dura fino a oggi e * oltre; rientro e nuova uscita nello stesso giorno non si sovrappongono. * * Copre in un colpo solo la doppia assegnazione aperta, l'uscita * retrodatata a prima dell'ultimo rientro e le correzioni a mano nel * Registro che accavallano due periodi. Il confronto è fatto in PHP sulle * poche assegnazioni del territorio: in SQL dipenderebbe da come il * driver memorizza le DATE (MariaDB "Y-m-d", SQLite "Y-m-d H:i:s"). */ public static function sovrappostaA( int $territorioId, Carbon $uscita, ?Carbon $rientro = null, ?int $esclusaId = null ): ?self { $uscita = $uscita->copy()->startOfDay(); $rientro = $rientro?->copy()->startOfDay(); return static::with('proclamatore') ->where('territorio_id', $territorioId) ->when($esclusaId, fn ($q) => $q->whereKeyNot($esclusaId)) ->orderBy('assigned_at') ->get() ->first(fn (self $altra) => ($rientro === null || $altra->assigned_at->lt($rientro)) && ($altra->returned_at === null || $uscita->lt($altra->returned_at))); } /** Descrizione del periodo per i messaggi d'errore: "a Rossi Mario dal 01/09/2025 al 15/10/2025". */ public function descrizionePeriodo(): string { $chi = $this->proclamatore?->nome_completo ?? 'un proclamatore'; $al = $this->returned_at ? ' al ' . $this->returned_at->format('d/m/Y') : ', non ancora rientrato'; return "a {$chi} dal {$this->assigned_at->format('d/m/Y')}{$al}"; } // ─── Relationships ───────────────────────────────────────── public function territorio() { return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed(); } public function proclamatore() { return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed(); } public function annoTeocratico() { return $this->belongsTo(AnnoTeocratico::class, 'anno_teocratico_id'); } public function campagna() { return $this->belongsTo(Campagna::class, 'campaign_id'); } public function creatoDa() { return $this->belongsTo(User::class, 'created_by'); } public function rientratoDa() { return $this->belongsTo(User::class, 'returned_by'); } // ─── Computed ─────────────────────────────────────────────── /** * Number of days between assignment and return (or today if still open). */ public function getGiorniAttribute(): int { $end = $this->returned_at ?? now(); // Carbon 3 restituisce un float: i giorni interi trascorsi. return (int) Carbon::parse($this->assigned_at)->diffInDays($end); } public function getIsApertaAttribute(): bool { return is_null($this->returned_at); } /** * Scadenza dell'assegnazione: data entro cui il territorio andrebbe * riconsegnato, cioè assigned_at + la soglia "giorni_per_smarrito" (la * stessa oltre cui Territorio::stato diventa "da_rientrare"). Null per le * assegnazioni già rientrate. */ public function getDataConsegnaPresuntaAttribute(): ?Carbon { if ($this->returned_at !== null) { return null; } $soglia = (int) Setting::getValue('giorni_per_smarrito', 120); return Carbon::parse($this->assigned_at)->addDays($soglia); } /** * Giorni che mancano alla scadenza: positivo se ancora entro i termini, * negativo (giorni di ritardo) se già scaduta. Null se rientrata. */ public function getGiorniAllaScadenzaAttribute(): ?int { $scadenza = $this->data_consegna_presunta?->copy()->startOfDay(); if (! $scadenza) { return null; } $oggi = Carbon::today(); $giorni = (int) $oggi->diffInDays($scadenza); return $oggi->lessThanOrEqualTo($scadenza) ? $giorni : -$giorni; } public function ensurePdfAccessCode(): string { if ($this->pdf_access_code) { return $this->pdf_access_code; } do { $code = strtoupper(Str::random(12)); } while (static::query()->where('pdf_access_code', $code)->exists()); $this->forceFill(['pdf_access_code' => $code])->saveQuietly(); return $code; } public function temporaryPdfViewerUrl(): ?string { if (! $this->is_aperta || ! $this->territorio?->pdf_path) { return null; } return route('assignments.pdf.viewer', [ 'assignment' => $this->id, 'code' => $this->ensurePdfAccessCode(), ]); } public function shortPdfUrl(): ?string { if (! $this->is_aperta || ! $this->territorio?->pdf_path) { return null; } return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]); } public function markLinkSent(?string $canale = null): void { $this->registraInvioLink(true, $canale); } /** Canali con cui si manda il link al proclamatore. */ public const CANALI_LINK = ['copia' => 'Copia link', 'whatsapp' => 'WhatsApp', 'email' => 'Email']; /** * Esito dell'invio del link: inviato (con il canale usato) oppure no. Un * link non inviato resta "da inviare" e lo si segnala sull'assegnazione, * a meno che non ci sia proprio un link da mandare (territorio senza PDF). */ public function registraInvioLink(bool $inviato, ?string $canale = null): void { $this->forceFill([ 'link_sent' => $inviato, 'link_da_inviare' => ! $inviato && $this->shortPdfUrl() !== null, 'link_canale' => $inviato ? $canale : $this->link_canale, ])->saveQuietly(); } /** Il link va ancora mandato: l'avviso da mostrare sull'assegnazione. */ public function getLinkInSospesoAttribute(): bool { return $this->link_da_inviare && ! $this->link_sent && $this->is_aperta; } /** Il messaggio con il link, uguale per WhatsApp ed email. */ public function messaggioLink(): ?string { $link = $this->shortPdfUrl(); if ($link === null) { return null; } return "Ciao {$this->proclamatore?->nome}, ti è stato assegnato il territorio N° {$this->territorio?->numero}. Ecco la piantina: {$link}"; } // ─── Scopes ───────────────────────────────────────────────── public function scopeAperte($query) { return $query->whereNull('returned_at'); } public function scopeChiuse($query) { return $query->whereNotNull('returned_at'); } public function scopePerAnnoTeocratico($query, $annoId) { return $query->where('anno_teocratico_id', $annoId); } // ─── Business Logic ───────────────────────────────────────── /** * Registra nell'audit log la scelta dell'operatore sul conteggio in * campagna: evento dedicato "campagna_percorso", leggibile senza dover * interpretare le proprietà dell'evento "returned". */ public function logCampagnaPercorso(?Campagna $campagna, bool $percorso, string $origine): void { $this->loadMissing('territorio'); $giorni = $this->giorniPercorrenza(); $soglia = static::sogliaPercorrenzaElevata(); activity()->causedBy(auth()->user()) ->performedOn($this) ->withProperties([ 'territorio' => $this->territorio?->numero, 'proclamatore_id' => $this->proclamatore_id, // vedi Assegna: niente nomi in chiaro 'campagna' => $campagna?->descrizione, 'percorso_in_campagna' => $percorso ? 'Sì' : 'No', 'giorni_percorrenza' => $giorni, 'avviso_tempo_elevato' => $giorni > $soglia ? "Sì (soglia {$soglia} giorni)" : 'No', 'origine' => $origine, ]) ->log('campagna_percorso'); } /** * Giorni tra assegnazione e rientro (o la data indicata, o oggi). */ public function giorniPercorrenza(?Carbon $returnDate = null): int { $end = $returnDate ?? $this->returned_at ?? now(); return (int) Carbon::parse($this->assigned_at)->startOfDay()->diffInDays(Carbon::parse($end)->startOfDay()); } /** * Oltre questa soglia il tempo di percorrenza è considerato molto alto: * è la stessa soglia "giorni_per_smarrito" dello stato "da_rientrare". */ public static function sogliaPercorrenzaElevata(): int { return (int) Setting::getValue('giorni_per_smarrito', 120); } /** * Check if a campaign prompt should be shown when returning this assignment. * Returns the matching campaign or null. */ public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna { $returnDate = ($returnDate ?? now())->toDateString(); $assignedAt = $this->assigned_at->toDateString(); // Il territorio può essere conteggiato per una campagna se è rimasto // in mano al proclamatore almeno in parte del periodo: assegnato // durante la campagna, rientrato durante la campagna, oppure fuori per // tutta la sua durata (assegnato prima e rientrato dopo). Se sia stato // davvero percorso lo decide l'operatore con la spunta. // Se più campagne corrispondono si propone la più recente. return Campagna::where('start_date', '<=', $returnDate) ->where('end_date', '>=', $assignedAt) ->orderByDesc('start_date') ->first(); } }