Initial commit with contrats and domaines modules

This commit is contained in:
mrKamoo
2026-04-08 18:07:08 +02:00
commit 092a6a0484
191 changed files with 24639 additions and 0 deletions

35
app/Models/Article.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Builder;
class Article extends Model
{
protected $fillable = [
'reference', 'designation', 'description',
'categorie_id', 'fournisseur_id', 'prix_unitaire_ht', 'unite', 'active',
];
protected $casts = [
'prix_unitaire_ht' => 'decimal:2',
'active' => 'boolean',
];
public function categorie(): BelongsTo
{
return $this->belongsTo(Categorie::class);
}
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

32
app/Models/Categorie.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
class Categorie extends Model
{
protected $fillable = ['nom', 'description', 'couleur', 'icone', 'ordre', 'active'];
protected $casts = [
'active' => 'boolean',
'ordre' => 'integer',
];
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
public function lignesCommande(): HasMany
{
return $this->hasMany(LigneCommande::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

267
app/Models/Commande.php Normal file
View File

@@ -0,0 +1,267 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
use Carbon\Carbon;
class Commande extends Model
{
use SoftDeletes;
protected $fillable = [
'numero_commande', 'service_id', 'fournisseur_id', 'user_id',
'validateur_id', 'acheteur_id', 'objet', 'description', 'justification',
'statut', 'priorite', 'reference_fournisseur', 'imputation_budgetaire',
'montant_ht', 'montant_ttc',
'date_demande', 'date_souhaitee', 'date_validation', 'date_commande',
'date_livraison_prevue', 'date_reception', 'date_reception_complete', 'date_cloture',
'notes', 'notes_fournisseur',
];
protected $casts = [
'montant_ht' => 'decimal:2',
'montant_ttc' => 'decimal:2',
'date_demande' => 'date',
'date_souhaitee' => 'date',
'date_validation' => 'datetime',
'date_commande' => 'datetime',
'date_livraison_prevue' => 'date',
'date_reception' => 'datetime',
'date_reception_complete' => 'datetime',
'date_cloture' => 'datetime',
];
// -----------------------------------------------------------------------
// Constantes
// -----------------------------------------------------------------------
const STATUTS_LABELS = [
'brouillon' => 'Brouillon',
'en_attente_validation' => 'En attente de validation',
'validee' => 'Validée',
'commandee' => 'Commandée',
'partiellement_recue' => 'Partiellement reçue',
'recue_complete' => 'Reçue complète',
'cloturee' => 'Clôturée',
'annulee' => 'Annulée',
];
const STATUTS_COULEURS = [
'brouillon' => 'gray',
'en_attente_validation' => 'yellow',
'validee' => 'blue',
'commandee' => 'indigo',
'partiellement_recue' => 'orange',
'recue_complete' => 'green',
'cloturee' => 'slate',
'annulee' => 'red',
];
const PRIORITES_LABELS = [
'normale' => 'Normale',
'haute' => 'Haute',
'urgente' => 'Urgente',
];
const STATUT_TRANSITIONS = [
'brouillon' => ['en_attente_validation', 'annulee'],
'en_attente_validation' => ['validee', 'brouillon', 'annulee'],
'validee' => ['commandee', 'annulee'],
'commandee' => ['partiellement_recue', 'recue_complete', 'annulee'],
'partiellement_recue' => ['recue_complete', 'annulee'],
'recue_complete' => ['cloturee'],
'cloturee' => [],
'annulee' => [],
];
// -----------------------------------------------------------------------
// Relations
// -----------------------------------------------------------------------
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
public function demandeur(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function validateur(): BelongsTo
{
return $this->belongsTo(User::class, 'validateur_id');
}
public function acheteur(): BelongsTo
{
return $this->belongsTo(User::class, 'acheteur_id');
}
public function lignes(): HasMany
{
return $this->hasMany(LigneCommande::class)->orderBy('ordre');
}
public function historique(): HasMany
{
return $this->hasMany(HistoriqueCommande::class)->orderBy('created_at', 'desc');
}
public function piecesJointes(): HasMany
{
return $this->hasMany(PieceJointe::class)->orderBy('created_at', 'desc');
}
// -----------------------------------------------------------------------
// Scopes
// -----------------------------------------------------------------------
public function scopeEnCours(Builder $query): Builder
{
return $query->whereNotIn('statut', ['cloturee', 'annulee']);
}
public function scopeEnRetard(Builder $query): Builder
{
return $query->enCours()->whereNotNull('date_souhaitee')->where('date_souhaitee', '<', now()->toDateString());
}
public function scopeUrgentes(Builder $query): Builder
{
return $query->where('priorite', 'urgente');
}
public function scopeParService(Builder $query, int $serviceId): Builder
{
return $query->where('service_id', $serviceId);
}
public function scopeParStatut(Builder $query, string $statut): Builder
{
return $query->where('statut', $statut);
}
public function scopeParFournisseur(Builder $query, int $fournisseurId): Builder
{
return $query->where('fournisseur_id', $fournisseurId);
}
// -----------------------------------------------------------------------
// Accesseurs
// -----------------------------------------------------------------------
public function getStatutLabelAttribute(): string
{
return self::STATUTS_LABELS[$this->statut] ?? $this->statut;
}
public function getStatutCouleurAttribute(): string
{
return self::STATUTS_COULEURS[$this->statut] ?? 'gray';
}
public function getEstEnRetardAttribute(): bool
{
return !in_array($this->statut, ['cloturee', 'annulee'])
&& $this->date_souhaitee !== null
&& $this->date_souhaitee->isPast();
}
public function getTauxReceptionAttribute(): float
{
$total = $this->lignes->sum(fn ($l) => (float) $l->quantite);
if ($total <= 0) {
return 0.0;
}
$recu = $this->lignes->sum(fn ($l) => (float) $l->quantite_recue);
return round(min($recu / $total * 100, 100), 1);
}
// -----------------------------------------------------------------------
// Numérotation automatique
// -----------------------------------------------------------------------
public static function genererNumero(): string
{
$annee = now()->year;
$derniere = static::withTrashed()
->where('numero_commande', 'like', "CMD-IT-{$annee}-%")
->orderByDesc('id')
->value('numero_commande');
$sequence = 1;
if ($derniere) {
$sequence = (int) substr($derniere, strrpos($derniere, '-') + 1) + 1;
}
return sprintf('CMD-IT-%d-%04d', $annee, $sequence);
}
// -----------------------------------------------------------------------
// Moteur de transitions
// -----------------------------------------------------------------------
public function peutTransitionnerVers(string $statut): bool
{
return in_array($statut, self::STATUT_TRANSITIONS[$this->statut] ?? []);
}
public function transitionnerVers(string $statut, User $user, ?string $commentaire = null): bool
{
if (!$this->peutTransitionnerVers($statut)) {
return false;
}
$ancienStatut = $this->statut;
$this->statut = $statut;
// Mettre à jour les dates et les acteurs selon le nouveau statut
match ($statut) {
'validee' => $this->fill(['date_validation' => now(), 'validateur_id' => $user->id]),
'commandee' => $this->fill(['date_commande' => now(), 'acheteur_id' => $user->id]),
'partiellement_recue' => $this->fill(['date_reception' => $this->date_reception ?? now()]),
'recue_complete' => $this->fill(['date_reception_complete' => now(), 'date_reception' => $this->date_reception ?? now()]),
'cloturee' => $this->fill(['date_cloture' => now()]),
default => null,
};
$this->save();
HistoriqueCommande::create([
'commande_id' => $this->id,
'user_id' => $user->id,
'ancien_statut' => $ancienStatut,
'nouveau_statut' => $statut,
'commentaire' => $commentaire,
]);
return true;
}
// -----------------------------------------------------------------------
// Recalcul des montants (appelé depuis LigneCommande)
// -----------------------------------------------------------------------
public function recalculerMontants(): void
{
$this->lignes()->get()->tap(function ($lignes) {
$ht = $lignes->sum(fn ($l) => (float) $l->montant_ht);
$ttc = $lignes->sum(fn ($l) => (float) $l->montant_ttc);
$this->withoutEvents(function () use ($ht, $ttc) {
$this->update(['montant_ht' => $ht, 'montant_ttc' => $ttc]);
});
});
}
}

75
app/Models/Contrat.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Contrat extends Model
{
use HasFactory;
protected $fillable = [
'titre',
'description',
'fournisseur_id',
'service_id',
'date_debut',
'date_echeance',
'statut',
'montant',
'preavis_jours',
];
protected $casts = [
'date_debut' => 'date',
'date_echeance' => 'date',
'montant' => 'decimal:2',
];
const STATUTS_LABELS = [
'actif' => 'Actif',
'a_renouveler' => 'À renouveler',
'expire' => 'Expiré',
'resilie' => 'Résilié',
];
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function piecesJointes(): HasMany
{
return $this->hasMany(PieceJointe::class)->orderByDesc('created_at');
}
// Un contrat est considéré "proche d'expiration" si l'échéance est dans moins de 30 jours (ou selon son préavis)
public function getEstProcheEcheanceAttribute(): bool
{
if (!$this->date_echeance || in_array($this->statut, ['resilie', 'expire'])) {
return false;
}
$joursAvantAlerte = $this->preavis_jours > 0 ? $this->preavis_jours + 15 : 30;
return Carbon::now()->diffInDays($this->date_echeance, false) <= $joursAvantAlerte;
}
public function getEstEnRetardAttribute(): bool
{
if (!$this->date_echeance || $this->statut === 'resilie') {
return false;
}
return Carbon::now()->isAfter($this->date_echeance);
}
}

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

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Domaine extends Model
{
use HasFactory;
protected $fillable = [
'nom',
'date_echeance',
'prestataire',
'hebergeur',
];
protected $casts = [
'date_echeance' => 'date',
];
// Un domaine est considéré "proche d'expiration" si l'échéance est dans moins de 30 jours
public function getEstProcheEcheanceAttribute(): bool
{
if (!$this->date_echeance) {
return false;
}
return Carbon::now()->diffInDays($this->date_echeance, false) <= 30;
}
public function getEstEnRetardAttribute(): bool
{
if (!$this->date_echeance) {
return false;
}
return Carbon::now()->isAfter($this->date_echeance);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
class Fournisseur extends Model
{
protected $fillable = [
'nom', 'raison_sociale', 'siret', 'adresse', 'code_postal', 'ville',
'telephone', 'email', 'contact_commercial', 'email_commercial',
'telephone_commercial', 'site_web', 'notes', 'active',
];
protected $casts = [
'active' => 'boolean',
];
public function commandes(): HasMany
{
return $this->hasMany(Commande::class);
}
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HistoriqueCommande extends Model
{
public $timestamps = false;
protected $fillable = ['commande_id', 'user_id', 'ancien_statut', 'nouveau_statut', 'commentaire'];
protected $casts = [
'created_at' => 'datetime',
];
public function commande(): BelongsTo
{
return $this->belongsTo(Commande::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LigneCommande extends Model
{
protected $table = 'lignes_commande';
protected $fillable = [
'commande_id', 'article_id', 'categorie_id',
'designation', 'reference', 'quantite', 'quantite_recue',
'unite', 'prix_unitaire_ht', 'taux_tva',
'montant_ht', 'montant_ttc', 'notes', 'ordre',
];
protected $casts = [
'quantite' => 'decimal:3',
'quantite_recue' => 'decimal:3',
'prix_unitaire_ht' => 'decimal:2',
'taux_tva' => 'decimal:2',
'montant_ht' => 'decimal:2',
'montant_ttc' => 'decimal:2',
];
protected static function booted(): void
{
static::saving(function (LigneCommande $ligne) {
if ($ligne->prix_unitaire_ht !== null && $ligne->quantite !== null) {
$ligne->montant_ht = round((float) $ligne->quantite * (float) $ligne->prix_unitaire_ht, 2);
$ligne->montant_ttc = round((float) $ligne->montant_ht * (1 + (float) $ligne->taux_tva / 100), 2);
}
});
static::saved(function (LigneCommande $ligne) {
$ligne->commande?->recalculerMontants();
});
static::deleted(function (LigneCommande $ligne) {
$ligne->commande?->recalculerMontants();
});
}
public function commande(): BelongsTo
{
return $this->belongsTo(Commande::class);
}
public function article(): BelongsTo
{
return $this->belongsTo(Article::class);
}
public function categorie(): BelongsTo
{
return $this->belongsTo(Categorie::class);
}
}

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

@@ -0,0 +1,113 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class PieceJointe extends Model
{
public $timestamps = false;
protected $table = 'pieces_jointes';
protected $fillable = [
'commande_id', 'contrat_id', 'user_id', 'type',
'nom_original', 'chemin', 'mime_type', 'taille', 'description',
];
protected $casts = [
'taille' => 'integer',
'created_at' => 'datetime',
];
const TYPES_LABELS = [
'devis' => 'Devis',
'bon_commande' => 'Bon de commande',
'bon_livraison' => 'Bon de livraison',
'facture' => 'Facture',
'contrat' => 'Contrat',
'avenant' => 'Avenant',
'autre' => 'Autre',
];
const TYPES_ICONES = [
'devis' => '📋',
'bon_commande' => '🛒',
'bon_livraison' => '📦',
'facture' => '🧾',
'contrat' => '📄',
'avenant' => '📑',
'autre' => '📎',
];
const TYPES_COULEURS = [
'devis' => 'purple',
'bon_commande' => 'blue',
'bon_livraison' => 'orange',
'facture' => 'green',
'contrat' => 'indigo',
'avenant' => 'teal',
'autre' => 'gray',
];
// -----------------------------------------------------------------------
// Relations
// -----------------------------------------------------------------------
public function commande(): BelongsTo
{
return $this->belongsTo(Commande::class);
}
public function contrat(): BelongsTo
{
return $this->belongsTo(Contrat::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// -----------------------------------------------------------------------
// Accesseurs
// -----------------------------------------------------------------------
public function getTypeLabelAttribute(): string
{
return self::TYPES_LABELS[$this->type] ?? $this->type;
}
public function getTailleFormatteeAttribute(): string
{
$taille = $this->taille;
if ($taille < 1024) {
return $taille . ' o';
} elseif ($taille < 1024 * 1024) {
return round($taille / 1024, 1) . ' Ko';
}
return round($taille / (1024 * 1024), 1) . ' Mo';
}
public function getEstImageAttribute(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
public function getEstPdfAttribute(): bool
{
return $this->mime_type === 'application/pdf';
}
// -----------------------------------------------------------------------
// Méthodes
// -----------------------------------------------------------------------
public function supprimer(): void
{
Storage::disk('private')->delete($this->chemin);
$this->delete();
}
}

21
app/Models/Service.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Service extends Model
{
protected $fillable = ['nom', 'description', 'couleur', 'icone'];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function commandes(): HasMany
{
return $this->hasMany(Commande::class);
}
}

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

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasRoles;
protected $fillable = [
'name', 'email', 'password', 'service_id', 'telephone', 'active',
];
protected $hidden = [
'password', 'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'active' => 'boolean',
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function commandesCreees(): HasMany
{
return $this->hasMany(Commande::class, 'user_id');
}
public function commandesValidees(): HasMany
{
return $this->hasMany(Commande::class, 'validateur_id');
}
}