diff --git a/app/Domains/Core/Address/Models/Address.php b/app/Domains/Core/Address/Models/Address.php new file mode 100644 index 00000000..a914966b --- /dev/null +++ b/app/Domains/Core/Address/Models/Address.php @@ -0,0 +1,29 @@ + 'decimal:7', + 'longitude' => 'decimal:7', + ]; + } + + /** @return BelongsToMany */ + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class, 'tenant_addresses', 'address_id', 'tenant_codigo', 'id', 'codigo') + ->using(TenantAddress::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps(); + } +} diff --git a/app/Domains/Core/Address/Models/TenantAddress.php b/app/Domains/Core/Address/Models/TenantAddress.php new file mode 100644 index 00000000..31694d31 --- /dev/null +++ b/app/Domains/Core/Address/Models/TenantAddress.php @@ -0,0 +1,33 @@ + 'boolean']; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + + /** @return BelongsTo */ + public function address(): BelongsTo + { + return $this->belongsTo(Address::class); + } +} diff --git a/app/Domains/Core/Tenant/Models/Tenant.php b/app/Domains/Core/Tenant/Models/Tenant.php index 532264ea..da58333a 100644 --- a/app/Domains/Core/Tenant/Models/Tenant.php +++ b/app/Domains/Core/Tenant/Models/Tenant.php @@ -6,6 +6,8 @@ use App\Domains\Commerce\Catalog\Enums\GroupLayout; use App\Domains\Commerce\Catalog\Enums\ProductLayout; 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\Client\Models\Client; use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\TenantMenu; @@ -22,6 +24,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Support\Facades\Schema; #[Fillable([ @@ -308,6 +311,22 @@ class Tenant extends Model return $this->hasMany(WebsiteExtra::class, 'website_code', 'codigo'); } + /** @return BelongsToMany */ + public function addresses(): BelongsToMany + { + return $this->belongsToMany(Address::class, 'tenant_addresses', 'tenant_codigo', 'address_id', 'codigo', 'id') + ->using(TenantAddress::class) + ->withPivot(['id', 'is_main']) + ->withTimestamps(); + } + + /** @return HasOneThrough */ + public function mainAddress(): HasOneThrough + { + return $this->hasOneThrough(Address::class, TenantAddress::class, 'tenant_codigo', 'id', 'codigo', 'address_id') + ->where('tenant_addresses.is_main', true); + } + public function menues(): BelongsToMany { return $this->belongsToMany( diff --git a/app/Domains/Core/Tenant/Services/TenantAddressService.php b/app/Domains/Core/Tenant/Services/TenantAddressService.php new file mode 100644 index 00000000..3eabfe78 --- /dev/null +++ b/app/Domains/Core/Tenant/Services/TenantAddressService.php @@ -0,0 +1,26 @@ +where('codigo', $tenant->codigo)->lockForUpdate()->firstOrFail(); + $tenant->addresses()->where('addresses.id', $address->id)->firstOrFail(); + + DB::table('tenant_addresses')->where('tenant_codigo', $tenant->codigo) + ->where('is_main', true)->update(['is_main' => false, 'updated_at' => now()]); + $tenant->addresses()->updateExistingPivot($address->id, ['is_main' => true]); + }); + + $tenant->unsetRelation('addresses'); + $tenant->unsetRelation('mainAddress'); + } +} diff --git a/database/migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php b/database/migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php new file mode 100644 index 00000000..a0c68632 --- /dev/null +++ b/database/migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php @@ -0,0 +1,57 @@ +getDriverName(); + + if (! in_array($driver, ['mysql', 'mariadb', 'sqlite', 'pgsql', 'sqlsrv'], true)) { + throw new RuntimeException("Unsupported address database driver: {$driver}"); + } + + Schema::create('addresses', function (Blueprint $table): void { + $table->id(); + $table->string('label'); + $table->text('address_text'); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->timestamps(); + }); + + Schema::create('tenant_addresses', function (Blueprint $table) use ($driver): void { + $table->id(); + $table->string('tenant_codigo'); + $table->foreignId('address_id')->constrained('addresses')->cascadeOnDelete(); + $table->boolean('is_main')->default(false); + $table->timestamps(); + + $table->foreign('tenant_codigo')->references('codigo')->on('tenants')->cascadeOnDelete(); + $table->unique(['tenant_codigo', 'address_id']); + + if (in_array($driver, ['mysql', 'mariadb'], true)) { + // NULL allows many secondary addresses, while a tenant code can occur only once. + $table->string('main_tenant_codigo')->nullable() + ->virtualAs('CASE WHEN is_main = 1 THEN tenant_codigo ELSE NULL END'); + $table->unique('main_tenant_codigo', 'tenant_addresses_one_main_per_tenant'); + } + }); + + if (! in_array($driver, ['mysql', 'mariadb'], true)) { + $condition = $driver === 'pgsql' ? 'is_main = true' : 'is_main = 1'; + DB::statement('CREATE UNIQUE INDEX tenant_addresses_one_main_per_tenant ' + ."ON tenant_addresses (tenant_codigo) WHERE {$condition}"); + } + } + + public function down(): void + { + Schema::dropIfExists('tenant_addresses'); + Schema::dropIfExists('addresses'); + } +}; diff --git a/database/migrations/2026_09_30_000100_copy_contact_menu_addresses.php b/database/migrations/2026_09_30_000100_copy_contact_menu_addresses.php new file mode 100644 index 00000000..7fa0a230 --- /dev/null +++ b/database/migrations/2026_09_30_000100_copy_contact_menu_addresses.php @@ -0,0 +1,108 @@ +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) || ! is_array($content['locations'] ?? [])) { + throw new RuntimeException("Invalid contact locations for tenant {$menu->tenant_code}"); + } + + foreach ($content['locations'] ?? [] as $location) { + if (! is_array($location) || ! is_array($location['addresses'] ?? [])) { + throw new RuntimeException("Invalid contact addresses for tenant {$menu->tenant_code}"); + } + + foreach ($location['addresses'] ?? [] as $address) { + $this->copyAddress($menu->tenant_code, $location, $address); + } + } + } + }); + }); + } + + private function copyAddress(string $tenantCode, array $location, mixed $address): void + { + if (! is_array($address) || ! is_string($address['address'] ?? null) + || trim($address['address']) === '') { + throw new RuntimeException("Invalid contact address text for tenant {$tenantCode}"); + } + + $coordinates = $address['coordinates'] ?? null; + + if ($coordinates !== null && (! is_array($coordinates) || count($coordinates) !== 2 + || ! is_numeric($coordinates[0] ?? null) || ! is_numeric($coordinates[1] ?? null) + || ! is_finite((float) $coordinates[0]) || ! is_finite((float) $coordinates[1]) + || abs((float) $coordinates[0]) > 90 || abs((float) $coordinates[1]) > 180)) { + throw new RuntimeException("Invalid contact coordinates for tenant {$tenantCode}"); + } + + $label = $address['label'] ?? $location['label'] ?? 'Dirección'; + + if (! is_string($label) || trim($label) === '' || mb_strlen($label) > 255) { + throw new RuntimeException("Invalid contact address label for tenant {$tenantCode}"); + } + + $values = [ + 'label' => $label, + 'address_text' => $address['address'], + 'latitude' => $coordinates === null ? null : number_format((float) $coordinates[0], 7, '.', ''), + 'longitude' => $coordinates === null ? null : number_format((float) $coordinates[1], 7, '.', ''), + ]; + + // Reuse only within this tenant; equal legacy addresses in different tenants stay independent. + $existing = DB::table('addresses') + ->join('tenant_addresses', 'tenant_addresses.address_id', '=', 'addresses.id') + ->where('tenant_addresses.tenant_codigo', $tenantCode) + ->where(array_combine( + array_map(fn (string $field): string => 'addresses.'.$field, array_keys($values)), + array_values($values), + )) + ->first(['addresses.id', 'tenant_addresses.id as link_id']); + + if ($existing !== null) { + return; + } + + $now = now(); + $isMain = ! DB::table('tenant_addresses') + ->where('tenant_codigo', $tenantCode) + ->where('is_main', true) + ->exists(); + $addressId = DB::table('addresses')->insertGetId([ + ...$values, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('tenant_addresses')->insert([ + 'tenant_codigo' => $tenantCode, + 'address_id' => $addressId, + 'is_main' => $isMain, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function down(): void + { + // Keep copies: they may already have been edited or reused. Legacy menus were never changed. + } +}; diff --git a/tests/Feature/Migrations/ContactAddressesTest.php b/tests/Feature/Migrations/ContactAddressesTest.php new file mode 100644 index 00000000..3b40317e --- /dev/null +++ b/tests/Feature/Migrations/ContactAddressesTest.php @@ -0,0 +1,229 @@ +id(); + $table->string('codigo')->unique(); + $table->string('address')->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(); + $table->boolean('is_enabled')->default(true); + }); + DB::table('tenants')->insert([ + ['codigo' => 'alpha', 'address' => 'Dirección anterior'], + ['codigo' => 'beta', 'address' => null], + ['codigo' => 'empty', 'address' => null], + ]); + + $this->schemaMigration()->up(); + } + + public function test_it_copies_all_contact_locations_without_changing_legacy_data_and_can_run_again(): void + { + $sharedAddress = [ + 'label' => 'Sede principal', + 'address' => 'San Lorenzo 1543, Rosario', + 'coordinates' => [-32.9431184, -60.6437991], + ]; + $this->contact('alpha', [ + 'rosario' => ['label' => 'Rosario', 'addresses' => [$sharedAddress]], + 'santa_fe' => ['label' => 'Santa Fe', 'addresses' => [ + ['label' => 'Sucursal', 'address' => 'San Martín 100', 'coordinates' => [0, 0]], + ['label' => 'Sin mapa', 'address' => 'San Martín 200'], + ]], + ], enabled: false); + $this->contact('beta', ['rosario' => ['addresses' => [$sharedAddress]]]); + $this->contact('empty', []); + DB::table('tenants_menues')->insert([ + 'tenant_code' => 'empty', + 'menu_code' => 'help.faq', + 'static_content' => json_encode(['locations' => [['addresses' => [$sharedAddress]]]]), + ]); + $oldMenus = DB::table('tenants_menues')->orderBy('id')->get()->toArray(); + + $migration = $this->dataMigration(); + $migration->up(); + $migration->up(); + + $this->assertDatabaseCount('addresses', 4); + $this->assertDatabaseCount('tenant_addresses', 4); + $this->assertDatabaseHas('addresses', [ + 'label' => 'Sede principal', + 'address_text' => 'San Lorenzo 1543, Rosario', + 'latitude' => -32.9431184, + 'longitude' => -60.6437991, + ]); + $this->assertDatabaseHas('addresses', ['label' => 'Sucursal', 'latitude' => 0, 'longitude' => 0]); + $this->assertDatabaseHas('addresses', ['label' => 'Sin mapa', 'latitude' => null, 'longitude' => null]); + $this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray()); + $this->assertDatabaseHas('tenants', ['codigo' => 'alpha', 'address' => 'Dirección anterior']); + + $alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $beta = Tenant::query()->where('codigo', 'beta')->firstOrFail(); + $this->assertCount(3, $alpha->addresses); + $this->assertTrue($alpha->addresses->first()->pivot->is_main); + $this->assertInstanceOf(TenantAddress::class, $alpha->addresses->first()->pivot); + $this->assertSame('Sede principal', $alpha->mainAddress->label); + $this->assertSame('-32.9431184', $alpha->mainAddress->latitude); + $this->assertNotSame($alpha->mainAddress->id, $beta->mainAddress->id); + $this->assertSame('alpha', $alpha->mainAddress->tenants->sole()->codigo); + $this->assertNull(Tenant::query()->where('codigo', 'empty')->firstOrFail()->mainAddress); + $this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count()); + + $migration->down(); + $this->assertDatabaseCount('addresses', 4); + $this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray()); + } + + public function test_a_tenant_can_have_many_secondary_addresses_but_only_one_main(): void + { + $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $addresses = $this->addresses(3); + $tenant->addresses()->attach($addresses[0], ['is_main' => true]); + $tenant->addresses()->attach([$addresses[1], $addresses[2]], ['is_main' => false]); + $this->assertCount(3, $tenant->addresses); + + $this->expectException(QueryException::class); + $tenant->addresses()->updateExistingPivot($addresses[1], ['is_main' => true]); + } + + public function test_it_prevents_duplicate_links(): void + { + $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $address = $this->addresses(1)[0]; + $tenant->addresses()->attach($address); + + $this->expectException(QueryException::class); + $tenant->addresses()->attach($address); + } + + public function test_main_address_is_specific_to_each_tenant_and_can_be_switched(): void + { + $alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $beta = Tenant::query()->where('codigo', 'beta')->firstOrFail(); + $addresses = $this->addresses(2); + $alpha->addresses()->attach($addresses[0], ['is_main' => true]); + $alpha->addresses()->attach($addresses[1]); + $beta->addresses()->attach($addresses[0], ['is_main' => true]); + $this->assertSame($addresses[0], $alpha->mainAddress->id); + + app(TenantAddressService::class)->setMainAddress($alpha, Address::findOrFail($addresses[1])); + + $this->assertSame($addresses[1], $alpha->mainAddress->id); + $this->assertSame($addresses[0], $beta->mainAddress->id); + $this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count()); + $loaded = Tenant::query()->with('mainAddress')->whereIn('codigo', ['alpha', 'beta'])->get()->keyBy('codigo'); + $this->assertSame($addresses[1], $loaded['alpha']->mainAddress->id); + $this->assertSame($addresses[0], $loaded['beta']->mainAddress->id); + } + + public function test_switching_to_an_unlinked_address_preserves_the_previous_main(): void + { + $tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail(); + $addresses = $this->addresses(2); + $tenant->addresses()->attach($addresses[0], ['is_main' => true]); + + try { + app(TenantAddressService::class)->setMainAddress($tenant, Address::findOrFail($addresses[1])); + $this->fail('An unlinked address must be rejected.'); + } catch (ModelNotFoundException) { + $this->assertSame($addresses[0], $tenant->fresh()->mainAddress->id); + } + } + + public function test_invalid_coordinates_roll_back_all_copies_and_preserve_source_data(): void + { + $this->contact('alpha', [['addresses' => [ + ['label' => 'Válida', 'address' => 'Primera 100', 'coordinates' => [-32, -60]], + ['label' => 'Inválida', 'address' => 'Segunda 200', 'coordinates' => [-91, -60]], + ]]]); + $oldContent = DB::table('tenants_menues')->value('static_content'); + + try { + $this->dataMigration()->up(); + $this->fail('Invalid latitude must not be silently imported.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('alpha', $exception->getMessage()); + $this->assertDatabaseCount('addresses', 0); + $this->assertDatabaseCount('tenant_addresses', 0); + $this->assertSame($oldContent, DB::table('tenants_menues')->value('static_content')); + } + } + + public function test_deleting_a_tenant_removes_links_but_keeps_shared_addresses(): void + { + $address = $this->addresses(1)[0]; + foreach (['alpha', 'beta'] as $code) { + Tenant::query()->where('codigo', $code)->firstOrFail()->addresses()->attach($address, ['is_main' => true]); + } + Tenant::query()->where('codigo', 'alpha')->delete(); + $this->assertDatabaseCount('tenant_addresses', 1); + $this->assertDatabaseCount('addresses', 1); + + Address::findOrFail($address)->delete(); + $this->assertDatabaseCount('tenant_addresses', 0); + } + + public function test_schema_can_be_rolled_back_without_removing_legacy_tables(): void + { + $this->schemaMigration()->down(); + $this->assertFalse(Schema::hasTable('addresses')); + $this->assertFalse(Schema::hasTable('tenant_addresses')); + $this->assertTrue(Schema::hasTable('tenants_menues')); + $this->assertTrue(Schema::hasColumn('tenants', 'address')); + } + + private function contact(string $code, array $locations, bool $enabled = true): void + { + DB::table('tenants_menues')->insert([ + 'tenant_code' => $code, + 'menu_code' => 'help.contact', + 'static_content' => json_encode(['phone' => '+54 123', 'locations' => $locations], JSON_THROW_ON_ERROR), + 'is_enabled' => $enabled, + ]); + } + + private function addresses(int $count): array + { + $ids = []; + for ($i = 0; $i < $count; $i++) { + $ids[] = Address::create(['label' => "Sucursal {$i}", 'address_text' => "Calle {$i}"])->id; + } + + return $ids; + } + + private function schemaMigration(): Migration + { + return require database_path('migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php'); + } + + private function dataMigration(): Migration + { + return require database_path('migrations/2026_09_30_000100_copy_contact_menu_addresses.php'); + } +}