Files
termanager2/app/Models/Territorio.php
Francesco Picone 2fb9838a0e ++ Il mio gruppo: il numero del territorio apre l'anteprima
Il numero diventa un pulsante che apre una finestra con la miniatura e il
collegamento al PDF. La miniatura passa dalla rotta nuova
mio-gruppo.miniatura, con la stessa regola del PDF: solo i territori del
pacchetto del gruppo sorvegliato.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:27:50 +02:00

340 lines
12 KiB
PHP

<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Territorio extends Model
{
use SoftDeletes, LogsActivity;
protected $table = 'territori';
protected $fillable = [
'numero',
'zona_id',
'tipologia_id',
'note',
'confini',
'pdf_path',
'thumbnail_path',
'attivo',
'prioritario',
'gruppo_servizio_id',
];
/** Il gruppo di servizio nel cui pacchetto si trova, se c'è. */
public function gruppo()
{
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
}
protected function casts(): array
{
return [
'attivo' => 'boolean',
'gruppo_servizio_id' => 'integer',
'prioritario' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['numero', 'zona_id', 'tipologia_id', 'attivo', 'prioritario', 'pdf_path'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
// ─── Relationships ─────────────────────────────────────────
public function zona()
{
return $this->belongsTo(Zona::class, 'zona_id');
}
public function tipologia()
{
return $this->belongsTo(Tipologia::class, 'tipologia_id');
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'territorio_id');
}
public function assegnazioneCorrente()
{
// Il filtro sulle aperte va nella closure di ofMany: scritto fuori con
// whereNull() vale solo per la query esterna, mentre il MAX(assigned_at)
// del sottoquery si calcola su tutte le assegnazioni. Bastava una
// chiusa più recente dell'aperta per far risultare il territorio
// libero mentre era fuori, e quindi assegnabile una seconda volta.
return $this->hasOne(Assegnazione::class, 'territorio_id')
->ofMany(['assigned_at' => 'max', 'id' => 'max'], fn ($q) => $q->whereNull('returned_at'));
}
public function ultimaAssegnazione()
{
return $this->hasOne(Assegnazione::class, 'territorio_id')
->latestOfMany('assigned_at');
}
// ─── Computed State ─────────────────────────────────────────
public function getStatoAttribute(): string
{
if (!$this->attivo) {
return 'inattivo';
}
$corrente = $this->assegnazioneCorrente;
if ($corrente) {
$giorniAssegnato = Carbon::parse($corrente->assigned_at)->diffInDays(now());
$sogliaSmarrito = Setting::getValue('giorni_per_smarrito', 120);
if ($giorniAssegnato > $sogliaSmarrito) {
return 'da_rientrare';
}
return 'assegnato';
}
// Nel pacchetto di un gruppo e non ancora dato a nessuno: non è in
// reparto (non si assegna dalla pagina normale), è del gruppo.
if ($this->gruppo_servizio_id) {
return 'in_gruppo';
}
return 'in_reparto';
}
/** Lo stato da mostrare: "In gruppo Zisa" invece di "In gruppo". */
public function getStatoEtichettaAttribute(): string
{
$stato = $this->stato;
return $stato === 'in_gruppo'
? 'In gruppo ' . ($this->gruppo?->nome ?? '')
: str_replace('_', ' ', ucfirst($stato));
}
public function getAssegnatarioAttribute(): ?Proclamatore
{
return $this->assegnazioneCorrente?->proclamatore;
}
/**
* Days since last return (or creation if never assigned).
*/
public function getGiorniGiacenzaAttribute(): int
{
$ultima = $this->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
return Carbon::parse($ultima->returned_at)->startOfDay()->diffInDays(today());
}
if (!$ultima) {
return $this->created_at->startOfDay()->diffInDays(today());
}
// Currently assigned, no giacenza concept
return 0;
}
/**
* Is this territory "prioritario"?
* Manual flag OR giacenza exceeds threshold (threshold always wins).
*/
public function getIsPrioritarioAttribute(): bool
{
if (!$this->attivo) {
return false;
}
if ($this->prioritario) {
return true;
}
// Threshold-based priority (only when in reparto)
if ($this->stato === 'in_reparto') {
$soglia = Setting::getValue('giorni_giacenza_prioritari', 180);
return $this->giorni_giacenza > $soglia;
}
return false;
}
/**
* La logica di priorità dei territori da distribuire, la stessa ovunque
* (Home "Da assegnare", pacchetto di un gruppo, "Il mio gruppo"): prima i
* prioritari — spunta manuale o fermi da più giorni della soglia
* "giorni_giacenza_prioritari" — poi chi è fermo da più tempo, poi il
* numero. Su ogni territorio imposta giacenza_giorni (dall'ultimo rientro,
* o dalla creazione se mai assegnato; 0 se è fuori) e in_priorita, da
* mostrare. Vuole 'ultimaAssegnazione' già caricata, per non fare una
* query a territorio.
*/
public static function ordinaPerPriorita(iterable $territori): \Illuminate\Support\Collection
{
$soglia = (int) (Setting::instance()->giorni_giacenza_prioritari ?? 180);
return collect($territori)
->each(function (self $territorio) use ($soglia) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorni = (int) $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorni = (int) $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorni = 0;
}
$territorio->setAttribute('giacenza_giorni', $giorni);
$territorio->setAttribute('in_priorita', (bool) $territorio->prioritario || $giorni > $soglia);
})
->sort(function (self $a, self $b) {
return [(int) $b->in_priorita, $b->giacenza_giorni] <=> [(int) $a->in_priorita, $a->giacenza_giorni]
?: strnatcasecmp((string) $a->numero, (string) $b->numero);
})
->values();
}
/**
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
* numero, zona e tipologia. Con $motivo compare cercando ma non si può
* scegliere ("già assegnato a …").
*/
public function opzioneScelta(?string $motivo = null, ?string $nota = null): array
{
$dettaglio = trim(($this->zona?->nome ?? '') . ($this->tipologia?->nome ? " ({$this->tipologia->nome})" : ''));
return array_filter([
'valore' => (string) $this->id,
'etichetta' => "N° {$this->numero}" . ($nota ? " ({$nota})" : ''),
'dettaglio' => $dettaglio !== '' ? $dettaglio : null,
'chiavi' => [(string) $this->numero, (string) $this->zona?->nome, (string) $this->tipologia?->nome],
'motivo' => $motivo,
], fn ($v) => $v !== null);
}
// ─── Scopes ─────────────────────────────────────────────────
public function scopeAttivi($query)
{
return $query->where('attivo', true);
}
public function scopeInReparto($query)
{
// Quelli nel pacchetto di un gruppo non sono in reparto: stanno al
// gruppo finché il sorvegliante non li dà o non li rimanda indietro.
return $query->attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('assegnazioni', function ($q) {
$q->whereNull('returned_at');
});
}
/** Nel pacchetto di un gruppo e non ancora dati a nessuno. */
public function scopeInGruppo($query)
{
return $query->attivi()
->whereNotNull('gruppo_servizio_id')
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
}
public function scopeAssegnato($query)
{
return $query->attivi()
->whereHas('assegnazioni', function ($q) {
$q->whereNull('returned_at');
});
}
public function scopeDaRientrare($query)
{
$soglia = Setting::getValue('giorni_per_smarrito', 120);
return $query->attivi()
->whereHas('assegnazioneCorrente', function ($q) use ($soglia) {
$q->where('assigned_at', '<=', now()->subDays($soglia));
});
}
public function scopeDaAssegnare($query)
{
$soglia = Setting::getValue('giorni_giacenza_da_assegnare', 120);
return $query->inReparto()
->where(function ($q) use ($soglia) {
// Territories whose last assignment returned > soglia days ago
$q->whereHas('assegnazioni', function ($sub) use ($soglia) {
$sub->whereNotNull('returned_at')
->where('returned_at', '<=', now()->subDays($soglia))
->whereRaw('id = (SELECT MAX(a2.id) FROM assegnazioni a2 WHERE a2.territorio_id = assegnazioni.territorio_id)');
})
// Or territories never assigned, created > soglia days ago
->orWhere(function ($sub) use ($soglia) {
$sub->doesntHave('assegnazioni')
->where('created_at', '<=', now()->subDays($soglia));
});
});
}
public function scopePrioritari($query)
{
$soglia = Setting::getValue('giorni_giacenza_prioritari', 180);
return $query->inReparto()
->where(function ($q) use ($soglia) {
// Manual priority flag
$q->where('prioritario', true)
// OR threshold-based
->orWhere(function ($sub) use ($soglia) {
$sub->whereHas('assegnazioni', function ($a) use ($soglia) {
$a->whereNotNull('returned_at')
->where('returned_at', '<=', now()->subDays($soglia))
->whereRaw('id = (SELECT MAX(a2.id) FROM assegnazioni a2 WHERE a2.territorio_id = assegnazioni.territorio_id)');
})
->orWhere(function ($never) use ($soglia) {
$never->doesntHave('assegnazioni')
->where('created_at', '<=', now()->subDays($soglia));
});
});
});
}
// ─── Indirizzi dei file (serviti da TerritorioFileController) ──────────
/**
* ?v= cambia con il file: l'indirizzo resta per territorio, e senza la
* cache del browser mostrerebbe il PDF di prima dopo una sostituzione.
*/
public function pdfUrl(): ?string
{
return $this->pdf_path
? route('territori.file.pdf', ['territorio' => $this->id, 'v' => substr(md5($this->pdf_path), 0, 8)])
: null;
}
public function miniaturaUrl(): ?string
{
return $this->thumbnail_path
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
/** La miniatura per il sorvegliante di gruppo ("Il mio gruppo"): solo del suo pacchetto. */
public function miniaturaGruppoUrl(): ?string
{
return $this->thumbnail_path
? route('mio-gruppo.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
}