diff --git a/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php b/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php new file mode 100644 index 00000000..902dcd9a --- /dev/null +++ b/app/Domains/Core/PhoneNumber/Models/PhoneNumber.php @@ -0,0 +1,30 @@ +number); + } + + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class, 'tenant_phone_numbers', 'phone_number_id', 'tenant_codigo', 'id', 'codigo') + ->using(TenantPhoneNumber::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps(); + } +} diff --git a/app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php b/app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php new file mode 100644 index 00000000..8c9f46c2 --- /dev/null +++ b/app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php @@ -0,0 +1,31 @@ + 'boolean']; + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + + public function phoneNumber(): BelongsTo + { + return $this->belongsTo(PhoneNumber::class); + } +} diff --git a/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php b/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php new file mode 100644 index 00000000..d5d3c909 --- /dev/null +++ b/app/Domains/Core/PhoneNumber/Resources/PhoneNumberResource.php @@ -0,0 +1,20 @@ + $this->id, + 'label' => $this->label, + 'number' => $this->number, + 'tel_url' => $this->resource->telUrl(), + 'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main), + ]; + } +} diff --git a/app/Domains/Core/Tenant/Models/Tenant.php b/app/Domains/Core/Tenant/Models/Tenant.php index 9b824a8a..d04549f2 100644 --- a/app/Domains/Core/Tenant/Models/Tenant.php +++ b/app/Domains/Core/Tenant/Models/Tenant.php @@ -8,6 +8,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Address\Models\Address; use App\Domains\Core\Address\Models\TenantAddress; +use App\Domains\Core\PhoneNumber\Models\PhoneNumber; +use App\Domains\Core\PhoneNumber\Models\TenantPhoneNumber; use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\TenantMenu; @@ -328,6 +330,22 @@ class Tenant extends Model ->where('tenant_addresses.is_main', true); } + public function phoneNumbers(): BelongsToMany + { + return $this->belongsToMany(PhoneNumber::class, 'tenant_phone_numbers', 'tenant_codigo', 'phone_number_id', 'codigo', 'id') + ->using(TenantPhoneNumber::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps() + ->orderByPivot('is_main', 'desc') + ->orderByPivot('id'); + } + + public function mainPhoneNumber(): HasOneThrough + { + return $this->hasOneThrough(PhoneNumber::class, TenantPhoneNumber::class, 'tenant_codigo', 'id', 'codigo', 'phone_number_id') + ->where('tenant_phone_numbers.is_main', true); + } + public function menues(): BelongsToMany { return $this->belongsToMany( diff --git a/app/Domains/Core/Tenant/Resources/TenantResource.php b/app/Domains/Core/Tenant/Resources/TenantResource.php index f4272ae3..1571930c 100644 --- a/app/Domains/Core/Tenant/Resources/TenantResource.php +++ b/app/Domains/Core/Tenant/Resources/TenantResource.php @@ -4,6 +4,7 @@ namespace App\Domains\Core\Tenant\Resources; use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Address\Resources\AddressResource; +use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Tenant\Models\StorefrontWebsiteType; use App\Domains\Core\Tenant\Models\Tenant; @@ -45,6 +46,8 @@ class TenantResource extends JsonResource 'addresses' => $this->whenLoaded('addresses', fn () => TenantAddressResource::collection($this->addresses)), 'main_address' => $this->whenLoaded('mainAddress', fn () => AddressResource::make($this->mainAddress)), 'phone' => $this->phone, + 'phone_numbers' => $this->whenLoaded('phoneNumbers', fn () => PhoneNumberResource::collection($this->phoneNumbers)), + 'main_phone_number' => $this->whenLoaded('mainPhoneNumber', fn () => PhoneNumberResource::make($this->mainPhoneNumber)), 'favicon' => $this->favicon?->getTemporaryUrl(1440), 'primary_color' => $this->primary_color, 'secondary_color' => $this->secondary_color, diff --git a/app/Domains/Core/Tenant/Services/TenantInformationService.php b/app/Domains/Core/Tenant/Services/TenantInformationService.php index acdfba63..d7f86f5f 100644 --- a/app/Domains/Core/Tenant/Services/TenantInformationService.php +++ b/app/Domains/Core/Tenant/Services/TenantInformationService.php @@ -20,6 +20,8 @@ class TenantInformationService 'socialMedia', 'addresses', 'mainAddress', + 'phoneNumbers', + 'mainPhoneNumber', 'websiteExtras.websiteTypeExtra', 'activeEvent.dates', 'activeEvent.socialMedia', diff --git a/app/Domains/Core/Tenant/documentacion/README.md b/app/Domains/Core/Tenant/documentacion/README.md index c0050062..b8896209 100644 --- a/app/Domains/Core/Tenant/documentacion/README.md +++ b/app/Domains/Core/Tenant/documentacion/README.md @@ -12,6 +12,7 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we - `StorefrontWebsiteTypeExtra`: definición de un extra y su configuración admitida. - `WebsiteExtra`: valor resuelto y estado del extra para un tenant. - `SocialMedia`: catálogo de redes sociales asociables. +- `phoneNumbers` y `mainPhoneNumber`: teléfonos públicos del tenant, definidos en el dominio `Core/PhoneNumber` y vinculados por `tenant_phone_numbers`. Solo un vínculo por tenant puede ser principal. ## Servicios @@ -31,3 +32,5 @@ El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant ## Dependencias y reglas Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral. + +Los resources exponen `phone_numbers` (incluye `is_main`) y `main_phone_number`, con `id`, `label`, `number` y `tel_url`. Contacto muestra la colección y el footer usa el principal. La migración copia primero `tenants.phone` como principal y luego el teléfono de `help.contact`, si es distinto; si no existe teléfono del tenant, el del menú pasa a ser principal. Los duplicados por formato se comparan dentro de cada tenant sin inferir país ni código de área. Los campos anteriores se conservan como datos legacy, pero la tienda consume las relaciones nuevas. No hay endpoints de edición de teléfonos ni cambios del adminapp en esta etapa. diff --git a/database/migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php b/database/migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php new file mode 100644 index 00000000..458db5a9 --- /dev/null +++ b/database/migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php @@ -0,0 +1,48 @@ +getDriverName(); + if (! in_array($driver, ['mysql', 'mariadb', 'sqlite', 'pgsql', 'sqlsrv'], true)) { + throw new RuntimeException("Unsupported phone number database driver: {$driver}"); + } + + Schema::create('phone_numbers', function (Blueprint $table): void { + $table->id(); + $table->string('label'); + $table->string('number'); + $table->timestamps(); + }); + Schema::create('tenant_phone_numbers', function (Blueprint $table) use ($driver): void { + $table->id(); + $table->string('tenant_codigo'); + $table->foreignId('phone_number_id')->constrained('phone_numbers')->cascadeOnDelete(); + $table->boolean('is_main')->default(false); + $table->timestamps(); + $table->foreign('tenant_codigo')->references('codigo')->on('tenants')->cascadeOnDelete(); + $table->unique(['tenant_codigo', 'phone_number_id']); + if (in_array($driver, ['mysql', 'mariadb'], true)) { + $table->string('main_tenant_codigo')->nullable() + ->virtualAs('CASE WHEN is_main = 1 THEN tenant_codigo ELSE NULL END'); + $table->unique('main_tenant_codigo', 'tenant_phones_one_main'); + } + }); + if (! in_array($driver, ['mysql', 'mariadb'], true)) { + $condition = $driver === 'pgsql' ? 'is_main = true' : 'is_main = 1'; + DB::statement("CREATE UNIQUE INDEX tenant_phones_one_main ON tenant_phone_numbers (tenant_codigo) WHERE {$condition}"); + } + } + + public function down(): void + { + Schema::dropIfExists('tenant_phone_numbers'); + Schema::dropIfExists('phone_numbers'); + } +}; diff --git a/database/migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php b/database/migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php new file mode 100644 index 00000000..01f18cd6 --- /dev/null +++ b/database/migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php @@ -0,0 +1,65 @@ +orderBy('id')->chunkById(100, function ($tenants): void { + foreach ($tenants as $tenant) { + $this->copyPhone($tenant->codigo, $tenant->phone, true); + } + }); + DB::table('tenants_menues')->where('menu_code', 'help.contact') + ->whereNotNull('static_content')->orderBy('id')->chunkById(100, function ($menus): void { + foreach ($menus as $menu) { + $content = json_decode($menu->static_content, true, flags: JSON_THROW_ON_ERROR); + if ($content === null) { + continue; + } + if (! is_array($content)) { + throw new RuntimeException("Invalid contact content for tenant {$menu->tenant_code}"); + } + $this->copyPhone($menu->tenant_code, $content['phone'] ?? null, false); + } + }); + }); + } + + private function copyPhone(string $tenantCode, mixed $number, bool $fromTenant): void + { + if ($number === null || (is_string($number) && trim($number) === '')) { + return; + } + if (! is_string($number) || mb_strlen($number) > 255 || ! preg_match('/[0-9]/', $number)) { + throw new RuntimeException("Invalid phone number for tenant {$tenantCode}"); + } + $numbers = DB::table('phone_numbers') + ->join('tenant_phone_numbers', 'phone_numbers.id', '=', 'tenant_phone_numbers.phone_number_id') + ->where('tenant_codigo', $tenantCode)->get(['phone_numbers.number']); + $dialable = preg_replace('/[^0-9]/', '', $number); + foreach ($numbers as $existing) { + if (preg_replace('/[^0-9]/', '', $existing->number) === $dialable) { + return; + } + } + $isMain = ! DB::table('tenant_phone_numbers')->where('tenant_codigo', $tenantCode)->where('is_main', true)->exists(); + $now = now(); + $id = DB::table('phone_numbers')->insertGetId([ + 'label' => $fromTenant ? 'Principal' : 'Contacto', + 'number' => trim($number), 'created_at' => $now, 'updated_at' => $now, + ]); + DB::table('tenant_phone_numbers')->insert([ + 'tenant_codigo' => $tenantCode, 'phone_number_id' => $id, 'is_main' => $isMain, + 'created_at' => $now, 'updated_at' => $now, + ]); + } + + public function down(): void + { + // Preserve copies that may have been edited. The legacy sources were not changed. + } +}; diff --git a/tests/Feature/Migrations/ContactPhoneNumbersTest.php b/tests/Feature/Migrations/ContactPhoneNumbersTest.php new file mode 100644 index 00000000..4068bf48 --- /dev/null +++ b/tests/Feature/Migrations/ContactPhoneNumbersTest.php @@ -0,0 +1,106 @@ +id(); + $table->string('codigo')->unique(); + $table->string('phone')->nullable(); + $table->timestamps(); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('menu_code'); + $table->json('static_content')->nullable(); + }); + (require database_path('migrations/2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php'))->up(); + } + + public function test_tenant_phone_wins_and_copy_is_idempotent_and_scoped(): void + { + DB::table('tenants')->insert([ + ['codigo' => 'a', 'phone' => '+54 341 111-1111'], + ['codigo' => 'b', 'phone' => '+54 341 111-1111'], + ['codigo' => 'c', 'phone' => null], + ['codigo' => 'empty', 'phone' => ''], + ]); + $this->contact('a', '+54 (341) 222-2222'); + $this->contact('b', '54 (341) 1111111'); + $this->contact('c', '+54 341 3333333'); + $copy = require database_path('migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php'); + $copy->up(); + $copy->up(); + $this->assertDatabaseCount('phone_numbers', 4); + $a = Tenant::where('codigo', 'a')->firstOrFail(); + $b = Tenant::where('codigo', 'b')->firstOrFail(); + $this->assertSame('+54 341 111-1111', $a->mainPhoneNumber->number); + $this->assertCount(2, $a->phoneNumbers); + $this->assertCount(1, $b->phoneNumbers); + $this->assertNotSame($a->mainPhoneNumber->id, $b->mainPhoneNumber->id); + $this->assertSame('+54 341 3333333', Tenant::where('codigo', 'c')->firstOrFail()->mainPhoneNumber->number); + $this->assertNull(Tenant::where('codigo', 'empty')->firstOrFail()->mainPhoneNumber); + $this->assertSame('tel:+543411111111', $a->mainPhoneNumber->telUrl()); + $mainResource = PhoneNumberResource::make($a->mainPhoneNumber)->resolve(); + $this->assertSame('+54 341 111-1111', $mainResource['number']); + $this->assertSame('tel:+543411111111', $mainResource['tel_url']); + $resources = PhoneNumberResource::collection($a->phoneNumbers)->resolve(); + $this->assertTrue($resources[0]['is_main']); + $this->assertFalse($resources[1]['is_main']); + $copy->down(); + $this->assertDatabaseCount('phone_numbers', 4); + $this->assertDatabaseHas('tenants', ['codigo' => 'a', 'phone' => '+54 341 111-1111']); + } + + public function test_database_rejects_two_main_numbers(): void + { + DB::table('tenants')->insert(['codigo' => 'a', 'phone' => '+54 341 1111111']); + $this->contact('a', '+54 341 2222222'); + (require database_path('migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php'))->up(); + $this->expectException(QueryException::class); + DB::table('tenant_phone_numbers')->where('tenant_codigo', 'a')->update(['is_main' => true]); + } + + public function test_invalid_contact_rolls_back_the_whole_copy(): void + { + DB::table('tenants')->insert(['codigo' => 'a', 'phone' => '+54 341 1111111']); + $this->contact('a', ['invalid']); + try { + (require database_path('migrations/2026_09_30_000500_copy_tenant_and_contact_phone_numbers.php'))->up(); + $this->fail('Invalid legacy numbers must not be silently discarded.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('Invalid phone number', $exception->getMessage()); + $this->assertDatabaseCount('phone_numbers', 0); + } + } + + public function test_a_number_cannot_be_linked_to_a_missing_tenant(): void + { + $id = DB::table('phone_numbers')->insertGetId(['label' => 'Contacto', 'number' => '123456']); + $this->expectException(QueryException::class); + DB::table('tenant_phone_numbers')->insert([ + 'tenant_codigo' => 'missing', 'phone_number_id' => $id, 'is_main' => true, + ]); + } + + private function contact(string $tenant, mixed $phone): void + { + DB::table('tenants_menues')->insert([ + 'tenant_code' => $tenant, 'menu_code' => 'help.contact', + 'static_content' => json_encode(['phone' => $phone], JSON_THROW_ON_ERROR), + ]); + } +}