deplay phase 1
This commit is contained in:
parent
e3dc1afd8e
commit
5a7478907e
68 changed files with 2831 additions and 818 deletions
|
|
@ -0,0 +1,110 @@
|
|||
diff --git a/app/Models/Booking.php b/app/Models/Booking.php
|
||||
index 79a91ba..f3bf11c 100644
|
||||
--- a/app/Models/Booking.php
|
||||
+++ b/app/Models/Booking.php
|
||||
@@ -9,6 +9,7 @@ namespace App\Models;
|
||||
use Carbon\Carbon;
|
||||
use App\Services\Util;
|
||||
use App\Services\Passolution;
|
||||
+use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
@@ -18,7 +19,7 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
* @property int $id
|
||||
* @property Carbon $booking_date
|
||||
* @property int $customer_id
|
||||
- * @property int $lead_id
|
||||
+ * @property int $inquiry_id
|
||||
* @property bool $new_drafts
|
||||
* @property int $sf_guard_user_id
|
||||
* @property int $branch_id
|
||||
@@ -203,13 +204,16 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
*/
|
||||
class Booking extends Model
|
||||
{
|
||||
+ use HasFactory;
|
||||
+
|
||||
protected $connection = 'mysql';
|
||||
|
||||
protected $table = 'booking';
|
||||
|
||||
protected $casts = [
|
||||
'customer_id' => 'int',
|
||||
- 'lead_id' => 'int',
|
||||
+ 'inquiry_id' => 'int',
|
||||
+ 'offer_id' => 'int',
|
||||
'new_drafts' => 'bool',
|
||||
'sf_guard_user_id' => 'int',
|
||||
'branch_id' => 'int',
|
||||
@@ -237,25 +241,24 @@ class Booking extends Model
|
||||
'is_rail_fly' => 'bool',
|
||||
'comfort' => 'bool',
|
||||
'airline_ids' => 'array',
|
||||
- 'participant_pass' => 'bool'
|
||||
- ];
|
||||
-
|
||||
- protected $dates = [
|
||||
- 'booking_date',
|
||||
- 'start_date',
|
||||
- 'end_date',
|
||||
- 'participant_birthdate',
|
||||
- 'final_payment_date',
|
||||
- 'refund_date',
|
||||
- 'lawyer_date',
|
||||
- 'xx_tkt_date'
|
||||
-
|
||||
- ];
|
||||
+ 'participant_pass' => 'bool',
|
||||
+ 'booking_date' => 'datetime',
|
||||
+ 'start_date' => 'datetime',
|
||||
+ 'end_date' => 'datetime',
|
||||
+ 'participant_birthdate' => 'datetime',
|
||||
+ 'final_payment_date' => 'datetime',
|
||||
+ 'refund_date' => 'datetime',
|
||||
+ 'lawyer_date' => 'datetime',
|
||||
+ 'xx_tkt_date' => 'datetime',
|
||||
+ ];
|
||||
+
|
||||
+
|
||||
|
||||
protected $fillable = [
|
||||
'booking_date',
|
||||
'customer_id',
|
||||
- 'lead_id',
|
||||
+ 'inquiry_id',
|
||||
+ 'offer_id',
|
||||
'new_drafts',
|
||||
'sf_guard_user_id',
|
||||
'branch_id',
|
||||
@@ -392,9 +395,29 @@ class Booking extends Model
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * Lead/Inquiry der Buchung.
|
||||
+ * FK-Spalte `inquiry_id` (vormals `lead_id` — Modul 3 Phase 2 Rename).
|
||||
+ * Methodenname bleibt `lead()` für Legacy-Kompatibilität; {@see self::inquiry()}
|
||||
+ * ist der fachlich korrekte Alias und sollte in neuem Code verwendet werden.
|
||||
+ */
|
||||
public function lead()
|
||||
{
|
||||
- return $this->belongsTo(Lead::class);
|
||||
+ return $this->belongsTo(Lead::class, 'inquiry_id');
|
||||
+ }
|
||||
+
|
||||
+ public function inquiry()
|
||||
+ {
|
||||
+ return $this->belongsTo(Lead::class, 'inquiry_id');
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Angebot, aus dem diese Buchung entstanden ist (Modul 6, Ticket B8).
|
||||
+ * Nullable — nicht jede Buchung hat einen Angebots-Vorlauf.
|
||||
+ */
|
||||
+ public function offer()
|
||||
+ {
|
||||
+ return $this->belongsTo(\App\Models\Offer::class);
|
||||
}
|
||||
|
||||
public function sf_guard_user()
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
diff --git a/app/Models/Booking.php b/app/Models/Booking.php
|
||||
index 79a91ba..3d61b89 100644
|
||||
--- a/app/Models/Booking.php
|
||||
+++ b/app/Models/Booking.php
|
||||
@@ -9,6 +9,7 @@ namespace App\Models;
|
||||
use Carbon\Carbon;
|
||||
use App\Services\Util;
|
||||
use App\Services\Passolution;
|
||||
+use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
@@ -203,6 +204,8 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
*/
|
||||
class Booking extends Model
|
||||
{
|
||||
+ use HasFactory;
|
||||
+
|
||||
protected $connection = 'mysql';
|
||||
|
||||
protected $table = 'booking';
|
||||
@@ -237,20 +240,18 @@ class Booking extends Model
|
||||
'is_rail_fly' => 'bool',
|
||||
'comfort' => 'bool',
|
||||
'airline_ids' => 'array',
|
||||
- 'participant_pass' => 'bool'
|
||||
- ];
|
||||
-
|
||||
- protected $dates = [
|
||||
- 'booking_date',
|
||||
- 'start_date',
|
||||
- 'end_date',
|
||||
- 'participant_birthdate',
|
||||
- 'final_payment_date',
|
||||
- 'refund_date',
|
||||
- 'lawyer_date',
|
||||
- 'xx_tkt_date'
|
||||
-
|
||||
- ];
|
||||
+ 'participant_pass' => 'bool',
|
||||
+ 'booking_date' => 'datetime',
|
||||
+ 'start_date' => 'datetime',
|
||||
+ 'end_date' => 'datetime',
|
||||
+ 'participant_birthdate' => 'datetime',
|
||||
+ 'final_payment_date' => 'datetime',
|
||||
+ 'refund_date' => 'datetime',
|
||||
+ 'lawyer_date' => 'datetime',
|
||||
+ 'xx_tkt_date' => 'datetime',
|
||||
+ ];
|
||||
+
|
||||
+
|
||||
|
||||
protected $fillable = [
|
||||
'booking_date',
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
diff --git a/app/Models/Contact.php b/app/Models/Contact.php
|
||||
new file mode 100644
|
||||
index 0000000..6d941e8
|
||||
--- /dev/null
|
||||
+++ b/app/Models/Contact.php
|
||||
@@ -0,0 +1,164 @@
|
||||
+<?php
|
||||
+
|
||||
+namespace App\Models;
|
||||
+
|
||||
+use App\Models\Sym\TravelCountry;
|
||||
+use Carbon\Carbon;
|
||||
+use Illuminate\Database\Eloquent\Builder;
|
||||
+use Illuminate\Database\Eloquent\Collection;
|
||||
+use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
+use Illuminate\Database\Eloquent\Model;
|
||||
+use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
+
|
||||
+/**
|
||||
+ * Kontakt-Modell — saubere Neuimplementierung auf Basis der customer-Tabelle.
|
||||
+ *
|
||||
+ * Unterschiede zum alten Customer-Modell:
|
||||
+ * - Global Scope schließt zusammengeführte Duplikate (merged_into_id IS NOT NULL) aus
|
||||
+ * - merged_into_id + merged_at in $fillable
|
||||
+ * - mergedInto() / mergedContacts() Beziehungen
|
||||
+ *
|
||||
+ * Tabellen-Name: 'customer' (wird in Phase 2 in 'contacts' umbenannt).
|
||||
+ *
|
||||
+ * @property int $id
|
||||
+ * @property int|null $salutation_id
|
||||
+ * @property string|null $title
|
||||
+ * @property string|null $name
|
||||
+ * @property string|null $firstname
|
||||
+ * @property Carbon|null $birthdate
|
||||
+ * @property string|null $company
|
||||
+ * @property string|null $street
|
||||
+ * @property string|null $zip
|
||||
+ * @property string|null $city
|
||||
+ * @property string|null $email
|
||||
+ * @property string|null $phone
|
||||
+ * @property string|null $phonebusiness
|
||||
+ * @property string|null $phonemobile
|
||||
+ * @property string|null $fax
|
||||
+ * @property int|null $merged_into_id
|
||||
+ * @property Carbon|null $merged_at
|
||||
+ * @property Carbon $created_at
|
||||
+ * @property Carbon $updated_at
|
||||
+ * @property-read Contact|null $mergedInto
|
||||
+ * @property-read Collection|Contact[] $mergedContacts
|
||||
+ * @property-read Collection|Lead[] $leads
|
||||
+ * @property-read Collection|Booking[] $bookings
|
||||
+ */
|
||||
+class Contact extends Model
|
||||
+{
|
||||
+ use HasFactory, SoftDeletes;
|
||||
+
|
||||
+ protected $connection = 'mysql';
|
||||
+
|
||||
+ protected $table = 'customer';
|
||||
+
|
||||
+ protected $casts = [
|
||||
+ 'salutation_id' => 'int',
|
||||
+ 'credit_card_type_id' => 'int',
|
||||
+ 'country_id' => 'int',
|
||||
+ 'merged_into_id' => 'int',
|
||||
+ 'birthdate' => 'datetime',
|
||||
+ 'credit_card_expiration_date' => 'datetime',
|
||||
+ 'merged_at' => 'datetime',
|
||||
+ ];
|
||||
+
|
||||
+ protected $fillable = [
|
||||
+ 'salutation_id',
|
||||
+ 'title',
|
||||
+ 'name',
|
||||
+ 'firstname',
|
||||
+ 'birthdate',
|
||||
+ 'company',
|
||||
+ 'street',
|
||||
+ 'zip',
|
||||
+ 'city',
|
||||
+ 'email',
|
||||
+ 'phone',
|
||||
+ 'phonebusiness',
|
||||
+ 'phonemobile',
|
||||
+ 'fax',
|
||||
+ 'bank',
|
||||
+ 'bank_code',
|
||||
+ 'bank_account_number',
|
||||
+ 'credit_card_type_id',
|
||||
+ 'credit_card_number',
|
||||
+ 'credit_card_expiration_date',
|
||||
+ 'participants_remarks',
|
||||
+ 'miscellaneous_remarks',
|
||||
+ 'country_id',
|
||||
+ 'merged_into_id',
|
||||
+ 'merged_at',
|
||||
+ ];
|
||||
+
|
||||
+ /**
|
||||
+ * Globaler Scope: zusammengeführte Duplikate werden standardmäßig ausgeblendet.
|
||||
+ * Für Zugriff auf alle inkl. Duplikate: Contact::withoutGlobalScope('not_merged')
|
||||
+ */
|
||||
+ protected static function booted(): void
|
||||
+ {
|
||||
+ static::addGlobalScope('not_merged', function (Builder $query) {
|
||||
+ $query->whereNull('merged_into_id');
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
+ // ── Beziehungen ──────────────────────────────────────────────────────────
|
||||
+
|
||||
+ public function mergedInto(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
+ {
|
||||
+ return $this->belongsTo(Contact::class, 'merged_into_id')
|
||||
+ ->withoutGlobalScope('not_merged');
|
||||
+ }
|
||||
+
|
||||
+ public function mergedContacts(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
+ {
|
||||
+ return $this->hasMany(Contact::class, 'merged_into_id')
|
||||
+ ->withoutGlobalScope('not_merged');
|
||||
+ }
|
||||
+
|
||||
+ public function leads(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
+ {
|
||||
+ return $this->hasMany(Lead::class, 'customer_id')->orderByDesc('created_at');
|
||||
+ }
|
||||
+
|
||||
+ public function bookings(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
+ {
|
||||
+ return $this->hasMany(Booking::class, 'customer_id')->orderByDesc('created_at');
|
||||
+ }
|
||||
+
|
||||
+ public function salutation(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
+ {
|
||||
+ return $this->belongsTo(Salutation::class);
|
||||
+ }
|
||||
+
|
||||
+ public function travel_country(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
+ {
|
||||
+ return $this->belongsTo(TravelCountry::class, 'country_id');
|
||||
+ }
|
||||
+
|
||||
+ // ── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||
+
|
||||
+ public function fullName(): string
|
||||
+ {
|
||||
+ if ($this->firstname) {
|
||||
+ return $this->firstname . ' ' . $this->name;
|
||||
+ }
|
||||
+ return (string) $this->name;
|
||||
+ }
|
||||
+
|
||||
+ public function isMerged(): bool
|
||||
+ {
|
||||
+ return $this->merged_into_id !== null;
|
||||
+ }
|
||||
+
|
||||
+ public static function getCountriesArray(): \Illuminate\Support\Collection
|
||||
+ {
|
||||
+ return TravelCountry::where('is_customer_country', 1)->get()->pluck('name', 'id');
|
||||
+ }
|
||||
+
|
||||
+ public static $salutationType = [
|
||||
+ 1 => 'Herr',
|
||||
+ 2 => 'Frau',
|
||||
+ 3 => 'Divers/keine Anrede',
|
||||
+ 4 => 'Firma',
|
||||
+ ];
|
||||
+}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
diff --git a/app/Http/Controllers/API/BookingController.php b/app/Http/Controllers/API/BookingController.php
|
||||
index e47cd07..a020ad4 100755
|
||||
--- a/app/Http/Controllers/API/BookingController.php
|
||||
+++ b/app/Http/Controllers/API/BookingController.php
|
||||
@@ -8,42 +8,35 @@ use App\Services\BookingImport;
|
||||
|
||||
class BookingController extends Controller
|
||||
{
|
||||
- private $successStatus = 200;
|
||||
- private $successKey = 'f6077389c9ce710e554763a5de02c8ec';
|
||||
-
|
||||
- protected $draftRepo;
|
||||
+ private int $successStatus = 200;
|
||||
+ private string $successKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
-
|
||||
+ $this->successKey = config('app.success_key');
|
||||
}
|
||||
|
||||
public function import()
|
||||
{
|
||||
|
||||
$request = \Request::all();
|
||||
- if(!isset($request['key']) || $request['key'] !== $this->successKey){
|
||||
+ if (!isset($request['key']) || $request['key'] !== $this->successKey) {
|
||||
return response()->json(['error' => "key"], 401);
|
||||
}
|
||||
$travel_booking = TravelBooking::find($request['travel_booking_id']);
|
||||
|
||||
- //# vor testing
|
||||
- //$travel_booking = TravelBooking::find(2922);
|
||||
- if(!isset($travel_booking) || !$travel_booking){
|
||||
+ if (!$travel_booking) {
|
||||
return response()->json(['error' => 'no-booking-found'], $this->successStatus);
|
||||
}
|
||||
|
||||
$booking = BookingImport::importFrom($travel_booking);
|
||||
|
||||
- $ret= [
|
||||
- 'url_v1' => make_old_url('/index.php/booking/'.$booking->id.'/edit'),
|
||||
+ $ret = [
|
||||
+ 'url_v1' => make_old_url('/index.php/booking/' . $booking->id . '/edit'),
|
||||
'url_v3' => route('booking_detail', $booking->id),
|
||||
'lead_id' => $booking->lead_id
|
||||
];
|
||||
return response()->json(['success' => "import", "ret" => $ret], $this->successStatus);
|
||||
- //return response()->json(['error' => 'no-node'], $this->successStatus);
|
||||
|
||||
}
|
||||
-
|
||||
-
|
||||
}
|
||||
diff --git a/app/Http/Controllers/ContactController.php b/app/Http/Controllers/ContactController.php
|
||||
new file mode 100644
|
||||
index 0000000..08ef241
|
||||
--- /dev/null
|
||||
+++ b/app/Http/Controllers/ContactController.php
|
||||
@@ -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('lead')->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('customer')->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('customer')->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('customer')->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('customer')->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('customer')
|
||||
+ ->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('customer')
|
||||
+ ->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('customer')
|
||||
+ ->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('customer.*')->withCount(['leads', 'bookings'])
|
||||
+ : Contact::select('customer.*')->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('customer.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);
|
||||
+ }
|
||||
+}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
diff --git a/app/Models/Customer.php b/app/Models/Customer.php
|
||||
index 94e618e..436b3ee 100644
|
||||
--- a/app/Models/Customer.php
|
||||
+++ b/app/Models/Customer.php
|
||||
@@ -9,6 +9,7 @@ namespace App\Models;
|
||||
use App\Models\Sym\TravelCountry;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
+use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class Customer extends Model
|
||||
{
|
||||
+ use HasFactory;
|
||||
+
|
||||
protected $connection = 'mysql';
|
||||
|
||||
protected $table = 'customer';
|
||||
@@ -90,13 +93,12 @@ class Customer extends Model
|
||||
protected $casts = [
|
||||
'salutation_id' => 'int',
|
||||
'credit_card_type_id' => 'int',
|
||||
- 'country_id' => 'int'
|
||||
- ];
|
||||
+ 'country_id' => 'int',
|
||||
+ 'birthdate' => 'datetime',
|
||||
+ 'credit_card_expiration_date' => 'datetime',
|
||||
+ ];
|
||||
|
||||
- protected $dates = [
|
||||
- 'birthdate',
|
||||
- 'credit_card_expiration_date'
|
||||
- ];
|
||||
+
|
||||
|
||||
protected $fillable = [
|
||||
'salutation_id',
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
diff --git a/app/Models/Lead.php b/app/Models/Lead.php
|
||||
index 8b84b9e..227bd38 100644
|
||||
--- a/app/Models/Lead.php
|
||||
+++ b/app/Models/Lead.php
|
||||
@@ -9,6 +9,7 @@ namespace App\Models;
|
||||
use Carbon\Carbon;
|
||||
use App\Services\Passolution;
|
||||
use App\Models\Lead as ModelsLead;
|
||||
+use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
@@ -107,6 +108,8 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
*/
|
||||
class Lead extends Model
|
||||
{
|
||||
+ use HasFactory;
|
||||
+
|
||||
protected $connection = 'mysql';
|
||||
|
||||
protected $table = 'lead';
|
||||
@@ -126,16 +129,14 @@ class Lead extends Model
|
||||
'travelcategory_id' => 'int',
|
||||
'price' => 'float',
|
||||
'pax' => 'int',
|
||||
- 'participant_salutation_id' => 'int'
|
||||
+ 'participant_salutation_id' => 'int',
|
||||
+ 'request_date' => 'datetime',
|
||||
+ 'travelperiod_start' => 'datetime',
|
||||
+ 'travelperiod_end' => 'datetime',
|
||||
+ 'next_due_date' => 'datetime',
|
||||
+ 'participant_birthdate' => 'datetime',
|
||||
];
|
||||
|
||||
- protected $dates = [
|
||||
- 'request_date',
|
||||
- 'travelperiod_start',
|
||||
- 'travelperiod_end',
|
||||
- 'next_due_date',
|
||||
- 'participant_birthdate'
|
||||
- ];
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id',
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
diff --git a/app/Repositories/BookingPDFRepository.php b/app/Repositories/BookingPDFRepository.php
|
||||
index 505ddaf..e5ba139 100644
|
||||
--- a/app/Repositories/BookingPDFRepository.php
|
||||
+++ b/app/Repositories/BookingPDFRepository.php
|
||||
@@ -22,7 +22,7 @@ class BookingPDFRepository extends BaseRepository
|
||||
public function __construct(Booking $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
- $this->prepath = Storage::disk('public')->getAdapter()->getPathPrefix();
|
||||
+ $this->prepath = Storage::disk('public')->path('');
|
||||
}
|
||||
|
||||
public function update($data)
|
||||
@@ -63,13 +63,13 @@ class BookingPDFRepository extends BaseRepository
|
||||
{
|
||||
$document = new stdClass();
|
||||
$document->name = 'registration';
|
||||
- $document->number = $this->model->lead_id;
|
||||
+ $document->number = $this->model->inquiry_id;
|
||||
$document->title = 'BUCHUNGSAUFTRAG';
|
||||
$document->voucher = null;
|
||||
$document->date = now();
|
||||
$document->total = $this->model->getPriceRaw();
|
||||
$dir = $this->getDirPath('pdf', 'booking', $document->date->format('Y'));
|
||||
- $filename = "Buchnungsauftrag-" . $this->model->lead_id . ".pdf";
|
||||
+ $filename = "Buchnungsauftrag-" . $this->model->inquiry_id . ".pdf";
|
||||
$pdf_file = new CreatePDF('pdf.booking_registration');
|
||||
$data = [
|
||||
'booking' => $this->model,
|
||||
@@ -85,7 +85,7 @@ class BookingPDFRepository extends BaseRepository
|
||||
{
|
||||
$document = new stdClass();
|
||||
$document->name = 'confirmation';
|
||||
- $document->number = $this->model->lead_id;
|
||||
+ $document->number = $this->model->inquiry_id;
|
||||
$document->title = 'REISEBESTÄTIGUNG';
|
||||
$document->voucher = null;
|
||||
$document->date = now();
|
||||
@@ -104,7 +104,7 @@ class BookingPDFRepository extends BaseRepository
|
||||
$document->final_payment_date = date('Y-m-d');
|
||||
}
|
||||
$dir = $this->getDirPath('pdf', 'booking', $document->date->format('Y'));
|
||||
- $filename = "Reisebestätigung-" . $this->model->lead_id . ".pdf";
|
||||
+ $filename = "Reisebestätigung-" . $this->model->inquiry_id . ".pdf";
|
||||
|
||||
$pdf_file = new CreatePDF('pdf.booking_confirmation');
|
||||
$data = [
|
||||
@@ -160,14 +160,14 @@ class BookingPDFRepository extends BaseRepository
|
||||
{
|
||||
$document = new stdClass();
|
||||
$document->name = 'voucher';
|
||||
- $document->number = $this->model->lead_id;
|
||||
+ $document->number = $this->model->inquiry_id;
|
||||
$document->name = 'voucher';
|
||||
$document->title = $agency ? 'VOUCHER Agentur' : 'VOUCHER';
|
||||
$document->voucher = $agency ? 'agency' : 'client';
|
||||
$document->date = now();
|
||||
|
||||
$dir = $this->getDirPath('pdf', 'voucher', $document->date->format('Y'));
|
||||
- $filename = ($agency ? 'VoucherAgentur' : 'Voucher') . "-" . $this->model->lead_id . ".pdf";
|
||||
+ $filename = ($agency ? 'VoucherAgentur' : 'Voucher') . "-" . $this->model->inquiry_id . ".pdf";
|
||||
|
||||
$pdf_file = new CreatePDF('pdf.booking_voucher');
|
||||
$data = [
|
||||
@@ -224,7 +224,7 @@ class BookingPDFRepository extends BaseRepository
|
||||
//init document
|
||||
$document = new stdClass();
|
||||
$document->name = $identifier;
|
||||
- $document->number = $this->model->lead_id;
|
||||
+ $document->number = $this->model->inquiry_id;
|
||||
$document->title = 'STORNOBESTÄTIGUNG';
|
||||
$document->voucher = null;
|
||||
$document->date = Carbon::parse($data['storno_print']);
|
||||
@@ -253,7 +253,7 @@ class BookingPDFRepository extends BaseRepository
|
||||
|
||||
|
||||
$dir = $this->getDirPath('pdf', 'storno', $document->date->format('Y'));
|
||||
- $filename = "Reisestornierung -" . $this->model->lead_id . ".pdf";
|
||||
+ $filename = "Reisestornierung -" . $this->model->inquiry_id . ".pdf";
|
||||
|
||||
$pdf_file = new CreatePDF('pdf.booking_storno');
|
||||
$data = [
|
||||
@@ -288,7 +288,9 @@ class BookingPDFRepository extends BaseRepository
|
||||
$fill = [
|
||||
'booking_id' => $this->model->id,
|
||||
'customer_id' => $this->model->customer_id,
|
||||
- 'lead_id' => $this->model->lead_id,
|
||||
+ // booking_documents.lead_id ist ein Shadow-Feld von booking.inquiry_id;
|
||||
+ // die Spalte selbst wird von Phase 2 nicht umbenannt.
|
||||
+ 'lead_id' => $this->model->inquiry_id,
|
||||
'identifier' => $identifier,
|
||||
'filename' => $filename,
|
||||
'dir' => $dir,
|
||||
diff --git a/app/Repositories/CustomerMailRepository.php b/app/Repositories/CustomerMailRepository.php
|
||||
index c1b1226..6397f0c 100644
|
||||
--- a/app/Repositories/CustomerMailRepository.php
|
||||
+++ b/app/Repositories/CustomerMailRepository.php
|
||||
@@ -135,7 +135,8 @@ class CustomerMailRepository extends BaseRepository {
|
||||
$customer_mail->fill([
|
||||
'booking_id' => $booking->id,
|
||||
'customer_id' => $booking->customer_id,
|
||||
- 'lead_id' => $booking->lead_id,
|
||||
+ // customer_mails.lead_id-Spalte bleibt unverändert; Wert kommt aus booking.inquiry_id
|
||||
+ 'lead_id' => $booking->inquiry_id,
|
||||
'is_answer' => $is_answer,
|
||||
'reply_id' => $reply_id,
|
||||
'email' => $mail_from,
|
||||
@@ -153,7 +154,8 @@ class CustomerMailRepository extends BaseRepository {
|
||||
$customer_mail = CustomerMail::create([
|
||||
'booking_id' => $booking->id,
|
||||
'customer_id' => $booking->customer_id,
|
||||
- 'lead_id' => $booking->lead_id,
|
||||
+ // customer_mails.lead_id-Spalte bleibt unverändert; Wert kommt aus booking.inquiry_id
|
||||
+ 'lead_id' => $booking->inquiry_id,
|
||||
'is_answer' => $is_answer,
|
||||
'reply_id' => $reply_id,
|
||||
'email' => $mail_from,
|
||||
@@ -300,7 +302,7 @@ class CustomerMailRepository extends BaseRepository {
|
||||
$value->id = $customer_mail->booking_id;
|
||||
$value->booking = $booking;
|
||||
$value->show = 'single';
|
||||
- $value->lead_title_id = " - (".$value->booking->lead_id.")";
|
||||
+ $value->lead_title_id = " - (".$value->booking->inquiry_id.")";
|
||||
|
||||
|
||||
$tmp = [];
|
||||
@@ -342,7 +344,7 @@ class CustomerMailRepository extends BaseRepository {
|
||||
$value->booking = $booking;
|
||||
$value->show = 'single';
|
||||
$value->draft = true;
|
||||
- $value->lead_title_id = " - (".$value->booking->lead_id.")";
|
||||
+ $value->lead_title_id = " - (".$value->booking->inquiry_id.")";
|
||||
|
||||
}else{
|
||||
//multi
|
||||
@@ -379,8 +381,8 @@ class CustomerMailRepository extends BaseRepository {
|
||||
$value->draft = false;
|
||||
$value->booking = $booking;
|
||||
$value->message = "";
|
||||
- $value->subject = " - (".$value->booking->lead_id.")";
|
||||
- $value->lead_title_id = " - (".$value->booking->lead_id.")";
|
||||
+ $value->subject = " - (".$value->booking->inquiry_id.")";
|
||||
+ $value->lead_title_id = " - (".$value->booking->inquiry_id.")";
|
||||
$value->s_placeholder = "Betreff des Kunden";
|
||||
$value->m_placeholder = "Nachricht des Kunden";
|
||||
if(isset($data['customer_mail_id']) && $customer_mail = CustomerMail::find($data['customer_mail_id'])){
|
||||
diff --git a/app/Repositories/LeadRepository.php b/app/Repositories/LeadRepository.php
|
||||
index 929426d..ad0ac87 100644
|
||||
--- a/app/Repositories/LeadRepository.php
|
||||
+++ b/app/Repositories/LeadRepository.php
|
||||
@@ -134,7 +134,7 @@ class LeadRepository extends BaseRepository {
|
||||
$data = [
|
||||
'booking_date' => date('Y-m-d'), //now
|
||||
'customer_id' => $this->model->customer->id,
|
||||
- 'lead_id' => $this->model->id,
|
||||
+ 'inquiry_id' => $this->model->id,
|
||||
'new_drafts' => 1,
|
||||
'sf_guard_user_id' => $this->model->sf_guard_user_id,
|
||||
'branch_id' => 4,
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
diff --git a/app/Repositories/BookingPDFRepository.php b/app/Repositories/BookingPDFRepository.php
|
||||
index 505ddaf..dc4d313 100644
|
||||
--- a/app/Repositories/BookingPDFRepository.php
|
||||
+++ b/app/Repositories/BookingPDFRepository.php
|
||||
@@ -22,7 +22,7 @@ class BookingPDFRepository extends BaseRepository
|
||||
public function __construct(Booking $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
- $this->prepath = Storage::disk('public')->getAdapter()->getPathPrefix();
|
||||
+ $this->prepath = Storage::disk('public')->path('');
|
||||
}
|
||||
|
||||
public function update($data)
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
diff --git a/app/Console/Commands/ContactsFindDuplicates.php b/app/Console/Commands/ContactsFindDuplicates.php
|
||||
new file mode 100644
|
||||
index 0000000..cef982d
|
||||
--- /dev/null
|
||||
+++ b/app/Console/Commands/ContactsFindDuplicates.php
|
||||
@@ -0,0 +1,160 @@
|
||||
+<?php
|
||||
+
|
||||
+namespace App\Console\Commands;
|
||||
+
|
||||
+use Illuminate\Console\Command;
|
||||
+use Illuminate\Support\Facades\DB;
|
||||
+
|
||||
+/**
|
||||
+ * Phase 1 — Schritt 1: Duplikate identifizieren
|
||||
+ *
|
||||
+ * Sucht Kunden-Datensätze, die vermutlich dieselbe Person repräsentieren.
|
||||
+ * Drei Erkennungs-Stufen (absteigend nach Konfidenz):
|
||||
+ *
|
||||
+ * HIGH — gleiche E-Mail (nicht leer)
|
||||
+ * MEDIUM — gleicher Name + Vorname + Geburtsdatum
|
||||
+ * LOW — gleicher Name + Vorname + PLZ
|
||||
+ *
|
||||
+ * Verwendung:
|
||||
+ * php artisan contacts:find-duplicates
|
||||
+ * php artisan contacts:find-duplicates --export=duplicates.csv
|
||||
+ * php artisan contacts:find-duplicates --confidence=HIGH
|
||||
+ */
|
||||
+class ContactsFindDuplicates extends Command
|
||||
+{
|
||||
+ protected $signature = 'contacts:find-duplicates
|
||||
+ {--export= : Pfad zur CSV-Ausgabedatei}
|
||||
+ {--confidence= : Nur diese Konfidenz-Stufe ausgeben (HIGH|MEDIUM|LOW)}';
|
||||
+
|
||||
+ protected $description = 'Identifiziert doppelte Customer-Datensätze anhand von E-Mail, Name/Geburtsdatum oder Name/PLZ';
|
||||
+
|
||||
+ public function handle(): int
|
||||
+ {
|
||||
+ $this->info('Suche nach Duplikaten in der customer-Tabelle...');
|
||||
+ $this->newLine();
|
||||
+
|
||||
+ $groups = collect();
|
||||
+
|
||||
+ // ── HIGH: gleiche E-Mail ──────────────────────────────────────────
|
||||
+ if ($this->shouldCheck('HIGH')) {
|
||||
+ $emailDupes = DB::table('customer')
|
||||
+ ->select('email', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids'))
|
||||
+ ->whereNotNull('email')
|
||||
+ ->where('email', '!=', '')
|
||||
+ ->whereNull('merged_into_id')
|
||||
+ ->groupBy('email')
|
||||
+ ->having('cnt', '>', 1)
|
||||
+ ->get();
|
||||
+
|
||||
+ foreach ($emailDupes as $row) {
|
||||
+ $groups->push([
|
||||
+ 'confidence' => 'HIGH',
|
||||
+ 'reason' => 'E-Mail: ' . $row->email,
|
||||
+ 'ids' => $row->ids,
|
||||
+ 'count' => $row->cnt,
|
||||
+ ]);
|
||||
+ }
|
||||
+
|
||||
+ $this->line(sprintf('<fg=green>HIGH</> (gleiche E-Mail): %d Gruppen', $emailDupes->count()));
|
||||
+ }
|
||||
+
|
||||
+ // ── MEDIUM: Name + Vorname + Geburtsdatum ────────────────────────
|
||||
+ if ($this->shouldCheck('MEDIUM')) {
|
||||
+ $nameBdDupes = DB::table('customer')
|
||||
+ ->select(
|
||||
+ 'name', 'firstname', 'birthdate',
|
||||
+ DB::raw('COUNT(*) as cnt'),
|
||||
+ DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids')
|
||||
+ )
|
||||
+ ->whereNotNull('name')
|
||||
+ ->whereNotNull('firstname')
|
||||
+ ->whereNotNull('birthdate')
|
||||
+ ->whereNull('merged_into_id')
|
||||
+ ->groupBy('name', 'firstname', 'birthdate')
|
||||
+ ->having('cnt', '>', 1)
|
||||
+ ->get();
|
||||
+
|
||||
+ foreach ($nameBdDupes as $row) {
|
||||
+ $groups->push([
|
||||
+ 'confidence' => 'MEDIUM',
|
||||
+ 'reason' => "Name: {$row->firstname} {$row->name}, GD: {$row->birthdate}",
|
||||
+ 'ids' => $row->ids,
|
||||
+ 'count' => $row->cnt,
|
||||
+ ]);
|
||||
+ }
|
||||
+
|
||||
+ $this->line(sprintf('<fg=yellow>MEDIUM</> (Name+GD): %d Gruppen', $nameBdDupes->count()));
|
||||
+ }
|
||||
+
|
||||
+ // ── LOW: Name + Vorname + PLZ ─────────────────────────────────────
|
||||
+ if ($this->shouldCheck('LOW')) {
|
||||
+ $nameZipDupes = DB::table('customer')
|
||||
+ ->select(
|
||||
+ 'name', 'firstname', 'zip',
|
||||
+ DB::raw('COUNT(*) as cnt'),
|
||||
+ DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids')
|
||||
+ )
|
||||
+ ->whereNotNull('name')
|
||||
+ ->whereNotNull('firstname')
|
||||
+ ->whereNotNull('zip')
|
||||
+ ->where('zip', '!=', '')
|
||||
+ ->whereNull('merged_into_id')
|
||||
+ ->groupBy('name', 'firstname', 'zip')
|
||||
+ ->having('cnt', '>', 1)
|
||||
+ ->get();
|
||||
+
|
||||
+ foreach ($nameZipDupes as $row) {
|
||||
+ $groups->push([
|
||||
+ 'confidence' => 'LOW',
|
||||
+ 'reason' => "Name: {$row->firstname} {$row->name}, PLZ: {$row->zip}",
|
||||
+ 'ids' => $row->ids,
|
||||
+ 'count' => $row->cnt,
|
||||
+ ]);
|
||||
+ }
|
||||
+
|
||||
+ $this->line(sprintf('<fg=red>LOW</> (Name+PLZ): %d Gruppen', $nameZipDupes->count()));
|
||||
+ }
|
||||
+
|
||||
+ $this->newLine();
|
||||
+ $this->info(sprintf('Gesamt: %d Duplikat-Gruppen gefunden', $groups->count()));
|
||||
+
|
||||
+ if ($groups->isEmpty()) {
|
||||
+ $this->info('Keine Duplikate — nichts zu tun.');
|
||||
+ return self::SUCCESS;
|
||||
+ }
|
||||
+
|
||||
+ // Tabellen-Ausgabe
|
||||
+ $this->table(
|
||||
+ ['Konfidenz', 'Grund', 'IDs (neueste zuerst)', 'Anzahl'],
|
||||
+ $groups->map(fn ($g) => [$g['confidence'], $g['reason'], $g['ids'], $g['count']])->all()
|
||||
+ );
|
||||
+
|
||||
+ // CSV-Export
|
||||
+ if ($export = $this->option('export')) {
|
||||
+ $this->exportCsv($groups->all(), $export);
|
||||
+ $this->info("CSV gespeichert: {$export}");
|
||||
+ } else {
|
||||
+ $this->newLine();
|
||||
+ $this->line('Tipp: --export=duplicates.csv für CSV-Export');
|
||||
+ $this->line('Tipp: php artisan contacts:merge-duplicates --dry-run zum Prüfen');
|
||||
+ }
|
||||
+
|
||||
+ return self::SUCCESS;
|
||||
+ }
|
||||
+
|
||||
+ private function shouldCheck(string $level): bool
|
||||
+ {
|
||||
+ $filter = strtoupper((string) $this->option('confidence'));
|
||||
+ return $filter === '' || $filter === $level;
|
||||
+ }
|
||||
+
|
||||
+ private function exportCsv(array $groups, string $path): void
|
||||
+ {
|
||||
+ $handle = fopen($path, 'w');
|
||||
+ fputcsv($handle, ['Konfidenz', 'Grund', 'IDs (neueste zuerst)', 'Anzahl']);
|
||||
+ foreach ($groups as $group) {
|
||||
+ fputcsv($handle, [$group['confidence'], $group['reason'], $group['ids'], $group['count']]);
|
||||
+ }
|
||||
+ fclose($handle);
|
||||
+ }
|
||||
+}
|
||||
diff --git a/app/Console/Commands/ContactsMergeDuplicates.php b/app/Console/Commands/ContactsMergeDuplicates.php
|
||||
new file mode 100644
|
||||
index 0000000..e51ca46
|
||||
--- /dev/null
|
||||
+++ b/app/Console/Commands/ContactsMergeDuplicates.php
|
||||
@@ -0,0 +1,233 @@
|
||||
+<?php
|
||||
+
|
||||
+namespace App\Console\Commands;
|
||||
+
|
||||
+use Illuminate\Console\Command;
|
||||
+use Illuminate\Support\Facades\DB;
|
||||
+
|
||||
+/**
|
||||
+ * Phase 1 — Schritt 2: Duplikate zusammenführen
|
||||
+ *
|
||||
+ * Strategie: Der neueste Datensatz (höchstes updated_at, dann höchste id)
|
||||
+ * wird Master. Alle anderen Datensätze derselben Gruppe erhalten
|
||||
+ * merged_into_id = master_id und werden nicht mehr zurückgegeben.
|
||||
+ *
|
||||
+ * Alle FK-Referenzen in lead, booking, customer_mails, lead_mails
|
||||
+ * werden auf den Master umgestellt.
|
||||
+ *
|
||||
+ * Verwendung:
|
||||
+ * php artisan contacts:merge-duplicates --dry-run # Vorschau, keine Änderung
|
||||
+ * php artisan contacts:merge-duplicates --confidence=HIGH # Nur sichere Duplikate
|
||||
+ * php artisan contacts:merge-duplicates # Ausführen
|
||||
+ */
|
||||
+class ContactsMergeDuplicates extends Command
|
||||
+{
|
||||
+ protected $signature = 'contacts:merge-duplicates
|
||||
+ {--dry-run : Zeigt was passieren würde, ohne Daten zu ändern}
|
||||
+ {--confidence= : Nur diese Konfidenz-Stufe verarbeiten (HIGH|MEDIUM|LOW)}
|
||||
+ {--force : Überspringt Sicherheitsabfrage}';
|
||||
+
|
||||
+ protected $description = 'Führt doppelte Customer-Datensätze zusammen (neuester wird Master)';
|
||||
+
|
||||
+ private bool $dryRun = false;
|
||||
+ private int $mergedCount = 0;
|
||||
+ private int $updatedLeads = 0;
|
||||
+ private int $updatedBookings = 0;
|
||||
+
|
||||
+ public function handle(): int
|
||||
+ {
|
||||
+ $this->dryRun = (bool) $this->option('dry-run');
|
||||
+
|
||||
+ if ($this->dryRun) {
|
||||
+ $this->warn('DRY-RUN Modus — keine Daten werden verändert');
|
||||
+ } else {
|
||||
+ $this->warn('ACHTUNG: Diese Operation verändert Produktionsdaten.');
|
||||
+ if (!$this->option('force') && !$this->confirm('Fortfahren?')) {
|
||||
+ $this->info('Abgebrochen.');
|
||||
+ return self::SUCCESS;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ $this->newLine();
|
||||
+
|
||||
+ DB::transaction(function () {
|
||||
+ $this->processLevel(
|
||||
+ 'HIGH',
|
||||
+ fn () => $this->findByEmail()
|
||||
+ );
|
||||
+
|
||||
+ $this->processLevel(
|
||||
+ 'MEDIUM',
|
||||
+ fn () => $this->findByNameBirthdate()
|
||||
+ );
|
||||
+
|
||||
+ $this->processLevel(
|
||||
+ 'LOW',
|
||||
+ fn () => $this->findByNameZip()
|
||||
+ );
|
||||
+ });
|
||||
+
|
||||
+ $this->newLine();
|
||||
+ $this->info(sprintf(
|
||||
+ '%s %d Duplikate zusammengeführt | %d leads aktualisiert | %d bookings aktualisiert',
|
||||
+ $this->dryRun ? '[DRY-RUN]' : '',
|
||||
+ $this->mergedCount,
|
||||
+ $this->updatedLeads,
|
||||
+ $this->updatedBookings
|
||||
+ ));
|
||||
+
|
||||
+ if ($this->dryRun) {
|
||||
+ $this->newLine();
|
||||
+ $this->line('Zum Ausführen: php artisan contacts:merge-duplicates');
|
||||
+ }
|
||||
+
|
||||
+ return self::SUCCESS;
|
||||
+ }
|
||||
+
|
||||
+ // ─────────────────────────────────────────────────────────────────────────
|
||||
+ // Duplikat-Gruppen ermitteln
|
||||
+ // ─────────────────────────────────────────────────────────────────────────
|
||||
+
|
||||
+ private function findByEmail(): array
|
||||
+ {
|
||||
+ return DB::table('customer')
|
||||
+ ->select('email', DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
|
||||
+ ->whereNotNull('email')
|
||||
+ ->where('email', '!=', '')
|
||||
+ ->whereNull('merged_into_id')
|
||||
+ ->groupBy('email')
|
||||
+ ->having(DB::raw('COUNT(*)'), '>', 1)
|
||||
+ ->pluck('ids')
|
||||
+ ->map(fn ($ids) => explode(',', $ids))
|
||||
+ ->all();
|
||||
+ }
|
||||
+
|
||||
+ private function findByNameBirthdate(): array
|
||||
+ {
|
||||
+ return DB::table('customer')
|
||||
+ ->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
|
||||
+ ->whereNotNull('name')
|
||||
+ ->whereNotNull('firstname')
|
||||
+ ->whereNotNull('birthdate')
|
||||
+ ->whereNull('merged_into_id')
|
||||
+ ->groupBy('name', 'firstname', 'birthdate')
|
||||
+ ->having(DB::raw('COUNT(*)'), '>', 1)
|
||||
+ ->pluck('ids')
|
||||
+ ->map(fn ($ids) => explode(',', $ids))
|
||||
+ ->all();
|
||||
+ }
|
||||
+
|
||||
+ private function findByNameZip(): array
|
||||
+ {
|
||||
+ return DB::table('customer')
|
||||
+ ->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
|
||||
+ ->whereNotNull('name')
|
||||
+ ->whereNotNull('firstname')
|
||||
+ ->whereNotNull('zip')
|
||||
+ ->where('zip', '!=', '')
|
||||
+ ->whereNull('merged_into_id')
|
||||
+ ->groupBy('name', 'firstname', 'zip')
|
||||
+ ->having(DB::raw('COUNT(*)'), '>', 1)
|
||||
+ ->pluck('ids')
|
||||
+ ->map(fn ($ids) => explode(',', $ids))
|
||||
+ ->all();
|
||||
+ }
|
||||
+
|
||||
+ // ─────────────────────────────────────────────────────────────────────────
|
||||
+ // Verarbeitung
|
||||
+ // ─────────────────────────────────────────────────────────────────────────
|
||||
+
|
||||
+ private function processLevel(string $level, callable $finder): void
|
||||
+ {
|
||||
+ if ($filter = $this->option('confidence')) {
|
||||
+ if (strtoupper($filter) !== $level) {
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ $groups = $finder();
|
||||
+
|
||||
+ if (empty($groups)) {
|
||||
+ $this->line("[{$level}] Keine Duplikate.");
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ $this->line(sprintf('[%s] %d Gruppe(n) gefunden', $level, count($groups)));
|
||||
+
|
||||
+ foreach ($groups as $ids) {
|
||||
+ $masterId = (int) $ids[0]; // erster = neuester
|
||||
+ $duplicateIds = array_map('intval', array_slice($ids, 1));
|
||||
+
|
||||
+ $this->line(sprintf(
|
||||
+ ' Master: #%d ← Duplikate: %s',
|
||||
+ $masterId,
|
||||
+ implode(', ', array_map(fn ($id) => '#' . $id, $duplicateIds))
|
||||
+ ));
|
||||
+
|
||||
+ foreach ($duplicateIds as $dupeId) {
|
||||
+ $this->mergeInto($masterId, $dupeId);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private function mergeInto(int $masterId, int $dupeId): void
|
||||
+ {
|
||||
+ // 1. Leads umhängen
|
||||
+ $leadCount = DB::table('lead')->where('customer_id', $dupeId)->count();
|
||||
+ if ($leadCount > 0) {
|
||||
+ $this->line(" lead.customer_id: {$leadCount} Zeile(n) → #{$masterId}");
|
||||
+ if (!$this->dryRun) {
|
||||
+ DB::table('lead')
|
||||
+ ->where('customer_id', $dupeId)
|
||||
+ ->update(['customer_id' => $masterId]);
|
||||
+ }
|
||||
+ $this->updatedLeads += $leadCount;
|
||||
+ }
|
||||
+
|
||||
+ // 2. Bookings umhängen
|
||||
+ $bookingCount = DB::table('booking')->where('customer_id', $dupeId)->count();
|
||||
+ if ($bookingCount > 0) {
|
||||
+ $this->line(" booking.customer_id: {$bookingCount} Zeile(n) → #{$masterId}");
|
||||
+ if (!$this->dryRun) {
|
||||
+ DB::table('booking')
|
||||
+ ->where('customer_id', $dupeId)
|
||||
+ ->update(['customer_id' => $masterId]);
|
||||
+ }
|
||||
+ $this->updatedBookings += $bookingCount;
|
||||
+ }
|
||||
+
|
||||
+ // 3. customer_mails umhängen
|
||||
+ $mailCount = DB::table('customer_mails')->where('customer_id', $dupeId)->count();
|
||||
+ if ($mailCount > 0) {
|
||||
+ $this->line(" customer_mails.customer_id: {$mailCount} Zeile(n) → #{$masterId}");
|
||||
+ if (!$this->dryRun) {
|
||||
+ DB::table('customer_mails')
|
||||
+ ->where('customer_id', $dupeId)
|
||||
+ ->update(['customer_id' => $masterId]);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // 4. lead_mails umhängen
|
||||
+ $leadMailCount = DB::table('lead_mails')->where('customer_id', $dupeId)->count();
|
||||
+ if ($leadMailCount > 0) {
|
||||
+ $this->line(" lead_mails.customer_id: {$leadMailCount} Zeile(n) → #{$masterId}");
|
||||
+ if (!$this->dryRun) {
|
||||
+ DB::table('lead_mails')
|
||||
+ ->where('customer_id', $dupeId)
|
||||
+ ->update(['customer_id' => $masterId]);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // 5. Duplikat als zusammengeführt markieren
|
||||
+ if (!$this->dryRun) {
|
||||
+ DB::table('customer')
|
||||
+ ->where('id', $dupeId)
|
||||
+ ->update([
|
||||
+ 'merged_into_id' => $masterId,
|
||||
+ 'merged_at' => now(),
|
||||
+ ]);
|
||||
+ }
|
||||
+
|
||||
+ $this->mergedCount++;
|
||||
+ }
|
||||
+}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
diff --git a/config/filesystems.php b/config/filesystems.php
|
||||
index 2318ca2..97762c8 100755
|
||||
--- a/config/filesystems.php
|
||||
+++ b/config/filesystems.php
|
||||
@@ -72,6 +72,12 @@ return [
|
||||
'url' => env('APP_URL').'/storage/booking',
|
||||
'visibility' => 'public',
|
||||
],
|
||||
+ 'offer' => [
|
||||
+ 'driver' => 'local',
|
||||
+ 'root' => storage_path('app/offer'),
|
||||
+ 'url' => env('APP_URL').'/storage/offer',
|
||||
+ 'visibility' => 'public',
|
||||
+ ],
|
||||
'general' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/general'),
|
||||
Loading…
Add table
Add a link
Reference in a new issue