Primo commit

This commit is contained in:
Francesco Picone
2026-04-05 19:26:04 +02:00
commit 701f479b7f
135 changed files with 21445 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
class AnnoTeocratico extends Model
{
protected $table = 'anni_teocratici';
protected $fillable = ['label', 'start_date', 'end_date'];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
];
}
/**
* Get or create the theocratic year for a given date.
*/
public static function perData(Carbon $date = null): static
{
$date = $date ?? now();
if ($date->month >= 9) {
$startYear = $date->year;
$endYear = $date->year + 1;
} else {
$startYear = $date->year - 1;
$endYear = $date->year;
}
$label = "{$startYear}-{$endYear}";
return static::firstOrCreate(
['label' => $label],
[
'start_date' => Carbon::create($startYear, 9, 1),
'end_date' => Carbon::create($endYear, 8, 31),
]
);
}
/**
* Get the current theocratic year.
*/
public static function corrente(): static
{
return static::perData(now());
}
/**
* Number of months elapsed since start of this theocratic year.
*/
public function getMesiTrascorsiAttribute(): int
{
$start = $this->start_date;
$end = now()->lt($this->end_date) ? now() : $this->end_date;
return max(1, $start->diffInMonths($end));
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'anno_teocratico_id');
}
}

113
app/Models/Assegnazione.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
class Assegnazione extends Model
{
protected $table = 'assegnazioni';
protected $fillable = [
'territorio_id',
'proclamatore_id',
'anno_teocratico_id',
'assigned_at',
'returned_at',
'counted_in_campaign',
'campaign_id',
'note',
'created_by',
'returned_by',
];
protected function casts(): array
{
return [
'assigned_at' => 'date',
'returned_at' => 'date',
'counted_in_campaign' => 'boolean',
];
}
// ─── 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);
}
// ─── 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();
}
}

94
app/Models/Campagna.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Campagna extends Model
{
use LogsActivity;
protected $table = 'campagne';
protected $fillable = ['start_date', 'end_date', 'descrizione'];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['start_date', 'end_date', 'descrizione'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* Is this campaign currently active?
*/
public function getIsAttivaAttribute(): bool
{
$today = now()->toDateString();
return $this->start_date->toDateString() <= $today
&& $this->end_date->toDateString() >= $today;
}
/**
* Find the currently active campaign (if any).
*/
public static function attiva(): ?static
{
return static::where('start_date', '<=', now())
->where('end_date', '>=', now())
->first();
}
/**
* Assignments counted for this campaign.
*/
public function assegnazioniConteggiate()
{
return $this->hasMany(Assegnazione::class, 'campaign_id')
->where('counted_in_campaign', true);
}
/**
* All assignments with assigned_at in this campaign's range.
*/
public function assegnazioniNelRange()
{
return Assegnazione::where('assigned_at', '>=', $this->start_date)
->where('assigned_at', '<=', $this->end_date);
}
/**
* Campaign coverage percentage.
* Numerator: assignments counted for campaign
* Denominator: ALL assignments with assigned_at in campaign range (returned or not)
*/
public function getPercentualePercorrenzaAttribute(): float
{
$totaleNelRange = $this->assegnazioniNelRange()->count();
if ($totaleNelRange === 0) {
return 0.0;
}
$conteggiate = $this->assegnazioniConteggiate()->count();
return round(($conteggiate / $totaleNelRange) * 100, 1);
}
public function scopeCompletate($query)
{
return $query->where('end_date', '<', now())->orderByDesc('end_date');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Proclamatore extends Model
{
use SoftDeletes, LogsActivity;
protected $table = 'proclamatori';
protected $fillable = ['nome', 'cognome', 'attivo'];
protected function casts(): array
{
return [
'nome' => 'encrypted',
'cognome' => 'encrypted',
'attivo' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['attivo']) // Do NOT log nome/cognome in audit (encrypted, GDPR)
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* Full name (decrypted, only visible in PHP/UI).
*/
public function getNomeCompletoAttribute(): string
{
return trim($this->cognome . ' ' . $this->nome);
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
}
public function assegnazioniAperte()
{
return $this->hasMany(Assegnazione::class, 'proclamatore_id')
->whereNull('returned_at');
}
public function scopeAttivi($query)
{
return $query->where('attivo', true);
}
/**
* Anonymize this proclamatore (GDPR right to be forgotten).
*/
public function anonimizza(): void
{
$this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id;
$this->attivo = false;
$this->save();
$this->delete(); // soft delete
}
}

56
app/Models/Setting.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = [
'congregazione_nome',
'logo_path',
'giorni_giacenza_da_assegnare',
'giorni_giacenza_prioritari',
'giorni_per_smarrito',
'home_limit_list',
'audit_retention_days',
'setup_completed',
];
protected function casts(): array
{
return [
'setup_completed' => 'boolean',
'giorni_giacenza_da_assegnare' => 'integer',
'giorni_giacenza_prioritari' => 'integer',
'giorni_per_smarrito' => 'integer',
'home_limit_list' => 'integer',
'audit_retention_days' => 'integer',
];
}
/**
* Get the singleton settings instance (first row).
*/
public static function instance(): static
{
return static::firstOrCreate([], [
'giorni_giacenza_da_assegnare' => 120,
'giorni_giacenza_prioritari' => 180,
'giorni_per_smarrito' => 120,
'home_limit_list' => 10,
'audit_retention_days' => 730,
]);
}
public static function isSetupComplete(): bool
{
$setting = static::first();
return $setting && $setting->setup_completed;
}
public static function getValue(string $key, mixed $default = null): mixed
{
return static::instance()->{$key} ?? $default;
}
}

219
app/Models/Territorio.php Normal file
View File

@@ -0,0 +1,219 @@
<?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',
'attivo',
'prioritario',
];
protected function casts(): array
{
return [
'attivo' => 'boolean',
'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()
{
return $this->hasOne(Assegnazione::class, 'territorio_id')
->whereNull('returned_at')
->latestOfMany('assigned_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';
}
return 'in_reparto';
}
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)->diffInDays(now());
}
if (!$ultima) {
return $this->created_at->diffInDays(now());
}
// 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;
}
// ─── Scopes ─────────────────────────────────────────────────
public function scopeAttivi($query)
{
return $query->where('attivo', true);
}
public function scopeInReparto($query)
{
return $query->attivi()
->whereDoesntHave('assegnazioni', function ($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('assegnazioni', function ($q) use ($soglia) {
$q->whereNull('returned_at')
->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));
});
});
});
}
}

41
app/Models/Tipologia.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Tipologia extends Model
{
use LogsActivity;
protected $table = 'tipologie';
protected $fillable = ['nome', 'attivo'];
protected function casts(): array
{
return [
'attivo' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'attivo'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function territori()
{
return $this->hasMany(Territorio::class, 'tipologia_id');
}
public function scopeAttive($query)
{
return $query->where('attivo', true);
}
}

42
app/Models/User.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasRoles, LogsActivity;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'email'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
}

41
app/Models/Zona.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Zona extends Model
{
use LogsActivity;
protected $table = 'zone';
protected $fillable = ['nome', 'attivo'];
protected function casts(): array
{
return [
'attivo' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'attivo'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function territori()
{
return $this->hasMany(Territorio::class, 'zona_id');
}
public function scopeAttive($query)
{
return $query->where('attivo', true);
}
}