'date', 'returned_at' => 'date', 'counted_in_campaign' => 'boolean', 'link_sent' => '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(); return 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(): void { $this->forceFill(['link_sent' => true])->saveQuietly(); } // ─── 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 ───────────────────────────────────────── /** * 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(); return Campagna::where('start_date', '<=', $returnDate) ->where('end_date', '>=', $this->assigned_at) ->first(); } }