diff --git a/src/backend/app/Models/Cluster.php b/src/backend/app/Models/Cluster.php index 909b54f8c..74879416f 100644 --- a/src/backend/app/Models/Cluster.php +++ b/src/backend/app/Models/Cluster.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Support\Carbon; /** @@ -22,6 +23,7 @@ * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property-read Collection $cities + * @property-read GeographicalInformation|null $location * @property-read User|null $manager * @property-read Collection $miniGrids */ @@ -31,6 +33,12 @@ class Cluster extends BaseModel implements ITargetAssignable { public const RELATION_NAME = 'cluster'; + /** @var array */ + protected $hidden = ['location']; + + /** @var array */ + protected $appends = ['geo_json']; + /** @return BelongsTo */ public function manager(): BelongsTo { return $this->belongsTo(User::class); @@ -46,6 +54,31 @@ public function miniGrids(): HasMany { return $this->hasMany(MiniGrid::class); } + /** @return MorphOne */ + public function location(): MorphOne { + return $this->morphOne(GeographicalInformation::class, 'owner'); + } + + public function getGeoJsonAttribute(): mixed { + $location = $this->relationLoaded('location') + ? $this->location + : $this->location()->first(); + + if ($location?->geo_json !== null) { + return $location->geo_json; + } + + $legacyGeoJson = $this->getRawOriginal('geo_json'); + + if ($legacyGeoJson === null || $legacyGeoJson === '') { + return null; + } + + return is_string($legacyGeoJson) + ? json_decode($legacyGeoJson) + : $legacyGeoJson; + } + protected function casts(): array { return [ 'geo_json' => 'object', diff --git a/src/backend/app/Models/GeographicalInformation.php b/src/backend/app/Models/GeographicalInformation.php index 20be81690..d57684443 100644 --- a/src/backend/app/Models/GeographicalInformation.php +++ b/src/backend/app/Models/GeographicalInformation.php @@ -16,6 +16,7 @@ * @property int $owner_id * @property string $owner_type * @property string $points + * @property object|null $geo_json * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property-read Model $owner @@ -32,4 +33,10 @@ class GeographicalInformation extends BaseModel { public function owner(): MorphTo { return $this->morphTo(); } + + protected function casts(): array { + return [ + 'geo_json' => 'object', + ]; + } } diff --git a/src/backend/app/Services/ClusterService.php b/src/backend/app/Services/ClusterService.php index 9e1d5766e..6155ae113 100644 --- a/src/backend/app/Services/ClusterService.php +++ b/src/backend/app/Services/ClusterService.php @@ -7,6 +7,8 @@ use App\Services\Interfaces\IBaseService; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\DB; /** * @implements IBaseService @@ -35,15 +37,17 @@ public function getClusterWithComputedData( } public function getClusterCities(int $clusterId): ?Cluster { - return Cluster::query()->with('cities')->find($clusterId); + return Cluster::query()->with(['cities', 'location'])->find($clusterId); } public function getClusterMiniGrids(int $clusterId): ?Cluster { - return Cluster::query()->with('miniGrids')->find($clusterId); + return Cluster::query()->with(['miniGrids', 'location'])->find($clusterId); } public function getGeoLocationById(int $clusterId): mixed { - return $this->cluster->newQuery()->select('geo_json')->find($clusterId)->geo_json; + $cluster = $this->cluster->newQuery()->with('location')->findOrFail($clusterId); + + return $cluster->geo_json; } /** @@ -64,14 +68,32 @@ public function getDateRangeFromRequest(?string $startDate, ?string $endDate): a } public function getById(int $clusterId): Cluster { - return $this->cluster->newQuery()->with(['miniGrids.location', 'cities'])->find($clusterId); + return $this->cluster->newQuery()->with(['miniGrids.location', 'cities', 'location'])->find($clusterId); } /** * @param array $clusterData */ public function create(array $clusterData): Cluster { - return $this->cluster->newQuery()->create($clusterData); + return DB::connection('tenant')->transaction(function () use ($clusterData): Cluster { + $geoJson = $clusterData['geo_json'] ?? null; + + // Keep legacy write only while clusters.geo_json exists. + if (! Schema::connection('tenant')->hasColumn('clusters', 'geo_json')) { + unset($clusterData['geo_json']); + } + + $cluster = $this->cluster->newQuery()->create($clusterData); + + if ($geoJson !== null) { + $cluster->location()->create([ + 'points' => '', + 'geo_json' => $geoJson, + ]); + } + + return $cluster->fresh('location'); + }); } /** @@ -79,10 +101,10 @@ public function create(array $clusterData): Cluster { */ public function getAll(?int $limit = null): Collection|LengthAwarePaginator { if ($limit !== null) { - return $this->cluster->newQuery()->with('miniGrids')->limit($limit)->get(); + return $this->cluster->newQuery()->with(['miniGrids', 'location'])->limit($limit)->get(); } - return $this->cluster->newQuery()->with('miniGrids')->get(); + return $this->cluster->newQuery()->with(['miniGrids', 'location'])->get(); } /** @@ -104,6 +126,7 @@ public function getAllForExport(): Collection { 'miniGrids', 'cities', 'manager', + 'location', ])->get(); } } diff --git a/src/backend/database/factories/ClusterFactory.php b/src/backend/database/factories/ClusterFactory.php index 3865d51ab..2cea9128f 100644 --- a/src/backend/database/factories/ClusterFactory.php +++ b/src/backend/database/factories/ClusterFactory.php @@ -4,6 +4,7 @@ use App\Models\Cluster; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Facades\Schema; /** @extends Factory */ class ClusterFactory extends Factory { @@ -15,6 +16,19 @@ public function __construct( $this->faker->addProvider(new \Faker\Provider\en_NG\Address($this->faker)); } + public function configure(): static { + return $this->afterCreating(function (Cluster $cluster): void { + if ($cluster->location()->exists()) { + return; + } + + $cluster->location()->create([ + 'points' => '', + 'geo_json' => $this->buildDefaultGeoJson($cluster->name), + ]); + }); + } + /** * Define the model's default state. * @@ -24,27 +38,41 @@ public function definition(): array { // @phpstan-ignore-next-line varTag.unresolvableType /** @var \Faker\Generator&\Faker\Provider\en_NG\Address */ $faker = $this->faker; + $clusterName = 'Cluster '.$faker->county(); + $geoJson = $this->buildDefaultGeoJson($clusterName); + + return [ + 'name' => $clusterName, + // Kept for backward compatibility until tenant data migration removes the column. + ...($this->shouldPersistLegacyGeoJsonOnCluster() ? ['geo_json' => $geoJson] : []), + ]; + } + /** + * @return array + */ + private function buildDefaultGeoJson(string $clusterName): array { return [ - 'name' => 'Cluster '.$faker->county(), - 'geo_json' => json_decode('{ - "type": "Feature", - "properties": { - "name": "Cluster '.$faker->county().'" - }, - "geometry": { - "type": "Polygon", - "coordinates": [ - [34.09735878800838, -1.0021831137920607], - [34.08104951280351, -1.0037278294879668], - [34.08001945331692, -0.9961758791705448], - [34.079332746992485, -0.9831315606570004], - [34.08036280647911, -0.9745497443261169], - [34.09890387723834, -0.9889671831741885], - [34.09735878800838, -1.0021831137920607] - ] - } - }'), + 'type' => 'Feature', + 'properties' => [ + 'name' => $clusterName, + ], + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [[ + [34.09735878800838, -1.0021831137920607], + [34.08104951280351, -1.0037278294879668], + [34.08001945331692, -0.9961758791705448], + [34.079332746992485, -0.9831315606570004], + [34.08036280647911, -0.9745497443261169], + [34.09890387723834, -0.9889671831741885], + [34.09735878800838, -1.0021831137920607], + ]], + ], ]; } + + private function shouldPersistLegacyGeoJsonOnCluster(): bool { + return Schema::connection('tenant')->hasColumn('clusters', 'geo_json'); + } } diff --git a/src/backend/database/migrations/tenant/2026_03_13_120000_move_cluster_geo_json_to_geographical_information.php b/src/backend/database/migrations/tenant/2026_03_13_120000_move_cluster_geo_json_to_geographical_information.php new file mode 100644 index 000000000..ac804a4a9 --- /dev/null +++ b/src/backend/database/migrations/tenant/2026_03_13_120000_move_cluster_geo_json_to_geographical_information.php @@ -0,0 +1,98 @@ +hasColumn('geographical_informations', 'geo_json')) { + $schema->table('geographical_informations', function (Blueprint $table): void { + $table->json('geo_json')->nullable()->after('points'); + }); + } + + if (! $schema->hasColumn('clusters', 'geo_json')) { + return; + } + + $clusters = DB::connection('tenant') + ->table('clusters') + ->select(['id', 'geo_json']) + ->whereNotNull('geo_json') + ->get(); + + foreach ($clusters as $cluster) { + $existingGeo = DB::connection('tenant') + ->table('geographical_informations') + ->where('owner_type', 'cluster') + ->where('owner_id', $cluster->id) + ->first(); + + if ($existingGeo) { + DB::connection('tenant') + ->table('geographical_informations') + ->where('id', $existingGeo->id) + ->update([ + 'geo_json' => $cluster->geo_json, + 'updated_at' => now(), + ]); + + continue; + } + + DB::connection('tenant') + ->table('geographical_informations') + ->insert([ + 'owner_id' => $cluster->id, + 'owner_type' => 'cluster', + 'points' => '', + 'geo_json' => $cluster->geo_json, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $schema->table('clusters', function (Blueprint $table): void { + $table->dropColumn('geo_json'); + }); + } + + public function down(): void { + $schema = Schema::connection('tenant'); + + if (! $schema->hasColumn('clusters', 'geo_json')) { + $schema->table('clusters', function (Blueprint $table): void { + $table->json('geo_json')->nullable()->after('manager_id'); + }); + } + + if (! $schema->hasColumn('geographical_informations', 'geo_json')) { + return; + } + + $clusterGeo = DB::connection('tenant') + ->table('geographical_informations') + ->select(['owner_id', 'geo_json']) + ->where('owner_type', 'cluster') + ->whereNotNull('geo_json') + ->get(); + + foreach ($clusterGeo as $geoInfo) { + DB::connection('tenant') + ->table('clusters') + ->where('id', $geoInfo->owner_id) + ->update([ + 'geo_json' => $geoInfo->geo_json, + 'updated_at' => now(), + ]); + } + + $schema->table('geographical_informations', function (Blueprint $table): void { + $table->dropColumn('geo_json'); + }); + } +}; diff --git a/src/backend/database/seeders/ClusterSeeder.php b/src/backend/database/seeders/ClusterSeeder.php index 1fa173114..2834df5f8 100644 --- a/src/backend/database/seeders/ClusterSeeder.php +++ b/src/backend/database/seeders/ClusterSeeder.php @@ -40,56 +40,54 @@ public function run() { ->sequence( [ 'name' => 'Cluster Mafia Island', - 'geo_json' => json_decode( - '{ - "type": "Feature", - "properties": { - "name": "Cluster Mafia Island" - }, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [39.961513, -7.630225], - [39.631923, -7.652002], - [39.549526, -7.910525], - [39.631923, -8.125383], - [39.934047, -8.092754], - [39.988979, -7.869716], - [39.961513, -7.630225] - ] - ] - } - }' - ), ], [ 'name' => 'Cluster Pemba Island', - 'geo_json' => json_decode( - '{ - "type": "Feature", - "properties": {}, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [39.770765, -4.800463], - [39.520826, -4.937297], - [39.545546, -5.421455], - [39.647169, -5.582757], - [39.905348, -5.448798], - [39.95204, -5.194467], - [39.899855, -4.86341], - [39.770765, -4.800463] - ] - ] - } - }' - ), ], ) ->create(); + $clusters[0]->location()->update([ + 'geo_json' => [ + 'type' => 'Feature', + 'properties' => [ + 'name' => 'Cluster Mafia Island', + ], + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [[ + [39.961513, -7.630225], + [39.631923, -7.652002], + [39.549526, -7.910525], + [39.631923, -8.125383], + [39.934047, -8.092754], + [39.988979, -7.869716], + [39.961513, -7.630225], + ]], + ], + ], + ]); + + $clusters[1]->location()->update([ + 'geo_json' => [ + 'type' => 'Feature', + 'properties' => (object) [], + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [[ + [39.770765, -4.800463], + [39.520826, -4.937297], + [39.545546, -5.421455], + [39.647169, -5.582757], + [39.905348, -5.448798], + [39.95204, -5.194467], + [39.899855, -4.86341], + [39.770765, -4.800463], + ]], + ], + ], + ]); + // MiniGrids and Villages on Mafia Island $miniGridsMafiaIsland = MiniGrid::factory() ->count(2) diff --git a/src/backend/tests/Unit/AgentSellApplianceTest.php b/src/backend/tests/Unit/AgentSellApplianceTest.php index 96e1de90a..67e0c6fde 100644 --- a/src/backend/tests/Unit/AgentSellApplianceTest.php +++ b/src/backend/tests/Unit/AgentSellApplianceTest.php @@ -13,6 +13,7 @@ use Database\Factories\Person\PersonFactory; use Database\Factories\UserFactory; use Illuminate\Foundation\Testing\WithFaker; +use Illuminate\Support\Facades\Schema; use Tests\RefreshMultipleDatabases; use Tests\TestCase; @@ -46,26 +47,40 @@ public function initData(): array { $user = UserFactory::new()->create(['company_id' => $this->companyId]); $this->actingAs($user); $person = PersonFactory::new()->create(); - $cluster = Cluster::query()->create([ - 'name' => 'Test Cluster', - 'manager_id' => 1, - 'geo_json' => json_encode([ - 'type' => 'Feature', - 'properties' => [ - 'name' => 'Test Cluster', - ], - 'geometry' => [ - 'type' => 'Polygon', - 'coordinates' => [ - [ - [37.937924389032375, -3.204747603780925], - [37.93779565098191, -3.4220930701917984], - [38.24208948955007, -3.2492230959644415], - [37.937924389032375, -3.204747603780925], - ], + $clusterGeoJson = [ + 'type' => 'Feature', + 'properties' => [ + 'name' => 'Test Cluster', + ], + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [ + [ + [37.937924389032375, -3.204747603780925], + [37.93779565098191, -3.4220930701917984], + [38.24208948955007, -3.2492230959644415], + [37.937924389032375, -3.204747603780925], ], ], - ]), + ], + ]; + + $clusterCreateData = [ + 'name' => 'Test Cluster', + 'manager_id' => 1, + ]; + + if (Schema::connection('tenant')->hasColumn('clusters', 'geo_json')) { + $clusterCreateData['geo_json'] = $clusterGeoJson; + } + + $cluster = Cluster::query()->create([ + ...$clusterCreateData, + ]); + + $cluster->location()->create([ + 'points' => '', + 'geo_json' => $clusterGeoJson, ]); $miniGrid = MiniGrid::query()->create([