feat(phone): implement PhoneNumber and TenantPhoneNumber models with relationships and migration
This commit is contained in:
30
app/Domains/Core/PhoneNumber/Models/PhoneNumber.php
Normal file
30
app/Domains/Core/PhoneNumber/Models/PhoneNumber.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\PhoneNumber\Models;
|
||||
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class PhoneNumber extends Model
|
||||
{
|
||||
protected $fillable = ['label', 'number'];
|
||||
|
||||
public static function dialableNumber(string $number): string
|
||||
{
|
||||
return preg_replace('/[^0-9+]/', '', $number);
|
||||
}
|
||||
|
||||
public function telUrl(): string
|
||||
{
|
||||
return 'tel:'.self::dialableNumber($this->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();
|
||||
}
|
||||
}
|
||||
31
app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php
Normal file
31
app/Domains/Core/PhoneNumber/Models/TenantPhoneNumber.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\PhoneNumber\Models;
|
||||
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class TenantPhoneNumber extends Pivot
|
||||
{
|
||||
protected $table = 'tenant_phone_numbers';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $fillable = ['tenant_codigo', 'phone_number_id', 'is_main'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_main' => 'boolean'];
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
public function phoneNumber(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PhoneNumber::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\PhoneNumber\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PhoneNumberResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,8 @@ class TenantInformationService
|
||||
'socialMedia',
|
||||
'addresses',
|
||||
'mainAddress',
|
||||
'phoneNumbers',
|
||||
'mainPhoneNumber',
|
||||
'websiteExtras.websiteTypeExtra',
|
||||
'activeEvent.dates',
|
||||
'activeEvent.socialMedia',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$driver = DB::connection()->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
DB::table('tenants')->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.
|
||||
}
|
||||
};
|
||||
106
tests/Feature/Migrations/ContactPhoneNumbersTest.php
Normal file
106
tests/Feature/Migrations/ContactPhoneNumbersTest.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ContactPhoneNumbersTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Schema::create('tenants', function (Blueprint $table): void {
|
||||
$table->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user