Étapes 6-9 + types de lieux + picker + filtres
- Étape 6 : formulaire de saisie dynamique des relevés (piloté par source_type_fields, calendriers grégorien/julien/républicain) - Étape 7 : workflow de statut des sources + notifications mail+DB (SourceAValider, SourceRejetee) - Étape 8 : recherche fulltext PostgreSQL avec filtres type/lieu/années et CTE récursive pour les subdivisions de lieux - Étape 9 : export GEDCOM 5.5.1 (GedcomExportService + DateConversionService) - Types de lieux : CRUD admin (LieuTypeController) avec champ ordre - Composant lieu-picker : modale Alpine.js avec recherche AJAX + debounce - Filtres sources : statut, type, lieu (CTE récursive), période annee_debut/annee_fin - Filtres lieux : type, texte, lieu parent avec descendants (CTE récursive) - Migration : lieu_id + annee_debut + annee_fin sur sources Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreLieuTypeRequest;
|
||||
use App\Models\LieuType;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LieuTypeController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$lieuTypes = LieuType::withCount('lieux')->orderBy('ordre')->get();
|
||||
return view('admin.lieu-types.index', compact('lieuTypes'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.lieu-types.create');
|
||||
}
|
||||
|
||||
public function store(StoreLieuTypeRequest $request): RedirectResponse
|
||||
{
|
||||
LieuType::create($request->validated());
|
||||
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu créé.');
|
||||
}
|
||||
|
||||
public function edit(LieuType $lieuType): View
|
||||
{
|
||||
return view('admin.lieu-types.edit', compact('lieuType'));
|
||||
}
|
||||
|
||||
public function update(StoreLieuTypeRequest $request, LieuType $lieuType): RedirectResponse
|
||||
{
|
||||
$lieuType->update($request->validated());
|
||||
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu mis à jour.');
|
||||
}
|
||||
|
||||
public function destroy(LieuType $lieuType): RedirectResponse
|
||||
{
|
||||
if ($lieuType->lieux()->exists()) {
|
||||
return back()->with('error', 'Impossible de supprimer un type utilisé par des lieux.');
|
||||
}
|
||||
$lieuType->delete();
|
||||
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu supprimé.');
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreSectionRequest;
|
||||
use App\Http\Requests\Admin\UpdateSectionRequest;
|
||||
use App\Models\Lieu;
|
||||
use App\Models\Section;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -22,8 +21,7 @@ class SectionController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']);
|
||||
return view('admin.sections.create', compact('lieux'));
|
||||
return view('admin.sections.create');
|
||||
}
|
||||
|
||||
public function store(StoreSectionRequest $request): RedirectResponse
|
||||
@@ -42,8 +40,8 @@ class SectionController extends Controller
|
||||
|
||||
public function edit(Section $section): View
|
||||
{
|
||||
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']);
|
||||
return view('admin.sections.edit', compact('section', 'lieux'));
|
||||
$section->load('lieu');
|
||||
return view('admin.sections.edit', compact('section'));
|
||||
}
|
||||
|
||||
public function update(UpdateSectionRequest $request, Section $section): RedirectResponse
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SourceStatus;
|
||||
use App\Models\Releve;
|
||||
use App\Models\Source;
|
||||
use App\Services\GedcomExportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GedcomExportService $gedcom,
|
||||
) {}
|
||||
|
||||
/** Export de tous les relevés d'une source */
|
||||
public function source(Source $source): Response
|
||||
{
|
||||
$this->authorize('view', $source);
|
||||
|
||||
$gedcomContent = $this->gedcom->exportSource($source);
|
||||
$filename = $this->sanitizeFilename($source->nom) . '.ged';
|
||||
|
||||
return response($gedcomContent, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Export depuis les résultats de recherche (avec les mêmes filtres) */
|
||||
public function recherche(Request $request): Response
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Releve::with(['source.sourceType', 'createur'])
|
||||
->whereHas('source', function ($q) use ($user, $request) {
|
||||
if (! $user->isSectionManager()) {
|
||||
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
|
||||
$q->where(function ($sq) use ($assignedIds) {
|
||||
$sq->where('status', SourceStatus::Termine)
|
||||
->orWhereIn('id', $assignedIds);
|
||||
});
|
||||
}
|
||||
if ($request->filled('source_type_id')) {
|
||||
$q->where('source_type_id', $request->integer('source_type_id'));
|
||||
}
|
||||
}); // $request déjà dans le use()
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$q = trim($request->get('q'));
|
||||
$query->where(function ($wq) use ($q) {
|
||||
$wq->where('nom', 'ilike', "%{$q}%")
|
||||
->orWhere('prenom','ilike', "%{$q}%")
|
||||
->orWhere('date_evenement', 'ilike', "%{$q}%")
|
||||
->orWhereRaw(
|
||||
"to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)",
|
||||
[$q]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('lieu_id')) {
|
||||
$rows = DB::select("
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id, nom FROM lieux WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT l.id, l.nom FROM lieux l
|
||||
INNER JOIN descendants d ON l.lieu_parent_id = d.id
|
||||
)
|
||||
SELECT DISTINCT nom FROM descendants WHERE nom IS NOT NULL
|
||||
", [$request->integer('lieu_id')]);
|
||||
|
||||
$noms = collect($rows)->pluck('nom')->filter();
|
||||
if ($noms->isNotEmpty()) {
|
||||
$pattern = $noms->map(fn ($n) => preg_quote($n, '/'))->join('|');
|
||||
$query->whereRaw("data::text ~* ?", [$pattern]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('annee_debut')) {
|
||||
$query->whereRaw('date_evenement >= ?', [$request->integer('annee_debut') . '-01-01']);
|
||||
}
|
||||
if ($request->filled('annee_fin')) {
|
||||
$query->whereRaw('date_evenement <= ?', [$request->integer('annee_fin') . '-12-31']);
|
||||
}
|
||||
|
||||
$releves = $query->orderByRaw('nom ASC NULLS LAST')->get();
|
||||
|
||||
if ($releves->isEmpty()) {
|
||||
return back()->with('error', 'Aucun relevé à exporter.');
|
||||
}
|
||||
|
||||
$titre = $request->filled('q') ? 'Recherche_' . $request->get('q') : 'Export';
|
||||
$gedcomContent = $this->gedcom->exportReleves($releves, $titre);
|
||||
$filename = $this->sanitizeFilename($titre) . '.ged';
|
||||
|
||||
return response($gedcomContent, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
]);
|
||||
}
|
||||
|
||||
private function sanitizeFilename(string $name): string
|
||||
{
|
||||
$name = iconv('UTF-8', 'ASCII//TRANSLIT', $name) ?: $name;
|
||||
return preg_replace('/[^a-zA-Z0-9_-]/', '_', $name);
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,77 @@ namespace App\Http\Controllers;
|
||||
use App\Http\Requests\StoreLieuRequest;
|
||||
use App\Http\Requests\UpdateLieuRequest;
|
||||
use App\Models\Lieu;
|
||||
use App\Models\LieuType;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LieuController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim($request->get('q', ''));
|
||||
|
||||
$lieux = Lieu::with('lieuType')
|
||||
->where(function ($query) use ($q) {
|
||||
$query->where('nom_long', 'ilike', "%{$q}%")
|
||||
->orWhere('nom', 'ilike', "%{$q}%")
|
||||
->orWhere('code', 'ilike', "%{$q}%");
|
||||
})
|
||||
->orderBy('nom_long')
|
||||
->limit(25)
|
||||
->get();
|
||||
|
||||
return response()->json($lieux->map(fn ($l) => [
|
||||
'id' => $l->id,
|
||||
'nom_long' => $l->nom_long ?? $l->nom,
|
||||
'code' => $l->code,
|
||||
'type' => $l->lieuType?->nom,
|
||||
]));
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', Lieu::class);
|
||||
|
||||
// Arbre complet trié par nom_long pour l'affichage
|
||||
$lieux = Lieu::with('parent')
|
||||
->orderBy('nom_long')
|
||||
->paginate(50);
|
||||
$query = Lieu::with(['parent', 'lieuType'])->orderBy('nom_long');
|
||||
|
||||
return view('lieux.index', compact('lieux'));
|
||||
if ($request->filled('lieu_type_id')) {
|
||||
$query->where('lieu_type_id', $request->integer('lieu_type_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$q = trim($request->get('q'));
|
||||
$query->where(function ($wq) use ($q) {
|
||||
$wq->where('nom_long', 'ilike', "%{$q}%")
|
||||
->orWhere('nom', 'ilike', "%{$q}%")
|
||||
->orWhere('code', 'ilike', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('lieu_id')) {
|
||||
$ids = $this->getDescendantAndSelfIds($request->integer('lieu_id'));
|
||||
$query->whereIn('id', $ids);
|
||||
}
|
||||
|
||||
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
|
||||
$lieuSelectionne = $request->filled('lieu_id')
|
||||
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
|
||||
: null;
|
||||
$lieux = $query->paginate(50)->withQueryString();
|
||||
|
||||
return view('lieux.index', compact('lieux', 'lieuTypes', 'lieuSelectionne'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', Lieu::class);
|
||||
|
||||
$parents = Lieu::orderBy('nom_long')->get(['id', 'nom_long']);
|
||||
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
|
||||
|
||||
return view('lieux.create', compact('parents'));
|
||||
return view('lieux.create', compact('lieuTypes'));
|
||||
}
|
||||
|
||||
public function store(StoreLieuRequest $request): RedirectResponse
|
||||
@@ -43,7 +90,7 @@ class LieuController extends Controller
|
||||
{
|
||||
$this->authorize('view', $lieu);
|
||||
|
||||
$lieu->load('parent', 'enfants');
|
||||
$lieu->load('parent', 'enfants', 'lieuType');
|
||||
|
||||
return view('lieux.show', compact('lieu'));
|
||||
}
|
||||
@@ -52,20 +99,15 @@ class LieuController extends Controller
|
||||
{
|
||||
$this->authorize('update', $lieu);
|
||||
|
||||
// Exclure le lieu lui-même et ses descendants pour éviter les cycles
|
||||
$descendants = $this->getDescendantIds($lieu);
|
||||
$parents = Lieu::whereNotIn('id', [...$descendants, $lieu->id])
|
||||
->orderBy('nom_long')
|
||||
->get(['id', 'nom_long']);
|
||||
$lieu->load('parent', 'lieuType');
|
||||
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
|
||||
|
||||
return view('lieux.edit', compact('lieu', 'parents'));
|
||||
return view('lieux.edit', compact('lieu', 'lieuTypes'));
|
||||
}
|
||||
|
||||
public function update(UpdateLieuRequest $request, Lieu $lieu): RedirectResponse
|
||||
{
|
||||
$lieu->update($request->validated());
|
||||
|
||||
// Recalculer nom_long des enfants en cascade
|
||||
$this->recalculerEnfants($lieu);
|
||||
|
||||
return redirect()->route('lieux.show', $lieu)
|
||||
@@ -86,12 +128,27 @@ class LieuController extends Controller
|
||||
->with('success', 'Lieu supprimé.');
|
||||
}
|
||||
|
||||
private function getDescendantAndSelfIds(int $lieuId): array
|
||||
{
|
||||
$rows = DB::select("
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM lieux WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT l.id FROM lieux l
|
||||
INNER JOIN descendants d ON l.lieu_parent_id = d.id
|
||||
)
|
||||
SELECT id FROM descendants
|
||||
", [$lieuId]);
|
||||
|
||||
return collect($rows)->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
private function getDescendantIds(Lieu $lieu): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($lieu->enfants as $enfant) {
|
||||
$ids[] = $enfant->id;
|
||||
$ids = array_merge($ids, $this->getDescendantIds($enfant));
|
||||
$ids = array_merge($ids, $this->getDescendantIds($enfant));
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
@@ -100,7 +157,7 @@ class LieuController extends Controller
|
||||
{
|
||||
$lieu->load('enfants');
|
||||
foreach ($lieu->enfants as $enfant) {
|
||||
$enfant->update([]); // déclenche le booted() hook qui recalcule nom_long
|
||||
$enfant->update([]);
|
||||
$this->recalculerEnfants($enfant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->paginate(25);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, string $id): RedirectResponse
|
||||
{
|
||||
$request->user()
|
||||
->notifications()
|
||||
->where('id', $id)
|
||||
->first()
|
||||
?->markAsRead();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): RedirectResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return back()->with('success', 'Toutes les notifications ont été marquées comme lues.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SourceStatus;
|
||||
use App\Models\Lieu;
|
||||
use App\Models\Releve;
|
||||
use App\Models\SourceType;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RechercheController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
|
||||
$resultats = null;
|
||||
$total = null;
|
||||
|
||||
// Charger le lieu sélectionné pour pré-remplir le picker
|
||||
$lieuSelectionne = $request->filled('lieu_id')
|
||||
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
|
||||
: null;
|
||||
|
||||
if ($request->anyFilled(['q', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin'])) {
|
||||
[$resultats, $total] = $this->search($request);
|
||||
}
|
||||
|
||||
return view('recherche.index', compact('sourceTypes', 'resultats', 'total', 'lieuSelectionne'));
|
||||
}
|
||||
|
||||
private function search(Request $request): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Releve::with(['source.sourceType', 'source.depot', 'createur'])
|
||||
->whereHas('source', function ($q) use ($user, $request) {
|
||||
if (! $user->isSectionManager()) {
|
||||
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
|
||||
$q->where(function ($sq) use ($assignedIds) {
|
||||
$sq->where('status', SourceStatus::Termine)
|
||||
->orWhereIn('id', $assignedIds);
|
||||
});
|
||||
}
|
||||
if ($request->filled('source_type_id')) {
|
||||
$q->where('source_type_id', $request->integer('source_type_id'));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Recherche textuelle ──────────────────────────────────────────────
|
||||
if ($request->filled('q')) {
|
||||
$q = trim($request->get('q'));
|
||||
$query->where(function ($wq) use ($q) {
|
||||
$wq->where('nom', 'ilike', "%{$q}%")
|
||||
->orWhere('prenom','ilike', "%{$q}%")
|
||||
->orWhere('date_evenement', 'ilike', "%{$q}%")
|
||||
->orWhereRaw(
|
||||
"to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)",
|
||||
[$q]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filtre par lieu (+ descendants via CTE récursive) ────────────────
|
||||
if ($request->filled('lieu_id')) {
|
||||
$lieuNoms = $this->getLieuNoms($request->integer('lieu_id'));
|
||||
if ($lieuNoms->isNotEmpty()) {
|
||||
// Recherche regex case-insensitive dans le JSONB text
|
||||
$pattern = $lieuNoms
|
||||
->map(fn ($n) => preg_quote($n, '/'))
|
||||
->join('|');
|
||||
$query->whereRaw("data::text ~* ?", [$pattern]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filtre par plage d'années ────────────────────────────────────────
|
||||
if ($request->filled('annee_debut')) {
|
||||
$query->whereRaw("date_evenement >= ?", [$request->integer('annee_debut') . '-01-01']);
|
||||
}
|
||||
if ($request->filled('annee_fin')) {
|
||||
$query->whereRaw("date_evenement <= ?", [$request->integer('annee_fin') . '-12-31']);
|
||||
}
|
||||
|
||||
// ── Tri + pagination ────────────────────────────────────────────────
|
||||
$total = $query->count();
|
||||
$resultats = $query
|
||||
->orderByRaw('nom ASC NULLS LAST')
|
||||
->orderByRaw('date_evenement ASC NULLS LAST')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return [$resultats, $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les noms du lieu et de tous ses descendants via CTE récursive PostgreSQL.
|
||||
*/
|
||||
private function getLieuNoms(int $lieuId): \Illuminate\Support\Collection
|
||||
{
|
||||
$rows = DB::select("
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id, nom
|
||||
FROM lieux
|
||||
WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT l.id, l.nom
|
||||
FROM lieux l
|
||||
INNER JOIN descendants d ON l.lieu_parent_id = d.id
|
||||
)
|
||||
SELECT DISTINCT nom FROM descendants WHERE nom IS NOT NULL
|
||||
", [$lieuId]);
|
||||
|
||||
return collect($rows)->pluck('nom')->filter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\FieldType;
|
||||
use App\Http\Requests\StoreReleveRequest;
|
||||
use App\Http\Requests\UpdateReleveRequest;
|
||||
use App\Models\Releve;
|
||||
use App\Models\Source;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ReleveController extends Controller
|
||||
{
|
||||
public function index(Source $source): View
|
||||
{
|
||||
$this->authorize('viewAny', [Releve::class, $source]);
|
||||
|
||||
$source->load('sourceType.fields');
|
||||
|
||||
// Keyset pagination sur id (évite le COUNT sur des millions de lignes)
|
||||
$releves = $source->releves()
|
||||
->orderBy('id')
|
||||
->cursorPaginate(25);
|
||||
|
||||
return view('releves.index', compact('source', 'releves'));
|
||||
}
|
||||
|
||||
public function create(Source $source): View
|
||||
{
|
||||
$this->authorize('create', [Releve::class, $source]);
|
||||
|
||||
$source->load('sourceType.fields');
|
||||
|
||||
return view('releves.create', compact('source'));
|
||||
}
|
||||
|
||||
public function store(StoreReleveRequest $request, Source $source): RedirectResponse
|
||||
{
|
||||
$data = $this->buildData($request->validated()['data'] ?? [], $source);
|
||||
|
||||
$source->releves()->create([
|
||||
'data' => $data,
|
||||
'created_by' => $request->user()->id,
|
||||
'updated_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return redirect()->route('sources.releves.index', $source)
|
||||
->with('success', 'Relevé ajouté.');
|
||||
}
|
||||
|
||||
public function show(Source $source, Releve $releve): View
|
||||
{
|
||||
$this->authorize('view', $releve);
|
||||
|
||||
$source->load('sourceType.fields');
|
||||
$releve->load('createur', 'modificateur');
|
||||
|
||||
return view('releves.show', compact('source', 'releve'));
|
||||
}
|
||||
|
||||
public function edit(Source $source, Releve $releve): View
|
||||
{
|
||||
$this->authorize('update', $releve);
|
||||
|
||||
$source->load('sourceType.fields');
|
||||
|
||||
return view('releves.edit', compact('source', 'releve'));
|
||||
}
|
||||
|
||||
public function update(UpdateReleveRequest $request, Source $source, Releve $releve): RedirectResponse
|
||||
{
|
||||
$data = $this->buildData($request->validated()['data'] ?? [], $source);
|
||||
|
||||
$releve->update([
|
||||
'data' => $data,
|
||||
'updated_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return redirect()->route('sources.releves.show', [$source, $releve])
|
||||
->with('success', 'Relevé mis à jour.');
|
||||
}
|
||||
|
||||
public function destroy(Source $source, Releve $releve): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $releve);
|
||||
|
||||
$releve->delete();
|
||||
|
||||
return redirect()->route('sources.releves.index', $source)
|
||||
->with('success', 'Relevé supprimé.');
|
||||
}
|
||||
|
||||
// Normalise les données POST en structure JSONB propre
|
||||
private function buildData(array $raw, Source $source): array
|
||||
{
|
||||
$data = [];
|
||||
|
||||
foreach ($source->sourceType->fields as $field) {
|
||||
$value = $raw[$field->name] ?? null;
|
||||
|
||||
$data[$field->name] = match ($field->type) {
|
||||
FieldType::Boolean => (bool) ($value ?? false),
|
||||
FieldType::Number => $value !== null && $value !== '' ? (float) $value : null,
|
||||
FieldType::Date => [
|
||||
'valeur' => $value['valeur'] ?? null,
|
||||
'calendrier' => $value['calendrier'] ?? 'gregorien',
|
||||
],
|
||||
default => $value === '' ? null : $value,
|
||||
};
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -6,26 +6,29 @@ use App\Enums\SourceStatus;
|
||||
use App\Http\Requests\StoreSourceRequest;
|
||||
use App\Http\Requests\UpdateSourceRequest;
|
||||
use App\Models\Depot;
|
||||
use App\Models\Lieu;
|
||||
use App\Models\Source;
|
||||
use App\Models\SourceType;
|
||||
use App\Models\User;
|
||||
use App\Notifications\SourceAValiderNotification;
|
||||
use App\Notifications\SourceRejeteeNotification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SourceController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', Source::class);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Source::with(['sourceType', 'depot'])
|
||||
$query = Source::with(['sourceType', 'depot', 'lieu'])
|
||||
->withCount('releves');
|
||||
|
||||
if (! $user->isSectionManager()) {
|
||||
// Membre : sources terminées + sources assignées
|
||||
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
|
||||
$query->where(function ($q) use ($assignedIds) {
|
||||
$q->where('status', SourceStatus::Termine)
|
||||
@@ -33,9 +36,56 @@ class SourceController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$sources = $query->orderBy('nom')->paginate(25);
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
return view('sources.index', compact('sources'));
|
||||
if ($request->filled('source_type_id')) {
|
||||
$query->where('source_type_id', $request->integer('source_type_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('lieu_id')) {
|
||||
$lieuIds = $this->getLieuDescendantIds($request->integer('lieu_id'));
|
||||
$query->whereIn('lieu_id', $lieuIds);
|
||||
}
|
||||
|
||||
if ($request->filled('annee_debut')) {
|
||||
$annee = $request->integer('annee_debut');
|
||||
$query->where(function ($q) use ($annee) {
|
||||
$q->whereNull('annee_fin')->orWhere('annee_fin', '>=', $annee);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('annee_fin')) {
|
||||
$annee = $request->integer('annee_fin');
|
||||
$query->where(function ($q) use ($annee) {
|
||||
$q->whereNull('annee_debut')->orWhere('annee_debut', '<=', $annee);
|
||||
});
|
||||
}
|
||||
|
||||
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
|
||||
$lieuSelectionne = $request->filled('lieu_id')
|
||||
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
|
||||
: null;
|
||||
|
||||
$sources = $query->orderBy('nom')->paginate(25)->withQueryString();
|
||||
|
||||
return view('sources.index', compact('sources', 'sourceTypes', 'lieuSelectionne'));
|
||||
}
|
||||
|
||||
private function getLieuDescendantIds(int $lieuId): array
|
||||
{
|
||||
$rows = DB::select("
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM lieux WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT l.id FROM lieux l
|
||||
INNER JOIN descendants d ON l.lieu_parent_id = d.id
|
||||
)
|
||||
SELECT id FROM descendants
|
||||
", [$lieuId]);
|
||||
|
||||
return collect($rows)->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
@@ -71,6 +121,7 @@ class SourceController extends Controller
|
||||
{
|
||||
$this->authorize('update', $source);
|
||||
|
||||
$source->loadMissing('lieu');
|
||||
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
|
||||
$depots = Depot::orderBy('nom')->get(['id', 'nom']);
|
||||
|
||||
@@ -136,8 +187,28 @@ class SourceController extends Controller
|
||||
return back()->with('error', 'Transition non autorisée.');
|
||||
}
|
||||
|
||||
$previousStatus = $source->status;
|
||||
$source->update(['status' => $newStatus]);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if ($newStatus === SourceStatus::AValider) {
|
||||
// Notifier admins + responsables de section
|
||||
$source->load('sourceType', 'depot');
|
||||
User::whereIn('role', ['admin', 'section_manager'])
|
||||
->where('id', '!=', $user->id)
|
||||
->get()
|
||||
->each(fn ($u) => $u->notify(new SourceAValiderNotification($source, $user)));
|
||||
}
|
||||
|
||||
if ($newStatus === SourceStatus::EnCours && $previousStatus === SourceStatus::AValider) {
|
||||
// Rejet : notifier les membres assignés
|
||||
$source->membres()
|
||||
->where('users.id', '!=', $user->id)
|
||||
->get()
|
||||
->each(fn ($m) => $m->notify(new SourceRejeteeNotification($source, $user)));
|
||||
}
|
||||
|
||||
return back()->with('success', 'Statut mis à jour : ' . $newStatus->label());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreLieuTypeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool { return $this->user()->isAdmin(); }
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$ignore = $this->route('lieuType')?->id;
|
||||
|
||||
return [
|
||||
'nom' => ['required', 'string', 'max:100', Rule::unique('lieu_types', 'nom')->ignore($ignore)],
|
||||
'ordre' => ['required', 'integer', 'min:0', 'max:999'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class StoreLieuRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'nom' => ['required', 'string', 'max:255'],
|
||||
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
|
||||
'code' => ['nullable', 'string', 'max:20'],
|
||||
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id'],
|
||||
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\CalendarType;
|
||||
use App\Enums\FieldType;
|
||||
use App\Models\Source;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Enum;
|
||||
|
||||
class StoreReleveRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$source = $this->route('source');
|
||||
return $this->user()->can('create', [app(\App\Models\Releve::class), $source]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Source $source */
|
||||
$source = $this->route('source');
|
||||
$source->loadMissing('sourceType.fields');
|
||||
|
||||
$rules = [];
|
||||
|
||||
foreach ($source->sourceType->fields as $field) {
|
||||
$base = "data.{$field->name}";
|
||||
|
||||
switch ($field->type) {
|
||||
case FieldType::Date:
|
||||
$rules["{$base}.valeur"] = [$field->required ? 'required' : 'nullable', 'string', 'max:50'];
|
||||
$rules["{$base}.calendrier"] = ['required', new Enum(CalendarType::class)];
|
||||
break;
|
||||
|
||||
case FieldType::Boolean:
|
||||
$rules[$base] = ['nullable', 'boolean'];
|
||||
break;
|
||||
|
||||
case FieldType::Number:
|
||||
$rules[$base] = [$field->required ? 'required' : 'nullable', 'numeric'];
|
||||
break;
|
||||
|
||||
case FieldType::Select:
|
||||
$options = $field->options ?? [];
|
||||
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
|
||||
break;
|
||||
|
||||
default: // text, textarea
|
||||
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ class StoreSourceRequest extends FormRequest
|
||||
'description' => ['nullable', 'string'],
|
||||
'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
|
||||
'depot_id' => ['nullable', 'integer', 'exists:depots,id'],
|
||||
'lieu_id' => ['nullable', 'integer', 'exists:lieux,id'],
|
||||
'annee_debut' => ['nullable', 'integer', 'min:1000', 'max:2100'],
|
||||
'annee_fin' => ['nullable', 'integer', 'min:1000', 'max:2100', 'gte:annee_debut'],
|
||||
'cote' => ['nullable', 'string', 'max:255'],
|
||||
'auteur' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ class UpdateLieuRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'nom' => ['required', 'string', 'max:255'],
|
||||
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
|
||||
'code' => ['nullable', 'string', 'max:20'],
|
||||
// Interdit de se choisir soi-même ou un descendant comme parent
|
||||
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id', "not_in:{$lieuId}"],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\CalendarType;
|
||||
use App\Enums\FieldType;
|
||||
use App\Models\Source;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Enum;
|
||||
|
||||
class UpdateReleveRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update', $this->route('releve'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var \App\Models\Releve $releve */
|
||||
$releve = $this->route('releve');
|
||||
$releve->source->loadMissing('sourceType.fields');
|
||||
|
||||
$rules = [];
|
||||
|
||||
foreach ($releve->source->sourceType->fields as $field) {
|
||||
$base = "data.{$field->name}";
|
||||
|
||||
switch ($field->type) {
|
||||
case FieldType::Date:
|
||||
$rules["{$base}.valeur"] = [$field->required ? 'required' : 'nullable', 'string', 'max:50'];
|
||||
$rules["{$base}.calendrier"] = ['required', new Enum(CalendarType::class)];
|
||||
break;
|
||||
case FieldType::Boolean:
|
||||
$rules[$base] = ['nullable', 'boolean'];
|
||||
break;
|
||||
case FieldType::Number:
|
||||
$rules[$base] = [$field->required ? 'required' : 'nullable', 'numeric'];
|
||||
break;
|
||||
case FieldType::Select:
|
||||
$options = $field->options ?? [];
|
||||
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
|
||||
break;
|
||||
default:
|
||||
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ class UpdateSourceRequest extends FormRequest
|
||||
'description' => ['nullable', 'string'],
|
||||
'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
|
||||
'depot_id' => ['nullable', 'integer', 'exists:depots,id'],
|
||||
'lieu_id' => ['nullable', 'integer', 'exists:lieux,id'],
|
||||
'annee_debut' => ['nullable', 'integer', 'min:1000', 'max:2100'],
|
||||
'annee_fin' => ['nullable', 'integer', 'min:1000', 'max:2100', 'gte:annee_debut'],
|
||||
'cote' => ['nullable', 'string', 'max:255'],
|
||||
'auteur' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
+6
-1
@@ -10,7 +10,12 @@ class Lieu extends Model
|
||||
{
|
||||
protected $table = 'lieux';
|
||||
|
||||
protected $fillable = ['nom', 'code', 'lieu_parent_id', 'nom_long', 'latitude', 'longitude', 'note'];
|
||||
protected $fillable = ['nom', 'code', 'lieu_type_id', 'lieu_parent_id', 'nom_long', 'latitude', 'longitude', 'note'];
|
||||
|
||||
public function lieuType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LieuType::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class LieuType extends Model
|
||||
{
|
||||
protected $fillable = ['nom', 'ordre'];
|
||||
|
||||
public function lieux(): HasMany
|
||||
{
|
||||
return $this->hasMany(Lieu::class);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Source extends Model
|
||||
{
|
||||
protected $fillable = ['nom', 'description', 'source_type_id', 'depot_id', 'cote', 'auteur', 'status'];
|
||||
protected $fillable = ['nom', 'description', 'source_type_id', 'depot_id', 'lieu_id', 'annee_debut', 'annee_fin', 'cote', 'auteur', 'status'];
|
||||
|
||||
protected $casts = [
|
||||
'status' => SourceStatus::class,
|
||||
@@ -26,6 +26,11 @@ class Source extends Model
|
||||
return $this->belongsTo(Depot::class);
|
||||
}
|
||||
|
||||
public function lieu(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lieu::class);
|
||||
}
|
||||
|
||||
public function membres(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'source_user');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Source;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class SourceAValiderNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Source $source,
|
||||
public readonly User $soumispar,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject("Source à valider : {$this->source->nom}")
|
||||
->greeting("Bonjour {$notifiable->name},")
|
||||
->line("{$this->soumispar->name} a soumis la source **{$this->source->nom}** pour validation.")
|
||||
->line("Type : {$this->source->sourceType->nom}")
|
||||
->when($this->source->depot, fn ($m) => $m->line("Dépôt : {$this->source->depot->nom}"))
|
||||
->action('Accéder à la source', route('sources.show', $this->source))
|
||||
->line('Vous pouvez valider ou renvoyer cette source en cours de saisie.');
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'source_id' => $this->source->id,
|
||||
'source_nom' => $this->source->nom,
|
||||
'soumis_par' => $this->soumispar->name,
|
||||
'url' => route('sources.show', $this->source),
|
||||
];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return $this->toDatabase($notifiable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Source;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class SourceRejeteeNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Source $source,
|
||||
public readonly User $rejetePar,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject("Relevés à corriger : {$this->source->nom}")
|
||||
->greeting("Bonjour {$notifiable->name},")
|
||||
->line("{$this->rejetePar->name} a renvoyé la source **{$this->source->nom}** en cours de saisie.")
|
||||
->line("Des corrections sont nécessaires avant une nouvelle soumission.")
|
||||
->action('Accéder à la source', route('sources.show', $this->source));
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'source_id' => $this->source->id,
|
||||
'source_nom' => $this->source->nom,
|
||||
'rejete_par' => $this->rejetePar->name,
|
||||
'url' => route('sources.show', $this->source),
|
||||
'type' => 'rejet',
|
||||
];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return $this->toDatabase($notifiable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\SourceStatus;
|
||||
use App\Models\Releve;
|
||||
use App\Models\Source;
|
||||
use App\Models\User;
|
||||
|
||||
class RelevePolicy
|
||||
{
|
||||
public function viewAny(User $user, Source $source): bool
|
||||
{
|
||||
return $source->isVisibleBy($user);
|
||||
}
|
||||
|
||||
public function view(User $user, Releve $releve): bool
|
||||
{
|
||||
return $releve->source->isVisibleBy($user);
|
||||
}
|
||||
|
||||
public function create(User $user, Source $source): bool
|
||||
{
|
||||
if ($source->status === SourceStatus::Termine) {
|
||||
return false;
|
||||
}
|
||||
if ($user->isAdmin() || $user->isSectionManager()) {
|
||||
return true;
|
||||
}
|
||||
return $source->membres()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function update(User $user, Releve $releve): bool
|
||||
{
|
||||
return $this->create($user, $releve->source);
|
||||
}
|
||||
|
||||
public function delete(User $user, Releve $releve): bool
|
||||
{
|
||||
return $user->isAdmin() || $user->isSectionManager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTime;
|
||||
|
||||
class DateConversionService
|
||||
{
|
||||
// Dates de début du 1er Vendémiaire pour chaque An républicain
|
||||
private const YEAR_STARTS = [
|
||||
1 => '1792-09-22',
|
||||
2 => '1793-09-22',
|
||||
3 => '1794-09-23',
|
||||
4 => '1795-09-23',
|
||||
5 => '1796-09-22',
|
||||
6 => '1797-09-22',
|
||||
7 => '1798-09-22',
|
||||
8 => '1799-09-23',
|
||||
9 => '1800-09-23',
|
||||
10 => '1801-09-23',
|
||||
11 => '1802-09-23',
|
||||
12 => '1803-09-23',
|
||||
13 => '1804-09-23',
|
||||
14 => '1805-09-23',
|
||||
];
|
||||
|
||||
private const MONTHS = [
|
||||
'vendémiaire' => 1, 'vendemiaire' => 1,
|
||||
'brumaire' => 2,
|
||||
'frimaire' => 3,
|
||||
'nivôse' => 4, 'nivose' => 4,
|
||||
'pluviôse' => 5, 'pluviose' => 5,
|
||||
'ventôse' => 6, 'ventose' => 6,
|
||||
'germinal' => 7,
|
||||
'floréal' => 8, 'floreal' => 8,
|
||||
'prairial' => 9,
|
||||
'messidor' => 10,
|
||||
'thermidor' => 11,
|
||||
'fructidor' => 12,
|
||||
];
|
||||
|
||||
private const ROMAN = [
|
||||
'XIV' => 14, 'XIII' => 13, 'XII' => 12, 'XI' => 11, 'X' => 10,
|
||||
'IX' => 9, 'VIII' => 8, 'VII' => 7, 'VI' => 6, 'V' => 5,
|
||||
'IV' => 4, 'III' => 3, 'II' => 2, 'I' => 1,
|
||||
];
|
||||
|
||||
private const GEDCOM_MONTHS = [
|
||||
1 => 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR',
|
||||
5 => 'MAY', 6 => 'JUN', 7 => 'JUL', 8 => 'AUG',
|
||||
9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convertit un champ date JSONB { valeur, calendrier } en chaîne GEDCOM.
|
||||
* Retourne null si la conversion échoue.
|
||||
*/
|
||||
public function toGedcomDate(?array $dateField): ?string
|
||||
{
|
||||
if (empty($dateField['valeur'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$valeur = trim($dateField['valeur']);
|
||||
$calendrier = $dateField['calendrier'] ?? 'gregorien';
|
||||
|
||||
return match ($calendrier) {
|
||||
'gregorien' => $this->gregorianToGedcom($valeur),
|
||||
'julien' => $this->julianToGedcom($valeur),
|
||||
'republicain' => $this->republicanToGedcom($valeur),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD → "D MON YYYY" */
|
||||
public function gregorianToGedcom(string $date): ?string
|
||||
{
|
||||
$d = DateTime::createFromFormat('Y-m-d', $date);
|
||||
if (! $d) {
|
||||
return null;
|
||||
}
|
||||
$day = (int) $d->format('j');
|
||||
$month = self::GEDCOM_MONTHS[(int) $d->format('n')];
|
||||
$year = $d->format('Y');
|
||||
return "{$day} {$month} {$year}";
|
||||
}
|
||||
|
||||
/** Julien : même format YYYY-MM-DD, marqué @#DJULIAN@ */
|
||||
private function julianToGedcom(string $date): ?string
|
||||
{
|
||||
$g = $this->gregorianToGedcom($date);
|
||||
return $g ? "@#DJULIAN@ {$g}" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "15 Vendémiaire An III" → date grégorienne GEDCOM.
|
||||
* Retourne la date avec préfixe @#DFRENCH R@ (standard GEDCOM pour calendrier républicain).
|
||||
*/
|
||||
private function republicanToGedcom(string $date): ?string
|
||||
{
|
||||
$gregory = $this->republicanToGregorian($date);
|
||||
if ($gregory) {
|
||||
return $this->gregorianToGedcom($gregory);
|
||||
}
|
||||
|
||||
// Fallback : on conserve la date telle quelle dans un format lisible
|
||||
return "({$date})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit "15 Vendémiaire An III" → "1794-10-06".
|
||||
*/
|
||||
public function republicanToGregorian(string $input): ?string
|
||||
{
|
||||
// Normaliser l'entrée
|
||||
$input = trim($input);
|
||||
|
||||
// Pattern : "DD NomDuMois An N" ou "DD NomDuMois An XIV"
|
||||
$pattern = '/^(\d{1,2})\s+([\wéèêôûî]+)\s+[Aa]n\s+([IVXLCDM\d]+)$/iu';
|
||||
|
||||
if (! preg_match($pattern, $input, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$day = (int) $m[1];
|
||||
$monthStr = mb_strtolower(trim($m[2]));
|
||||
$yearStr = strtoupper(trim($m[3]));
|
||||
|
||||
// Résoudre le mois
|
||||
$monthNum = self::MONTHS[$monthStr] ?? null;
|
||||
if (! $monthNum || $day < 1 || $day > 30) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Résoudre l'année (chiffres arabes ou romains)
|
||||
$yearNum = is_numeric($yearStr)
|
||||
? (int) $yearStr
|
||||
: (self::ROMAN[$yearStr] ?? null);
|
||||
|
||||
if (! $yearNum || ! isset(self::YEAR_STARTS[$yearNum])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculer le nombre de jours depuis le 1er Vendémiaire
|
||||
$daysOffset = ($monthNum - 1) * 30 + ($day - 1);
|
||||
|
||||
$start = new DateTime(self::YEAR_STARTS[$yearNum]);
|
||||
$start->modify("+{$daysOffset} days");
|
||||
|
||||
return $start->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Releve;
|
||||
use App\Models\Source;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class GedcomExportService
|
||||
{
|
||||
// Mapping des noms de champs vers les tags GEDCOM
|
||||
private const FIELD_EVENT_MAP = [
|
||||
'naissance' => 'BIRT', 'birth' => 'BIRT',
|
||||
'mariage' => 'MARR', 'marriage' => 'MARR',
|
||||
'deces' => 'DEAT', 'décès' => 'DEAT', 'death' => 'DEAT',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly DateConversionService $dates,
|
||||
) {}
|
||||
|
||||
public function exportSource(Source $source): string
|
||||
{
|
||||
$source->load('sourceType.fields', 'releves.createur');
|
||||
return $this->buildGedcom($source->releves, $source->nom, $source->sourceType->nom);
|
||||
}
|
||||
|
||||
public function exportReleves(Collection $releves, string $titre = 'Export'): string
|
||||
{
|
||||
return $this->buildGedcom($releves, $titre);
|
||||
}
|
||||
|
||||
private function buildGedcom(Collection $releves, string $sourceName, string $sourceTypeName = ''): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = $this->header($sourceName);
|
||||
|
||||
$sourceTag = '@S1@';
|
||||
$famId = 0;
|
||||
$indiId = 0;
|
||||
|
||||
// Détecter le type d'événement depuis le nom du type de source
|
||||
$eventType = $this->detectEventType($sourceTypeName ?: $sourceName);
|
||||
|
||||
$individuals = [];
|
||||
$families = [];
|
||||
|
||||
foreach ($releves as $releve) {
|
||||
$data = $releve->data ?? [];
|
||||
|
||||
$indiId++;
|
||||
$indiTag = "@I{$indiId}@";
|
||||
|
||||
$nom = $data['nom'] ?? '';
|
||||
$prenom = $data['prenom'] ?? $data['prenom_epoux'] ?? $data['prenom_epouse'] ?? '';
|
||||
|
||||
// ── Individu principal ──────────────────────────────────────────
|
||||
$indi = [];
|
||||
$indi[] = "0 {$indiTag} INDI";
|
||||
if ($nom || $prenom) {
|
||||
$indi[] = "1 NAME {$prenom} /{$nom}/";
|
||||
if ($prenom) $indi[] = "1 GIVN {$prenom}";
|
||||
if ($nom) $indi[] = "1 SURN {$nom}";
|
||||
}
|
||||
|
||||
// Événement principal
|
||||
$dateField = $data['date_evenement'] ?? $data['date_naissance'] ?? $data['date_mariage'] ?? null;
|
||||
$gedDate = is_array($dateField) ? $this->dates->toGedcomDate($dateField) : null;
|
||||
$lieu = $data['lieu_naissance'] ?? $data['lieu_evenement'] ?? $data['lieu_mariage'] ?? null;
|
||||
|
||||
$indi[] = "1 {$eventType}";
|
||||
if ($gedDate) $indi[] = "2 DATE {$gedDate}";
|
||||
if ($lieu) $indi[] = "2 PLAC {$lieu}";
|
||||
|
||||
// Référence à la source
|
||||
$indi[] = "1 SOUR {$sourceTag}";
|
||||
if (isset($data['numero_acte'])) {
|
||||
$indi[] = "2 PAGE Acte n°{$data['numero_acte']}";
|
||||
}
|
||||
|
||||
// Notes (champs non mappés)
|
||||
$noteFields = ['note', 'observation', 'remarque'];
|
||||
foreach ($noteFields as $nf) {
|
||||
if (!empty($data[$nf])) {
|
||||
foreach ($this->wrapNote($data[$nf]) as $noteLine) {
|
||||
$indi[] = $noteLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$individuals[] = implode("\n", $indi);
|
||||
|
||||
// ── Famille (père/mère ou époux/épouse) ─────────────────────────
|
||||
$hasFather = !empty($data['nom_pere']) || !empty($data['prenom_pere']);
|
||||
$hasMother = !empty($data['nom_mere']) || !empty($data['prenom_mere']);
|
||||
$isMarriage = $eventType === 'MARR';
|
||||
|
||||
if ($hasFather || $hasMother || $isMarriage) {
|
||||
$famId++;
|
||||
$famTag = "@F{$famId}@";
|
||||
|
||||
$fam = [];
|
||||
$fam[] = "0 {$famTag} FAM";
|
||||
|
||||
if ($isMarriage) {
|
||||
// Pour un mariage, créer époux et épouse séparément
|
||||
if (!empty($data['nom_epoux']) || !empty($data['prenom_epoux'])) {
|
||||
$indiId++;
|
||||
$epouxTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$epouxTag} INDI",
|
||||
"1 NAME {$data['prenom_epoux']} /{$data['nom_epoux']}/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 HUSB {$epouxTag}";
|
||||
}
|
||||
if (!empty($data['nom_epouse']) || !empty($data['prenom_epouse'])) {
|
||||
$indiId++;
|
||||
$epouseTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$epouseTag} INDI",
|
||||
"1 NAME {$data['prenom_epouse']} /{$data['nom_epouse']}/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 WIFE {$epouseTag}";
|
||||
}
|
||||
$fam[] = "1 MARR";
|
||||
if ($gedDate) $fam[] = "2 DATE {$gedDate}";
|
||||
if ($lieu) $fam[] = "2 PLAC {$lieu}";
|
||||
// Lier l'individu principal comme enfant si besoin
|
||||
} else {
|
||||
// Naissance/décès : père et mère
|
||||
if ($hasFather) {
|
||||
$indiId++;
|
||||
$pereTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$pereTag} INDI",
|
||||
"1 NAME " . ($data['prenom_pere'] ?? '') . " /" . ($data['nom_pere'] ?? '') . "/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 HUSB {$pereTag}";
|
||||
}
|
||||
if ($hasMother) {
|
||||
$indiId++;
|
||||
$mereTag = "@I{$indiId}@";
|
||||
$individuals[] = implode("\n", [
|
||||
"0 {$mereTag} INDI",
|
||||
"1 NAME " . ($data['prenom_mere'] ?? '') . " /" . ($data['nom_mere'] ?? '') . "/",
|
||||
"1 FAMS {$famTag}",
|
||||
"1 SOUR {$sourceTag}",
|
||||
]);
|
||||
$fam[] = "1 WIFE {$mereTag}";
|
||||
}
|
||||
$fam[] = "1 CHIL {$indiTag}";
|
||||
// Lier l'enfant à sa famille
|
||||
$indi[] = "1 FAMC {$famTag}";
|
||||
}
|
||||
|
||||
$families[] = implode("\n", $fam);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($individuals as $indi) {
|
||||
$lines[] = $indi;
|
||||
}
|
||||
foreach ($families as $fam) {
|
||||
$lines[] = $fam;
|
||||
}
|
||||
|
||||
// Enregistrement SOURCE
|
||||
$lines[] = implode("\n", [
|
||||
"0 {$sourceTag} SOUR",
|
||||
"1 TITL {$sourceName}",
|
||||
"1 AUTH MesRelevés",
|
||||
]);
|
||||
|
||||
$lines[] = "0 TRLR";
|
||||
|
||||
return implode("\n", $lines) . "\n";
|
||||
}
|
||||
|
||||
private function header(string $sourceName): string
|
||||
{
|
||||
$date = now()->format('d M Y');
|
||||
$time = now()->format('H:i:s');
|
||||
|
||||
return implode("\n", [
|
||||
'0 HEAD',
|
||||
'1 SOUR MESRELEVES',
|
||||
'2 NAME MesRelevés',
|
||||
'2 VERS 1.0',
|
||||
'1 DEST ANY',
|
||||
"1 DATE {$date}",
|
||||
"2 TIME {$time}",
|
||||
'1 GEDC',
|
||||
'2 VERS 5.5.1',
|
||||
'2 FORM LINEAGE-LINKED',
|
||||
'1 CHAR UTF-8',
|
||||
"1 FILE {$sourceName}.ged",
|
||||
'1 LANG French',
|
||||
]);
|
||||
}
|
||||
|
||||
private function detectEventType(string $name): string
|
||||
{
|
||||
$lower = mb_strtolower($name);
|
||||
foreach (self::FIELD_EVENT_MAP as $keyword => $tag) {
|
||||
if (str_contains($lower, $keyword)) {
|
||||
return $tag;
|
||||
}
|
||||
}
|
||||
return 'EVEN';
|
||||
}
|
||||
|
||||
/** Découpe une note longue en lignes GEDCOM (max 248 chars par ligne) */
|
||||
private function wrapNote(string $text): array
|
||||
{
|
||||
$lines = [];
|
||||
$chunks = mb_str_split($text, 248);
|
||||
foreach ($chunks as $i => $chunk) {
|
||||
$lines[] = ($i === 0 ? '1 NOTE ' : '2 CONT ') . $chunk;
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user