WIP: Sicherheitsnetz vor Phase-1-R\u00fcckbau
Enth\u00e4lt gemischt: Laravel-10-Upgrade + Phase 1 (Contacts-Modul, Duplicats-Commands, Soft-Delete+Merge-Fields) + Phase 2 Code-Umstellungen (inquiry_id, $table='contacts'/'inquiries') + Offers-Modul (Migrationen, Models, offer_id in Booking, offer-Disk in filesystems.php). Phase 2 + Offers werden im folgenden Commit nach dev/backups/phase2-offers-2026-04-17/ verschoben, damit der Workspace auf Phase-1-only (= Test-System-Stand) reduziert ist und direkt auf Live deploybar wird. Tarball-Backup zus\u00e4tzlich unter: ../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz Made-with: Cursor
This commit is contained in:
parent
389d5d1820
commit
e3dc1afd8e
165 changed files with 21914 additions and 3516 deletions
312
app/Http/Controllers/ContactController.php
Normal file
312
app/Http/Controllers/ContactController.php
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Contact;
|
||||
use App\Repositories\ContactRepository;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Yajra\DataTables\Facades\DataTables;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ContactRepository $contactRepo)
|
||||
{
|
||||
$this->middleware(['admin', '2fa']);
|
||||
}
|
||||
|
||||
public function index(): \Illuminate\View\View
|
||||
{
|
||||
return view('contact.index');
|
||||
}
|
||||
|
||||
public function detail(string $id): \Illuminate\View\View
|
||||
{
|
||||
if ($id === 'new') {
|
||||
$contact = new Contact();
|
||||
} else {
|
||||
$contact = Contact::with(['salutation', 'leads', 'bookings', 'mergedContacts'])
|
||||
->findOrFail((int) $id);
|
||||
}
|
||||
|
||||
return view('contact.detail', [
|
||||
'contact' => $contact,
|
||||
'id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, string $id): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$data = $request->except('_token');
|
||||
|
||||
if (!isset($data['action'])) {
|
||||
abort(403, 'keine Action');
|
||||
}
|
||||
|
||||
if ($id === 'new') {
|
||||
$contact = $this->contactRepo->createContact($data);
|
||||
\Session()->flash('alert-save', '1');
|
||||
return redirect(route('contact_detail', [$contact->id]) . '#collapseContactDetail');
|
||||
}
|
||||
|
||||
$this->contactRepo->updateContact($id, $data);
|
||||
\Session()->flash('alert-save', '1');
|
||||
|
||||
return redirect(route('contact_detail', [$id]) . '#collapseContactDetail');
|
||||
}
|
||||
|
||||
public function history(int $id): \Illuminate\View\View
|
||||
{
|
||||
$contact = Contact::with([
|
||||
'leads.sf_guard_user',
|
||||
'bookings.travel_country',
|
||||
'bookings.travel_agenda',
|
||||
'bookings.sf_guard_user',
|
||||
'bookings.lead',
|
||||
])->findOrFail($id);
|
||||
|
||||
return view('contact._detail_history', [
|
||||
'contact' => $contact,
|
||||
'modal' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$contact = Contact::withoutGlobalScope('not_merged')->findOrFail($id);
|
||||
|
||||
$leadsCount = $contact->leads()->count();
|
||||
$bookingCount = $contact->bookings()->count();
|
||||
|
||||
if ($leadsCount > 0 || $bookingCount > 0) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => sprintf(
|
||||
'Kontakt kann nicht gelöscht werden: %s%s vorhanden.',
|
||||
$leadsCount > 0 ? $leadsCount . ' Anfrage(n)' : '',
|
||||
$bookingCount > 0 ? ($leadsCount > 0 ? ' und ' : '') . $bookingCount . ' Buchung(en)' : ''
|
||||
),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$contact->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function duplicates(): \Illuminate\View\View
|
||||
{
|
||||
$counts = [
|
||||
'HIGH' => $this->countDuplicateGroups('email'),
|
||||
'MEDIUM' => $this->countDuplicateGroups('name_birthdate'),
|
||||
'LOW' => $this->countDuplicateGroups('name_zip'),
|
||||
];
|
||||
|
||||
return view('contact.duplicates', compact('counts'));
|
||||
}
|
||||
|
||||
public function getDuplicateGroups(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$confidence = strtoupper($request->input('confidence', 'HIGH'));
|
||||
|
||||
$groups = match ($confidence) {
|
||||
'HIGH' => $this->findByEmail(),
|
||||
'MEDIUM' => $this->findByNameBirthdate(),
|
||||
'LOW' => $this->findByNameZip(),
|
||||
default => [],
|
||||
};
|
||||
|
||||
// Für jede Gruppe die vollständigen Kontakt-Daten laden
|
||||
$result = [];
|
||||
foreach ($groups as $ids) {
|
||||
$contacts = Contact::withoutGlobalScopes()
|
||||
->withCount(['leads', 'bookings'])
|
||||
->whereIn('id', $ids)
|
||||
->whereNull('merged_into_id')
|
||||
->whereNull('deleted_at')
|
||||
->orderByRaw('FIELD(id, ' . implode(',', $ids) . ')')
|
||||
->get(['id', 'firstname', 'name', 'email', 'zip', 'city', 'phone', 'phonemobile', 'birthdate', 'created_at', 'updated_at']);
|
||||
|
||||
if ($contacts->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'master' => $contacts->first(),
|
||||
'duplicates' => $contacts->skip(1)->values(),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function merge(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$masterId = (int) $request->input('master_id');
|
||||
$dupeId = (int) $request->input('duplicate_id');
|
||||
|
||||
if (!$masterId || !$dupeId || $masterId === $dupeId) {
|
||||
return response()->json(['success' => false, 'message' => 'Ungültige IDs.'], 422);
|
||||
}
|
||||
|
||||
$master = Contact::withoutGlobalScopes()->find($masterId);
|
||||
$dupe = Contact::withoutGlobalScopes()->find($dupeId);
|
||||
|
||||
if (!$master || !$dupe) {
|
||||
return response()->json(['success' => false, 'message' => 'Kontakt nicht gefunden.'], 404);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($masterId, $dupeId) {
|
||||
DB::table('inquiries')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
|
||||
DB::table('booking')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
|
||||
DB::table('customer_mails')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
|
||||
DB::table('lead_mails')->where('customer_id', $dupeId)->update(['customer_id' => $masterId]);
|
||||
DB::table('contacts')->where('id', $dupeId)->update([
|
||||
'merged_into_id' => $masterId,
|
||||
'merged_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
// ── Duplikat-Hilfs-Queries ────────────────────────────────────────────────
|
||||
|
||||
private function countDuplicateGroups(string $type): int
|
||||
{
|
||||
return match ($type) {
|
||||
'email' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('email')->where('email', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('email')->havingRaw('COUNT(*) > 1')->get()->count(),
|
||||
'name_birthdate' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('birthdate')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'birthdate')->havingRaw('COUNT(*) > 1')->get()->count(),
|
||||
'name_zip' => DB::table('contacts')->selectRaw('COUNT(*) as cnt')->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('zip')->where('zip', '!=', '')->whereNull('merged_into_id')->whereNull('deleted_at')->groupBy('name', 'firstname', 'zip')->havingRaw('COUNT(*) > 1')->get()->count(),
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private function findByEmail(): array
|
||||
{
|
||||
return DB::table('contacts')
|
||||
->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')
|
||||
->whereNotNull('email')->where('email', '!=', '')
|
||||
->whereNull('merged_into_id')->whereNull('deleted_at')
|
||||
->groupBy('email')->havingRaw('COUNT(*) > 1')
|
||||
->pluck('ids')->map(fn ($s) => array_map('intval', explode(',', $s)))->all();
|
||||
}
|
||||
|
||||
private function findByNameBirthdate(): array
|
||||
{
|
||||
return DB::table('contacts')
|
||||
->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')
|
||||
->whereNotNull('name')->whereNotNull('firstname')->whereNotNull('birthdate')
|
||||
->whereNull('merged_into_id')->whereNull('deleted_at')
|
||||
->groupBy('name', 'firstname', 'birthdate')->havingRaw('COUNT(*) > 1')
|
||||
->pluck('ids')->map(fn ($s) => array_map('intval', explode(',', $s)))->all();
|
||||
}
|
||||
|
||||
private function findByNameZip(): array
|
||||
{
|
||||
return DB::table('contacts')
|
||||
->selectRaw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids')
|
||||
->whereNotNull('name')->whereNotNull('firstname')
|
||||
->whereNotNull('zip')->where('zip', '!=', '')
|
||||
->whereNull('merged_into_id')->whereNull('deleted_at')
|
||||
->groupBy('name', 'firstname', 'zip')->havingRaw('COUNT(*) > 1')
|
||||
->pluck('ids')->map(fn ($s) => array_map('intval', explode(',', $s)))->all();
|
||||
}
|
||||
|
||||
public function restore(int $id): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$contact = Contact::onlyTrashed()->withoutGlobalScope('not_merged')->findOrFail($id);
|
||||
$contact->restore();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function getContacts(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$showDeleted = $request->filled('filter_deleted');
|
||||
|
||||
// Reihenfolge wichtig: select() zuerst, dann withCount() — sonst überschreibt
|
||||
// select() die COUNT-Subqueries die withCount() per addSelect() eingetragen hat.
|
||||
$query = $showDeleted
|
||||
? Contact::onlyTrashed()->withoutGlobalScope('not_merged')->select('contacts.*')->withCount(['leads', 'bookings'])
|
||||
: Contact::select('contacts.*')->withCount(['leads', 'bookings']);
|
||||
|
||||
// Zusatzfilter aus der UI (werden als extra GET-Parameter gesendet)
|
||||
if ($request->filled('filter_has_leads')) {
|
||||
$query->has('leads');
|
||||
}
|
||||
if ($request->filled('filter_has_bookings')) {
|
||||
$query->has('bookings');
|
||||
}
|
||||
if ($request->filled('filter_has_email')) {
|
||||
$query->whereNotNull('email')->where('email', '!=', '');
|
||||
}
|
||||
|
||||
return DataTables::eloquent($query)
|
||||
->addColumn('action_edit', function (Contact $contact) use ($showDeleted) {
|
||||
if ($showDeleted) {
|
||||
return '';
|
||||
}
|
||||
return '<a href="' . route('contact_detail', [$contact->id]) . '" class="btn icon-btn btn-sm btn-primary" title="Öffnen"><span class="fa fa-edit"></span></a>';
|
||||
})
|
||||
->addColumn('action_delete', function (Contact $contact) use ($showDeleted) {
|
||||
if ($showDeleted) {
|
||||
return '<button class="btn icon-btn btn-xs btn-success ml-1 btn-contact-restore" '
|
||||
. 'data-id="' . $contact->id . '" '
|
||||
. 'data-name="' . e($contact->fullName()) . '" '
|
||||
. 'title="Wiederherstellen"><span class="fa fa-undo"></span></button>';
|
||||
}
|
||||
return '<button class="btn icon-btn btn-xs btn-danger ml-1 btn-contact-delete" '
|
||||
. 'data-id="' . $contact->id . '" '
|
||||
. 'data-name="' . e($contact->fullName()) . '" '
|
||||
. 'title="Löschen"><span class="fa fa-trash"></span></button>';
|
||||
})
|
||||
->addColumn('id', function (Contact $contact) use ($showDeleted) {
|
||||
if ($showDeleted) {
|
||||
return '<span data-order="' . $contact->id . '">' . $contact->id . '</span>';
|
||||
}
|
||||
return '<a data-order="' . $contact->id . '" href="' . route('contact_detail', [$contact->id]) . '">' . $contact->id . '</a>';
|
||||
})
|
||||
->addColumn('raw_id', fn(Contact $contact) => $contact->id)
|
||||
->addColumn('leads_count', fn(Contact $contact) => $contact->leads_count)
|
||||
->addColumn('bookings_count', fn(Contact $contact) => $contact->bookings_count)
|
||||
->addColumn('deleted_at', fn(Contact $contact) => $contact->deleted_at?->format('d.m.Y H:i') ?? '')
|
||||
->orderColumn('id', 'customer.id $1')
|
||||
->orderColumn('deleted_at', 'customer.deleted_at $1')
|
||||
->filterColumn('id', function ($query, $keyword) {
|
||||
if ($keyword !== '') {
|
||||
$query->where('contacts.id', 'LIKE', '%' . $keyword . '%');
|
||||
}
|
||||
})
|
||||
->filterColumn('name', function ($query, $keyword) {
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'LIKE', '%' . $keyword . '%')
|
||||
->orWhere('firstname', 'LIKE', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
})
|
||||
->filter(function ($query) use ($request) {
|
||||
$location = $request->input('filter_location');
|
||||
if ($location && $location !== '') {
|
||||
$query->where(function ($q) use ($location) {
|
||||
$q->where('zip', 'LIKE', '%' . $location . '%')
|
||||
->orWhere('city', 'LIKE', '%' . $location . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$search = $request->input('search.value');
|
||||
if ($search && $search !== '') {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'LIKE', '%' . $search . '%')
|
||||
->orWhere('firstname', 'LIKE', '%' . $search . '%')
|
||||
->orWhere('email', 'LIKE', '%' . $search . '%')
|
||||
->orWhere('phone', 'LIKE', '%' . $search . '%')
|
||||
->orWhere('phonemobile', 'LIKE', '%' . $search . '%');
|
||||
});
|
||||
}
|
||||
}, true)
|
||||
->rawColumns(['action_edit', 'action_delete', 'id'])
|
||||
->make(true);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue