From 615da8c4c14f9c154de83586302c562b5742d39d Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 14 Apr 2026 10:20:42 +0200 Subject: [PATCH] Initial release --- .gitattributes | 9 + .gitignore | 13 + LICENSE | 21 + README.md | 143 + composer.json | 86 + config/addresses.php | 48 + .../create_blax_address_tables.php.stub | 211 ++ docs/address-link-types.md | 131 + docs/address-service.md | 328 ++ docs/core-concepts.md | 172 + docs/customization.md | 188 ++ docs/has-address-assignments.md | 267 ++ docs/has-addresses.md | 327 ++ docs/installation.md | 106 + phpunit.xml | 18 + pint.json | 4 + src/AddressesServiceProvider.php | 111 + src/Enums/AddressLinkType.php | 120 + src/Models/Address.php | 165 + src/Models/AddressAssignment.php | 130 + src/Models/AddressLink.php | 181 + src/Services/AddressService.php | 553 ++++ src/Traits/HasAddressAssignments.php | 193 ++ src/Traits/HasAddresses.php | 283 ++ src/helpers.php | 15 + tests/Unit/HasAddressesTest.php | 2938 +++++++++++++++++ 26 files changed, 6761 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/addresses.php create mode 100644 database/migrations/create_blax_address_tables.php.stub create mode 100644 docs/address-link-types.md create mode 100644 docs/address-service.md create mode 100644 docs/core-concepts.md create mode 100644 docs/customization.md create mode 100644 docs/has-address-assignments.md create mode 100644 docs/has-addresses.md create mode 100644 docs/installation.md create mode 100644 phpunit.xml create mode 100644 pint.json create mode 100644 src/AddressesServiceProvider.php create mode 100644 src/Enums/AddressLinkType.php create mode 100644 src/Models/Address.php create mode 100644 src/Models/AddressAssignment.php create mode 100644 src/Models/AddressLink.php create mode 100644 src/Services/AddressService.php create mode 100644 src/Traits/HasAddressAssignments.php create mode 100644 src/Traits/HasAddresses.php create mode 100644 src/helpers.php create mode 100644 tests/Unit/HasAddressesTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2c4eb5b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +/.vscode export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.phpunit.cache export-ignore +/phpunit.xml export-ignore +/pint.json export-ignore +/workbench export-ignore +/tests export-ignore +/docs export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3028cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/vendor/ +/node_modules/ +composer.lock +.phpunit.cache/ +.phpunit.result.cache +.php-cs-fixer.cache +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db +workbench/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..adf7df3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Blax Software + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fdaaa2 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) + +# Laravel Addresses + +[![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue)](https://php.net) +[![Laravel](https://img.shields.io/badge/laravel-9.x--12.x-orange)](https://laravel.com) + +Universal Laravel address management — from rural GPS coordinates to specific rooms inside skyscrapers, worldwide. + +## Overview + +This package provides a complete address management system for Laravel applications built on a **three-layer architecture**: + +``` +Address → The physical place (street, city, coordinates …) + └── AddressLink → Connects an address to a model with a purpose (User's "Office") + └── AddressAssignment → References a link from another context (Job's "pickup") +``` + +**Example:** A user has an office address. A job references that office as its pickup location — without duplicating the address data. + +## Features + +- **15 address fields** — street-level to room-level precision, with GPS coordinates (WGS-84) and altitude +- **Polymorphic links** — attach addresses to any Eloquent model +- **17 built-in link types** — Home, Office, Shipping, Billing, Warehouse and more +- **Address assignments** — reference someone else's address in another context +- **Temporal validity** — `active_from` / `active_until` on every link +- **AddressService** — distance calculations (Haversine), proximity queries, duplicate detection, coordinate conversion +- **Fully configurable** — custom model classes, table names, default link type +- **Soft deletes** on addresses, cascade deletes on links and assignments + +## Requirements + +- PHP 8.1+ +- Laravel 9, 10, 11 or 12 +- `blax-software/laravel-workkit` (installed automatically) + +## Installation + +```bash +composer require blax-software/laravel-addresses +``` + +Publish and run the migrations: + +```bash +php artisan vendor:publish --tag="addresses-migrations" +php artisan migrate +``` + +Optionally publish the config: + +```bash +php artisan vendor:publish --tag="addresses-config" +``` + +## Quick Start + +### 1. Add the trait to your model + +```php +use Blax\Addresses\Traits\HasAddresses; + +class User extends Model +{ + use HasAddresses; +} +``` + +### 2. Create and attach an address + +```php +use Blax\Addresses\Enums\AddressLinkType; + +$link = $user->addAddress([ + 'street' => '350 Fifth Avenue', + 'city' => 'New York', + 'state' => 'NY', + 'postal_code' => '10118', + 'country_code' => 'US', + 'latitude' => 40.748817, + 'longitude' => -73.985428, +], AddressLinkType::Office); +``` + +### 3. Query addresses + +```php +$user->addresses; // all addresses +$user->addressesOfType(AddressLinkType::Office); // only offices +$user->primaryAddress(); // primary across all types +$user->activeAddressLinks(); // only currently active links +``` + +### 4. Assign an address to another model + +```php +use Blax\Addresses\Traits\HasAddressAssignments; + +class Job extends Model +{ + use HasAddressAssignments; +} + +$job->assignAddressLink($link, 'pickup'); +$job->assignedAddressForRole('pickup'); // → the Address model +``` + +### 5. Use the AddressService + +```php +// Via helper +$distance = address()->distanceBetween($addressA, $addressB); // km + +// Nearby addresses within 10 km +$nearby = address()->nearby(48.2082, 16.3738, 10); + +// Format for display +echo address()->formatMultiline($address); +``` + +## Documentation + +| Guide | Description | +|----------------------------------------------------------------|------------------------------------------------------| +| [Installation & Configuration](docs/installation.md) | Setup, publishing, config options | +| [Core Concepts](docs/core-concepts.md) | The three-layer architecture explained | +| [HasAddresses Trait](docs/has-addresses.md) | Full API for address-owning models | +| [HasAddressAssignments Trait](docs/has-address-assignments.md) | Full API for address-consuming models | +| [AddressService](docs/address-service.md) | Distance, proximity, formatting, conversion | +| [AddressLinkType Enum](docs/address-link-types.md) | All 17 built-in types with descriptions | +| [Customization](docs/customization.md) | Extending models, custom tables, overriding defaults | + +## Testing + +```bash +composer test +``` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8028525 --- /dev/null +++ b/composer.json @@ -0,0 +1,86 @@ +{ + "name": "blax-software/laravel-addresses", + "type": "library", + "description": "Universal Laravel address management system — from rural coordinates to skyscraper rooms, worldwide.", + "keywords": [ + "addresses", + "address", + "geocoding", + "coordinates", + "laravel", + "blax", + "location" + ], + "homepage": "http://www.blax.at", + "license": "MIT", + "authors": [ + { + "name": "Fabian Wagner", + "email": "fabian@blax.at", + "homepage": "https://www.blax.at", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Blax\\Addresses\\": "src" + }, + "files": [ + "src/helpers.php" + ] + }, + "config": { + "sort-packages": true + }, + "require": { + "php": "^8.1", + "blax-software/laravel-workkit": "*", + "illuminate/container": "^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0" + }, + "require-dev": { + "laravel/framework": "*", + "laravel/pint": "^1.22", + "orchestra/testbench": "^10.4", + "phpunit/phpunit": "^12.2" + }, + "extra": { + "laravel": { + "providers": [ + "Blax\\Addresses\\AddressesServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload-dev": { + "psr-4": { + "Blax\\Addresses\\Tests\\": "tests", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/" + } + }, + "scripts": { + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" + ], + "lint": [ + "@php vendor/bin/pint --ansi" + ], + "test": [ + "@clear", + "@php vendor/bin/phpunit" + ] + } +} \ No newline at end of file diff --git a/config/addresses.php b/config/addresses.php new file mode 100644 index 0000000..8533acc --- /dev/null +++ b/config/addresses.php @@ -0,0 +1,48 @@ + [ + 'address' => \Blax\Addresses\Models\Address::class, + 'address_link' => \Blax\Addresses\Models\AddressLink::class, + 'address_assignment' => \Blax\Addresses\Models\AddressAssignment::class, + ], + + /* + |-------------------------------------------------------------------------- + | Table Names + |-------------------------------------------------------------------------- + | + | The database table names used by the package. Change these if they + | collide with existing tables in your application. + | + */ + 'table_names' => [ + 'addresses' => 'addresses', + 'address_links' => 'address_links', + 'address_assignments' => 'address_assignments', + ], + + /* + |-------------------------------------------------------------------------- + | Default Address Link Type + |-------------------------------------------------------------------------- + | + | The default AddressLinkType applied when attaching an address to a model + | without specifying a type explicitly. + | + */ + 'default_link_type' => \Blax\Addresses\Enums\AddressLinkType::Other, + +]; diff --git a/database/migrations/create_blax_address_tables.php.stub b/database/migrations/create_blax_address_tables.php.stub new file mode 100644 index 0000000..982e5d3 --- /dev/null +++ b/database/migrations/create_blax_address_tables.php.stub @@ -0,0 +1,211 @@ +id(); + + // ── Street-level addressing ───────────────────────────── + // Primary street line (street name + house/building number). + $table->string('street')->nullable(); + + // Additional line for c/o, suite, apartment, P.O. box, etc. + $table->string('street_extra')->nullable(); + + // ── Building / indoor precision ───────────────────────── + // Building or complex name (e.g. "Empire State Building", "Block C"). + $table->string('building')->nullable(); + + // Floor / level inside the building (string to allow "GF", "B2", "Mezzanine"). + $table->string('floor')->nullable(); + + // Room, suite or unit number / name. + $table->string('room')->nullable(); + + // ── Postal / administrative divisions ─────────────────── + // Postal / ZIP code. + $table->string('postal_code')->nullable(); + + // City, town, village or locality name. + $table->string('city')->nullable(); + + // State, province, canton, prefecture or equivalent. + $table->string('state')->nullable(); + + // County, district or other sub-state administrative area. + $table->string('county')->nullable(); + + // ISO 3166-1 alpha-2 country code (e.g. "AT", "US", "JP"). + $table->string('country_code', 2)->nullable(); + + // ── Coordinates (WGS-84) ──────────────────────────────── + // Latitude in decimal degrees (−90 to +90). + $table->decimal('latitude', 10, 7)->nullable(); + + // Longitude in decimal degrees (−180 to +180). + $table->decimal('longitude', 10, 7)->nullable(); + + // Altitude in metres above mean sea level (AMSL). Positive = above, negative = below. + $table->decimal('altitude', 10, 2)->nullable(); + + // ── Additional properties ─────────────────────────────── + // Free-form notes (e.g. delivery instructions, landmark descriptions). + $table->text('notes')->nullable(); + + // Flexible JSON bucket for any extra data the consuming app needs + // (e.g. Plus Codes, what3words, timezone, formatted display string). + $table->json('meta')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + // ── Indexes ───────────────────────────────────────────── + $table->index('country_code'); + $table->index('postal_code'); + $table->index('city'); + $table->index(['latitude', 'longitude']); + }); + + /* + |---------------------------------------------------------------------- + | address_links — polymorphic pivot ("addressable") + |---------------------------------------------------------------------- + | + | Links an address to any Eloquent model (User, Company, Order …). + | The same address row can be linked to many models, and a single model + | can have many addresses (each with a different purpose / type). + | + | `type` – enum value describing the purpose of this link + | `label` – optional free-text override (handy for "Other" type) + | `is_primary` – marks ONE link per type per model as the primary + | `active_from` – when this link becomes effective + | `active_until`– when this link expires / is superseded + | `meta` – JSON bucket for developer-defined extra data + | + */ + Schema::create(config('addresses.table_names.address_links', 'address_links'), function (Blueprint $table) { + $table->id(); + + // ── Foreign key to the address ────────────────────────── + $table->foreignId('address_id') + ->constrained(config('addresses.table_names.addresses', 'addresses')) + ->cascadeOnDelete(); + + // ── Polymorphic owner ─────────────────────────────────── + // The model this address is linked to (e.g. App\Models\User, App\Models\Company). + $table->morphs('addressable'); + + // ── Link semantics ────────────────────────────────────── + // The purpose of this link, drawn from AddressLinkType enum. + $table->string('type')->default('other'); + + // Optional human-readable label to refine or override the type + // (useful when type = "other" or when several links share the same type). + $table->string('label')->nullable(); + + // Whether this is the primary address for this type on the model. + $table->boolean('is_primary')->default(false); + + // ── Temporal validity ─────────────────────────────────── + // When this link becomes active (null = immediately). + $table->timestamp('active_from')->nullable(); + + // When this link ceases to be active (null = indefinitely). + $table->timestamp('active_until')->nullable(); + + // ── Extra data ────────────────────────────────────────── + // JSON bucket for any developer-defined data on the pivot + // (e.g. delivery window, access codes, department reference). + $table->json('meta')->nullable(); + + $table->timestamps(); + + // ── Indexes ───────────────────────────────────────────── + $table->index(['addressable_type', 'addressable_id', 'type'], 'addr_link_owner_type'); + $table->index('type'); + $table->index('is_primary'); + }); + } + + /* + |---------------------------------------------------------------------- + | address_assignments — reference an AddressLink from another context + |---------------------------------------------------------------------- + | + | While an AddressLink says "this Address belongs to User X as their + | Office", an AddressAssignment says "Job Y uses that specific link + | for its pickup location." + | + | `role` – context-specific purpose (e.g. "pickup", "delivery", "origin") + | `label` – optional free-text label + | `meta` – JSON bucket for developer-defined data + | + */ + Schema::create(config('addresses.table_names.address_assignments', 'address_assignments'), function (Blueprint $table) { + $table->id(); + + // ── Foreign key to the address link ───────────────────── + $table->foreignId('address_link_id') + ->constrained(config('addresses.table_names.address_links', 'address_links')) + ->cascadeOnDelete(); + + // ── Polymorphic consumer ──────────────────────────────── + // The model this address link is assigned to (e.g. Job, Order, Event). + $table->morphs('assignable'); + + // ── Assignment semantics ──────────────────────────────── + // Context-specific role for this assignment (e.g. "pickup", "delivery"). + $table->string('role')->nullable(); + + // Optional human-readable label. + $table->string('label')->nullable(); + + // ── Extra data ────────────────────────────────────────── + $table->json('meta')->nullable(); + + $table->timestamps(); + + // ── Indexes ───────────────────────────────────────────── + $table->index(['assignable_type', 'assignable_id', 'role'], 'addr_assign_owner_role'); + $table->index('role'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists(config('addresses.table_names.address_assignments', 'address_assignments')); + Schema::dropIfExists(config('addresses.table_names.address_links', 'address_links')); + Schema::dropIfExists(config('addresses.table_names.addresses', 'addresses')); + } +}; diff --git a/docs/address-link-types.md b/docs/address-link-types.md new file mode 100644 index 0000000..8a32db7 --- /dev/null +++ b/docs/address-link-types.md @@ -0,0 +1,131 @@ +# AddressLinkType Enum + +`Blax\Addresses\Enums\AddressLinkType` is a PHP 8.1 backed string enum with 17 cases. It describes the purpose of an address link — **why** a particular address is attached to a model. + +## Usage + +```php +use Blax\Addresses\Enums\AddressLinkType; + +// When adding an address +$user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + +// As a string value +$user->addAddress(['city' => 'Vienna'], 'office'); + +// Get the human-readable label +AddressLinkType::Office->label(); // "Office" + +// Access the backing value +AddressLinkType::Office->value; // "office" + +// Create from a string +$type = AddressLinkType::from('office'); // AddressLinkType::Office +$type = AddressLinkType::tryFrom('unknown'); // null + +// List all cases +AddressLinkType::cases(); // array of all 17 cases +``` + +## All Types + +### Residential + +| Case | Value | Label | Description | +|----------------------|-----------------------|---------------------|--------------------------------| +| `Home` | `home` | Home | Primary living / home address | +| `SecondaryResidence` | `secondary_residence` | Secondary Residence | Holiday home, second apartment | + +### Business / Work + +| Case | Value | Label | Description | +|----------------|----------------|--------------|-------------------------------| +| `Office` | `office` | Office | General office address | +| `Headquarters` | `headquarters` | Headquarters | Company headquarters | +| `Branch` | `branch` | Branch | Branch or satellite office | +| `Factory` | `factory` | Factory | Factory or production site | +| `Warehouse` | `warehouse` | Warehouse | Warehouse or storage facility | + +### Logistics & Shipping + +| Case | Value | Label | Description | +|------------|------------|----------|-------------------------------------| +| `Shipping` | `shipping` | Shipping | Shipping / delivery address | +| `Billing` | `billing` | Billing | Billing / invoicing address | +| `Return` | `return` | Return | Return / reverse-logistics address | +| `Pickup` | `pickup` | Pick-up | Pick-up point (parcel locker, shop) | + +### Special Purpose + +| Case | Value | Label | Description | +|-------------------|---------------------|-------------------|--------------------------------------| +| `PointOfInterest` | `point_of_interest` | Point of Interest | Landmark, monument, notable location | +| `Site` | `site` | Site | Construction or project site | +| `Temporary` | `temporary` | Temporary | Temporary / event-based address | +| `Contact` | `contact` | Contact | Correspondence address | +| `Legal` | `legal` | Legal | Registered / legal address | + +### Catch-All + +| Case | Value | Label | Description | +|---------|---------|-------|-------------------------------| +| `Other` | `other` | Other | Any purpose not covered above | + +## Using `Other` with labels + +When none of the 17 types fit, use `Other` and set a `label` on the address link for detail: + +```php +$user->addAddress([ + 'city' => 'Munich', +], AddressLinkType::Other, [ + 'label' => 'Emergency Shelter', +]); +``` + +## Multiple addresses of the same type + +A model can have multiple addresses of the same type — for example, two offices: + +```php +$link1 = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office, [ + 'label' => 'Vienna Office', +]); +$link2 = $user->addAddress(['city' => 'Berlin'], AddressLinkType::Office, [ + 'label' => 'Berlin Office', +]); + +// Set one as primary +$user->setPrimaryAddressLink($link1->id); + +// Query +$user->addressesOfType(AddressLinkType::Office); // both +$user->primaryAddress(AddressLinkType::Office); // Vienna +``` + +## Filtering with query scopes + +`AddressLink` provides scopes for filtering by type: + +```php +use Blax\Addresses\Models\AddressLink; + +// All office links across all models +AddressLink::ofType(AddressLinkType::Office)->get(); + +// Combined with other scopes +AddressLink::ofType(AddressLinkType::Office)->active()->primary()->get(); +``` + +## Default type + +When no type is specified, the config default is used: + +```php +// config/addresses.php +'default_link_type' => AddressLinkType::Other, + +// These are equivalent: +$user->addAddress(['city' => 'Vienna']); +$user->addAddress(['city' => 'Vienna'], AddressLinkType::Other); +``` diff --git a/docs/address-service.md b/docs/address-service.md new file mode 100644 index 0000000..b45bf81 --- /dev/null +++ b/docs/address-service.md @@ -0,0 +1,328 @@ +# AddressService + +The `AddressService` is a singleton service providing distance calculations, proximity queries, duplicate detection, formatting and coordinate conversion. + +## Accessing the Service + +```php +// Via the global helper +address()->distanceBetween($a, $b); + +// Via dependency injection +use Blax\Addresses\Services\AddressService; + +public function __construct(private AddressService $addressService) {} + +// Via the container +app(AddressService::class)->nearby($lat, $lng, 10); +``` + +--- + +## Distance Calculation + +### `distanceBetween(Address $from, Address $to, string $unit = 'km'): ?float` + +Calculate the great-circle distance between two addresses using the Haversine formula. + +```php +$vienna = Address::create(['latitude' => 48.2082, 'longitude' => 16.3738]); +$berlin = Address::create(['latitude' => 52.5200, 'longitude' => 13.4050]); + +$km = address()->distanceBetween($vienna, $berlin); // ~524.2 km +$mi = address()->distanceBetween($vienna, $berlin, 'mi'); // ~325.8 mi +``` + +Returns `null` if either address is missing coordinates. + +### `haversine(float $lat1, float $lng1, float $lat2, float $lng2, string $unit = 'km'): float` + +Calculate distance directly from coordinate pairs — no Address models needed. + +```php +$distance = address()->haversine(48.2082, 16.3738, 52.5200, 13.4050); // ~524.2 km +``` + +### `altitudeDifference(Address $from, Address $to): ?float` + +Calculate the altitude difference in metres (signed: `to − from`). + +```php +$valley = Address::create(['altitude' => 200.0, 'latitude' => 0, 'longitude' => 0]); +$peak = Address::create(['altitude' => 1800.0, 'latitude' => 0, 'longitude' => 0]); + +address()->altitudeDifference($valley, $peak); // 1600.0 +address()->altitudeDifference($peak, $valley); // -1600.0 +``` + +Returns `null` if either address is missing altitude data. + +### Constants + +| Constant | Value | Description | +|-----------------------------------|--------|---------------------------------| +| `AddressService::EARTH_RADIUS_KM` | 6371.0 | Mean Earth radius in kilometres | +| `AddressService::EARTH_RADIUS_MI` | 3958.8 | Mean Earth radius in miles | + +--- + +## Proximity Queries + +### `nearby(float $latitude, float $longitude, float $radius, string $unit = 'km'): Collection` + +Find all addresses within a given radius of a coordinate point. Uses a bounding-box pre-filter for performance, then refines with Haversine. Results are ordered by distance (nearest first). + +```php +// All addresses within 10 km of St. Stephen's Cathedral +$nearby = address()->nearby(48.2082, 16.3738, 10); + +foreach ($nearby as $address) { + echo $address->city; // "Vienna" + echo $address->distance; // 2.34 (km from centre) +} +``` + +Each returned `Address` has a `->distance` attribute appended with the calculated distance. + +### `nearbyAddress(Address $address, float $radius, string $unit = 'km', bool $excludeSelf = true): Collection` + +Convenience wrapper — find addresses near a given address. + +```php +$office = Address::create([ + 'street' => 'Stephansplatz 1', + 'city' => 'Vienna', + 'latitude' => 48.2082, + 'longitude' => 16.3738, +]); + +// Find other addresses within 5 km +$neighbours = address()->nearbyAddress($office, 5); + +// Include the reference address itself +$all = address()->nearbyAddress($office, 5, 'km', false); +``` + +Returns an empty collection if the address has no coordinates. + +### `closest(float $latitude, float $longitude): ?Address` + +Get the single closest address to a coordinate point. + +```php +$nearest = address()->closest(48.2082, 16.3738); + +echo $nearest->formatted; // "Stephansplatz 1, 1010, Vienna, AT" +echo $nearest->distance; // 0.12 (km) +``` + +Returns `null` if no addresses with coordinates exist. + +--- + +## Bounding Box + +### `boundingBox(float $latitude, float $longitude, float $radius, string $unit = 'km'): array` + +Calculate a latitude/longitude bounding box around a centre point. Useful as a fast pre-filter before computing Haversine distances. + +```php +$box = address()->boundingBox(48.2082, 16.3738, 10); // 10 km radius + +// Returns: +// [ +// 'minLat' => 48.1183..., +// 'maxLat' => 48.2981..., +// 'minLng' => 16.2395..., +// 'maxLng' => 16.5081..., +// ] + +// Use in a query +Address::whereBetween('latitude', [$box['minLat'], $box['maxLat']]) + ->whereBetween('longitude', [$box['minLng'], $box['maxLng']]) + ->get(); +``` + +--- + +## Duplicate Detection & Merging + +### `findDuplicates(Address $address): Collection` + +Find addresses that look like potential duplicates. Matches on `street`, `postal_code`, `city` and `country_code`. + +```php +$address = Address::create([ + 'street' => 'Baker Street 221B', + 'postal_code' => 'NW1 6XE', + 'city' => 'London', + 'country_code' => 'GB', +]); + +$duplicates = address()->findDuplicates($address); + +foreach ($duplicates as $dup) { + echo "Possible duplicate: #{$dup->id} — {$dup->formatted}"; +} +``` + +### `merge(Address $target, Address $duplicate): int` + +Merge a duplicate address into a target. All `AddressLink` rows pointing to the duplicate are reassigned to the target, and the duplicate is soft-deleted. + +```php +$target = Address::find(1); // the one to keep +$duplicate = Address::find(2); // the one to merge away + +$reassigned = address()->merge($target, $duplicate); +echo "Reassigned {$reassigned} links"; + +$duplicate->trashed(); // true +``` + +--- + +## Query Builders + +These methods return Eloquent `Builder` instances for further chaining. + +### `inCountry(string $countryCode): Builder` + +```php +$austrianAddresses = address()->inCountry('AT')->get(); +$austrianCount = address()->inCountry('AT')->count(); +``` + +### `inCity(string $city, ?string $countryCode = null): Builder` + +```php +$viennaAddresses = address()->inCity('Vienna')->get(); + +// Disambiguate: Vienna, Austria vs Vienna, Virginia +$at = address()->inCity('Vienna', 'AT')->get(); +``` + +### `inPostalCode(string $postalCode, ?string $countryCode = null): Builder` + +```php +$addresses = address()->inPostalCode('1010')->get(); +$addresses = address()->inPostalCode('1010', 'AT')->get(); +``` + +### `withCoordinates(): Builder` + +Get all addresses that have latitude and longitude set. + +```php +$geoAddresses = address()->withCoordinates()->get(); +$count = address()->withCoordinates()->count(); +``` + +--- + +## Formatting + +### `format(Address $address, string $separator = ', '): string` + +Build a single-line formatted string from an address. + +```php +$address = Address::create([ + 'street' => '350 Fifth Avenue', + 'building' => 'Empire State Building', + 'floor' => '32', + 'postal_code' => '10118', + 'city' => 'New York', + 'state' => 'NY', + 'country_code' => 'US', +]); + +echo address()->format($address); +// "350 Fifth Avenue, (Empire State Building), Floor 32, 10118, New York, NY, US" + +echo address()->format($address, ' | '); +// "350 Fifth Avenue | (Empire State Building) | Floor 32 | 10118 | New York | NY | US" +``` + +> **Tip:** The `Address` model also has a `$address->formatted` accessor that produces the same single-line output with `", "` separator. + +### `formatMultiline(Address $address): string` + +Build a multi-line, postal-style formatted string. + +```php +echo address()->formatMultiline($address); +// 350 Fifth Avenue +// Empire State Building, Floor 32 +// 10118 New York, NY +// US +``` + +Line structure: +1. Street + street_extra +2. Building, floor, room +3. Postal code + city, state +4. County (if set) +5. Country code + +### `formatCoordinates(Address $address): ?string` + +Format coordinates as a human-readable string. + +```php +$address = Address::create([ + 'latitude' => 48.2082, + 'longitude' => 16.3738, + 'altitude' => 171.0, +]); + +echo address()->formatCoordinates($address); +// "48.2082000°N, 16.3738000°E (alt: 171.00m AMSL)" +``` + +Returns `null` if no coordinates are set. + +--- + +## Coordinate Conversion + +### `dmsToDecimal(int $degrees, int $minutes, float $seconds, string $direction): float` + +Convert degrees/minutes/seconds (DMS) to decimal degrees. + +```php +// 48° 12' 29.52" N +$lat = address()->dmsToDecimal(48, 12, 29.52, 'N'); // 48.2082 + +// 16° 22' 25.68" E +$lng = address()->dmsToDecimal(16, 22, 25.68, 'E'); // 16.3738 + +// Southern / Western hemispheres yield negative values +$lat = address()->dmsToDecimal(33, 51, 54.0, 'S'); // -33.865 +``` + +### `decimalToDms(float $decimal, string $axis = 'lat'): array` + +Convert decimal degrees to DMS. + +```php +$dms = address()->decimalToDms(48.2082, 'lat'); +// [ +// 'degrees' => 48, +// 'minutes' => 12, +// 'seconds' => 29.52, +// 'direction' => 'N', +// ] + +$dms = address()->decimalToDms(-73.9854, 'lng'); +// [ +// 'degrees' => 73, +// 'minutes' => 59, +// 'seconds' => 7.44, +// 'direction' => 'W', +// ] +``` + +The `$axis` parameter determines the direction letter: +- `'lat'` → N (positive) / S (negative) +- `'lng'` → E (positive) / W (negative) diff --git a/docs/core-concepts.md b/docs/core-concepts.md new file mode 100644 index 0000000..70d8505 --- /dev/null +++ b/docs/core-concepts.md @@ -0,0 +1,172 @@ +# Core Concepts + +## The Three-Layer Architecture + +Laravel Addresses uses three models that work together: + +``` +Address + └── AddressLink + └── AddressAssignment +``` + +Each layer has a distinct responsibility: + +### Layer 1: Address + +The **physical place**. An `Address` is a standalone record containing street data, postal information, and optional GPS coordinates. It knows nothing about who uses it or why. + +```php +Address::create([ + 'street' => '350 Fifth Avenue', + 'building' => 'Empire State Building', + 'floor' => '32', + 'room' => '3201', + 'postal_code' => '10118', + 'city' => 'New York', + 'state' => 'NY', + 'country_code' => 'US', + 'latitude' => 40.748817, + 'longitude' => -73.985428, + 'altitude' => 443.0, +]); +``` + +All fields are nullable — an address can be as minimal as a GPS coordinate pair or as detailed as a full postal address with indoor precision. + +**Available fields:** + +| Field | Type | Description | +|----------------|--------|------------------------------------------------| +| `street` | string | Street name + house number | +| `street_extra` | string | c/o, suite, P.O. box | +| `building` | string | Building or complex name | +| `floor` | string | Floor/level (supports "GF", "B2", "Mezzanine") | +| `room` | string | Room, suite or unit identifier | +| `postal_code` | string | Postal / ZIP code | +| `city` | string | City, town, village | +| `state` | string | State, province, canton | +| `county` | string | County, district | +| `country_code` | string | ISO 3166-1 alpha-2 ("US", "AT", "JP") | +| `latitude` | float | WGS-84 decimal degrees (−90 … +90) | +| `longitude` | float | WGS-84 decimal degrees (−180 … +180) | +| `altitude` | float | Metres above mean sea level (AMSL) | +| `notes` | text | Free-form notes, delivery instructions | +| `meta` | JSON | Arbitrary extra data | + +Addresses use **soft deletes** — calling `$address->delete()` sets `deleted_at` instead of removing the row. + +### Layer 2: AddressLink + +The **ownership pivot**. An `AddressLink` connects an `Address` to an Eloquent model (User, Company, Order …) and describes the **purpose** of that connection. + +```php +$link = $user->addAddress([ + 'street' => 'Stephansplatz 1', + 'city' => 'Vienna', + 'country_code' => 'AT', +], AddressLinkType::Office, [ + 'label' => 'Main Office', + 'is_primary' => true, +]); + +// $link->type → AddressLinkType::Office +// $link->label → "Main Office" +// $link->address → the Address model +``` + +**Key properties:** + +| Property | Type | Description | +|----------------|-----------------|-----------------------------------------------| +| `type` | AddressLinkType | The purpose (Home, Office, Shipping …) | +| `label` | string\|null | Free-text label to refine the type | +| `is_primary` | bool | Whether this is the primary link for its type | +| `active_from` | datetime\|null | When the link becomes effective | +| `active_until` | datetime\|null | When the link expires | +| `meta` | object\|null | Arbitrary JSON data | + +**Important:** The same address can be linked to multiple models, and each model can have multiple addresses. A user can have a Home address, an Office address, and a Billing address — each as a separate `AddressLink`. + +**Polymorphic:** Uses `addressable_type` / `addressable_id` morphs, so any Eloquent model can own addresses. + +### Layer 3: AddressAssignment + +The **contextual reference**. An `AddressAssignment` lets one model reference another model's address link without owning the address. + +**The problem it solves:** A transport job needs a pickup and delivery address. Those addresses belong to the customer, not the job. Instead of duplicating address data, the job *assigns* the customer's existing address links. + +```php +// User owns the address +$link = $user->addAddress([ + 'street' => 'Kärntner Straße 21', + 'city' => 'Vienna', +], AddressLinkType::Office); + +// Job references it as "pickup" +$job->assignAddressLink($link, 'pickup'); + +// Later, retrieve it +$pickupAddress = $job->assignedAddressForRole('pickup'); +// → Address { street: "Kärntner Straße 21", city: "Vienna" } +``` + +**Key properties:** + +| Property | Type | Description | +|-------------------|--------------|---------------------------------------------------| +| `address_link_id` | int | FK to the address link being referenced | +| `role` | string\|null | Context-specific purpose ("pickup", "delivery" …) | +| `label` | string\|null | Free-text label | +| `meta` | object\|null | Arbitrary JSON data | + +## Cascade Behaviour + +- **Deleting an Address** → all its `AddressLink` rows are cascade-deleted +- **Deleting an AddressLink** → all its `AddressAssignment` rows are cascade-deleted +- Addresses use **soft deletes**; links and assignments use **hard deletes** + +## Traits + +The package provides two traits to add to your Eloquent models: + +| Trait | Purpose | Use on | +|-------------------------|-----------------------------------|-----------------------------------------------------| +| `HasAddresses` | Own and manage addresses | Models that **have** addresses (User, Company …) | +| `HasAddressAssignments` | Reference other models' addresses | Models that **use** addresses (Job, Order, Event …) | + +A model can use both traits if it both owns and references addresses. + +## The AddressService + +A singleton service for operations that go beyond CRUD: + +```php +// Distance between two addresses (Haversine) +address()->distanceBetween($a, $b); + +// Find nearby addresses +address()->nearby($lat, $lng, $radiusKm); + +// Detect duplicates +address()->findDuplicates($address); + +// Format for display +address()->formatMultiline($address); +``` + +Access via the `address()` helper or dependency injection: + +```php +use Blax\Addresses\Services\AddressService; + +public function __construct(private AddressService $addressService) {} +``` + +See the individual documentation pages for complete API references: + +- [HasAddresses Trait](has-addresses.md) +- [HasAddressAssignments Trait](has-address-assignments.md) +- [AddressService](address-service.md) +- [AddressLinkType Enum](address-link-types.md) +- [Customization](customization.md) diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 0000000..091924c --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,188 @@ +# Customization + +## Custom Model Classes + +You can extend any of the three package models with your own. This is useful for adding custom methods, relationships, accessors or validation logic. + +### 1. Create your custom model + +```php +// app/Models/CustomAddress.php + +namespace App\Models; + +use Blax\Addresses\Models\Address as BaseAddress; + +class CustomAddress extends BaseAddress +{ + public function getFullAddressAttribute(): string + { + return "{$this->street}, {$this->postal_code} {$this->city}, {$this->country_code}"; + } + + public function geocode(): self + { + // your geocoding logic + return $this; + } +} +``` + +### 2. Update the config + +```php +// config/addresses.php + +'models' => [ + 'address' => \App\Models\CustomAddress::class, + 'address_link' => \Blax\Addresses\Models\AddressLink::class, + 'address_assignment' => \Blax\Addresses\Models\AddressAssignment::class, +], +``` + +The package resolves all model classes through `config('addresses.models.…')`, so your custom class will be used everywhere — in relationships, service methods, and traits. + +### Example: Custom AddressLink + +```php +namespace App\Models; + +use Blax\Addresses\Models\AddressLink as BaseAddressLink; + +class CustomAddressLink extends BaseAddressLink +{ + protected static function booted() + { + static::creating(function (self $link) { + // Auto-set label from type if not provided + if (! $link->label) { + $link->label = $link->type->label(); + } + }); + } +} +``` + +```php +'models' => [ + 'address_link' => \App\Models\CustomAddressLink::class, +], +``` + +--- + +## Custom Table Names + +Change the table names if they collide with existing tables in your application: + +```php +// config/addresses.php + +'table_names' => [ + 'addresses' => 'company_addresses', + 'address_links' => 'company_address_links', + 'address_assignments' => 'company_address_assignments', +], +``` + +**Important:** Update the published migration to match these names before running `php artisan migrate`. The migration stub reads from the config, so if you publish the config first and then the migration, the table names will be picked up automatically. + +--- + +## Custom Default Link Type + +Change the default `AddressLinkType` applied when adding an address without specifying a type: + +```php +// config/addresses.php + +'default_link_type' => \Blax\Addresses\Enums\AddressLinkType::Home, +``` + +Now `$user->addAddress(['city' => 'Vienna'])` will use `Home` instead of `Other`. + +--- + +## Using the Meta Column + +All three models (`Address`, `AddressLink`, `AddressAssignment`) include a `meta` JSON column for storing arbitrary data. The column is cast to `object`. + +```php +// On addresses — store extra data +$link = $user->addAddress([ + 'street' => 'Main Street 1', + 'city' => 'Vienna', + 'meta' => [ + 'plus_code' => '8FWR4HCJ+XX', + 'what3words' => 'index.home.raft', + 'timezone' => 'Europe/Vienna', + ], +]); + +// On address links — store context about the relationship +$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office, [ + 'meta' => [ + 'department' => 'Engineering', + 'access_code' => '4521', + ], +]); + +// On address assignments — store context about the assignment +$job->assignAddressLink($link, 'delivery', [ + 'meta' => [ + 'time_window' => '09:00-12:00', + 'priority' => 'express', + ], +]); + +// Reading meta +$address->meta->plus_code; // "8FWR4HCJ+XX" +$link->meta->department; // "Engineering" +$assignment->meta->time_window; // "09:00-12:00" +``` + +--- + +## Model Bindings + +The service provider registers model bindings so that resolving a package model through the container returns the configured (possibly customised) class: + +```php +// Always resolves to your custom class if configured +$address = app(\Blax\Addresses\Models\Address::class); +``` + +This means type-hinting the base class in dependency injection will automatically resolve to your custom model. + +--- + +## Extending the AddressService + +The `AddressService` is registered as a singleton. To add custom methods, extend it and rebind: + +```php +namespace App\Services; + +use Blax\Addresses\Services\AddressService; +use Blax\Addresses\Models\Address; + +class CustomAddressService extends AddressService +{ + public function geocode(Address $address): Address + { + // your geocoding implementation + return $address; + } +} +``` + +In a service provider: + +```php +$this->app->singleton( + \Blax\Addresses\Services\AddressService::class, + \App\Services\CustomAddressService::class +); +``` + +The `address()` helper and all DI injection will now resolve your custom service. diff --git a/docs/has-address-assignments.md b/docs/has-address-assignments.md new file mode 100644 index 0000000..f518393 --- /dev/null +++ b/docs/has-address-assignments.md @@ -0,0 +1,267 @@ +# HasAddressAssignments Trait + +Add the `HasAddressAssignments` trait to any Eloquent model that **references** addresses owned by other models. + +```php +use Blax\Addresses\Traits\HasAddressAssignments; + +class Job extends Model +{ + use HasAddressAssignments; +} + +class Order extends Model +{ + use HasAddressAssignments; +} +``` + +## When to use this + +Use `HasAddressAssignments` when a model needs to reference an address that it does **not** own. Instead of duplicating address data, the model creates an assignment that points to an existing `AddressLink`. + +**Example:** A transport job needs a pickup address and a delivery address. Those addresses belong to customers. The job *assigns* the customers' address links to itself with context-specific roles. + +```php +// Customer owns the address +$pickupLink = $customer->addAddress(['city' => 'Vienna'], AddressLinkType::Office); +$deliveryLink = $recipient->addAddress(['city' => 'Berlin'], AddressLinkType::Home); + +// Job references them +$job->assignAddressLink($pickupLink, 'pickup'); +$job->assignAddressLink($deliveryLink, 'delivery'); +``` + +> A model can use **both** `HasAddresses` and `HasAddressAssignments` if it both owns and references addresses. + +--- + +## Relationship + +### `addressAssignments()` + +Returns all `AddressAssignment` records for this model. + +```php +$assignments = $job->addressAssignments; + +foreach ($assignments as $assignment) { + echo $assignment->role; // "pickup" + echo $assignment->addressLink->type->label(); // "Office" + echo $assignment->addressLink->address->city; // "Vienna" +} +``` + +**Return:** `MorphMany` of `AddressAssignment` + +--- + +## Assigning + +### `assignAddressLink(AddressLink|int $addressLink, ?string $role = null, array $extra = []): AddressAssignment` + +Assign an existing address link to this model. + +```php +use Blax\Addresses\Enums\AddressLinkType; + +$link = $user->addAddress([ + 'street' => 'Kärntner Straße 21', + 'city' => 'Vienna', + 'country_code' => 'AT', +], AddressLinkType::Office); + +// Assign with a role +$assignment = $job->assignAddressLink($link, 'pickup'); + +// Assign with extra attributes +$assignment = $job->assignAddressLink($link, 'delivery', [ + 'label' => 'Customer Office Delivery', + 'meta' => ['priority' => 'express', 'time_window' => '09:00-12:00'], +]); + +// Assign by link ID +$assignment = $job->assignAddressLink($link->id, 'origin'); +``` + +**Parameters:** +- `$addressLink` — `AddressLink` model or its ID +- `$role` — Context-specific purpose string (e.g. "pickup", "delivery", "origin", "billing") +- `$extra` — Additional attributes: `label`, `meta` + +**Returns:** The created `AddressAssignment` with `addressLink.address` loaded. + +--- + +## Removing + +### `removeAddressAssignment(int $assignmentId): bool` + +Remove a specific assignment by its ID. + +```php +$assignment = $job->assignAddressLink($link, 'pickup'); +$job->removeAddressAssignment($assignment->id); // true +``` + +### `removeAssignmentsForRole(string $role): int` + +Remove all assignments for a specific role. + +```php +// Remove all "pickup" assignments +$removed = $job->removeAssignmentsForRole('pickup'); // 1 +``` + +**Returns:** Number of assignments removed. + +### `removeAllAddressAssignments(): int` + +Remove all address assignments from this model. + +```php +$removed = $job->removeAllAddressAssignments(); // 3 +``` + +**Returns:** Number of assignments removed. + +--- + +## Querying + +### `addressAssignmentForRole(string $role): ?AddressAssignment` + +Get the first assignment for a specific role (eager-loads the address link and address). + +```php +$assignment = $job->addressAssignmentForRole('pickup'); + +if ($assignment) { + echo $assignment->role; // "pickup" + echo $assignment->addressLink->address->city; // "Vienna" +} +``` + +### `addressAssignmentsForRole(string $role): Collection` + +Get **all** assignments for a specific role. Useful when a model has multiple addresses for the same role. + +```php +// A job with multiple stops +$job->assignAddressLink($linkA, 'stop'); +$job->assignAddressLink($linkB, 'stop'); +$job->assignAddressLink($linkC, 'stop'); + +$stops = $job->addressAssignmentsForRole('stop'); // Collection of 3 +``` + +### `assignedAddressForRole(string $role): ?Address` + +Convenience shortcut — returns the `Address` model directly for a role. + +```php +$pickupAddress = $job->assignedAddressForRole('pickup'); +echo $pickupAddress->formatted; // "Kärntner Straße 21, 1010, Vienna, AT" +``` + +**Returns:** `Address` or `null`. + +### `assignedAddresses(): Collection` + +Get all addresses assigned to this model (through their links). + +```php +$addresses = $job->assignedAddresses(); + +foreach ($addresses as $address) { + echo $address->city; +} +``` + +**Returns:** `Collection` of `Address` models. + +### `hasAddressAssignments(): bool` + +Check whether this model has any address assignments. + +```php +if ($job->hasAddressAssignments()) { + // ... +} +``` + +### `hasAssignmentForRole(string $role): bool` + +Check whether this model has an assignment for a specific role. + +```php +if (! $job->hasAssignmentForRole('delivery')) { + // prompt to assign a delivery address +} +``` + +--- + +## Cascade Behaviour + +When an `AddressLink` is deleted (e.g. because the user removes their office address), all `AddressAssignment` rows referencing that link are **cascade-deleted** at the database level. + +```php +// User removes their office address link +$user->removeAddressLink($officeLink->id); + +// All jobs that referenced this link automatically lose their assignment +$job->hasAssignmentForRole('pickup'); // false +``` + +--- + +## Full Example + +```php +use App\Models\User; +use App\Models\Job; +use Blax\Addresses\Enums\AddressLinkType; + +// Setup: users own addresses +$sender = User::create(['name' => 'Alice']); +$receiver = User::create(['name' => 'Bob']); + +$senderOffice = $sender->addAddress([ + 'street' => 'Stephansplatz 1', + 'city' => 'Vienna', + 'country_code' => 'AT', +], AddressLinkType::Office); + +$receiverHome = $receiver->addAddress([ + 'street' => 'Unter den Linden 77', + 'city' => 'Berlin', + 'country_code' => 'DE', +], AddressLinkType::Home); + +// Job references both addresses +$job = Job::create(['title' => 'Piano Transport #42']); + +$job->assignAddressLink($senderOffice, 'pickup', [ + 'label' => "Alice's Office", +]); + +$job->assignAddressLink($receiverHome, 'delivery', [ + 'label' => "Bob's Home", + 'meta' => ['floor' => 3, 'elevator' => false], +]); + +// Query +$job->assignedAddressForRole('pickup')->city; // "Vienna" +$job->assignedAddressForRole('delivery')->city; // "Berlin" +$job->assignedAddresses()->count(); // 2 +$job->hasAssignmentForRole('pickup'); // true +$job->hasAssignmentForRole('billing'); // false + +// Clean up a single assignment +$job->removeAssignmentsForRole('delivery'); +$job->assignedAddresses()->count(); // 1 + +// Clean up everything +$job->removeAllAddressAssignments(); +``` diff --git a/docs/has-addresses.md b/docs/has-addresses.md new file mode 100644 index 0000000..a78f7a3 --- /dev/null +++ b/docs/has-addresses.md @@ -0,0 +1,327 @@ +# HasAddresses Trait + +Add the `HasAddresses` trait to any Eloquent model that **owns** addresses. + +```php +use Blax\Addresses\Traits\HasAddresses; + +class User extends Model +{ + use HasAddresses; +} + +class Company extends Model +{ + use HasAddresses; +} +``` + +## Relationships + +### `addressLinks()` + +Returns all `AddressLink` pivot rows for this model. + +```php +$links = $user->addressLinks; + +foreach ($links as $link) { + echo $link->type->label(); // "Office" + echo $link->label; // "Main Office" + echo $link->address->city; // "Vienna" +} +``` + +**Return:** `MorphMany` of `AddressLink` + +### `addresses()` + +Returns all `Address` models linked to this model (many-to-many through `address_links`). Pivot columns are included automatically. + +```php +$addresses = $user->addresses; + +foreach ($addresses as $address) { + echo $address->city; + echo $address->pivot->type; // "office" + echo $address->pivot->is_primary; // true/false +} +``` + +**Return:** `MorphToMany` of `Address` (with pivot: `id`, `type`, `label`, `is_primary`, `active_from`, `active_until`, `meta`) + +--- + +## Adding Addresses + +### `addAddress(array $attributes, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink` + +Creates a new `Address` record and links it to this model in one step. + +```php +use Blax\Addresses\Enums\AddressLinkType; + +// Minimal +$link = $user->addAddress([ + 'city' => 'Vienna', + 'country_code' => 'AT', +]); + +// With type +$link = $user->addAddress([ + 'street' => '350 Fifth Avenue', + 'city' => 'New York', + 'postal_code' => '10118', + 'country_code' => 'US', +], AddressLinkType::Office); + +// With type and pivot data +$link = $user->addAddress([ + 'street' => 'Baker Street 221B', + 'city' => 'London', + 'country_code' => 'GB', +], AddressLinkType::Home, [ + 'label' => 'Primary Residence', + 'is_primary' => true, + 'meta' => ['floor' => 'ground'], +]); +``` + +**Parameters:** +- `$attributes` — Address fields (street, city, latitude, etc.) +- `$type` — `AddressLinkType` enum, string value, or `null` (uses config default) +- `$pivot` — Extra link data: `label`, `is_primary`, `active_from`, `active_until`, `meta` + +**Returns:** The created `AddressLink` with the `address` relation loaded. + +### `linkAddress(Address|int $address, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink` + +Links an **existing** address to this model. Useful when the same address should be shared by multiple models. + +```php +use Blax\Addresses\Models\Address; + +$address = Address::create([ + 'street' => 'Shared Office Road 1', + 'city' => 'Berlin', + 'country_code' => 'DE', +]); + +// Link to multiple models +$user->linkAddress($address, AddressLinkType::Office); +$company->linkAddress($address, AddressLinkType::Headquarters); + +// Also accepts an address ID +$user->linkAddress($address->id, AddressLinkType::Home); +``` + +**Returns:** The created `AddressLink` with the `address` relation loaded. + +--- + +## Removing Addresses + +### `removeAddressLink(int $linkId): bool` + +Removes a specific address link by its pivot ID. The address record is preserved. + +```php +$link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + +$user->removeAddressLink($link->id); // true +``` + +### `detachAddress(Address|int $address): int` + +Removes **all** links between this model and a specific address. + +```php +// If the user has multiple links to the same address (e.g. Office + Billing) +$removed = $user->detachAddress($address); // 2 +``` + +**Returns:** Number of links removed. + +### `detachAllAddresses(): int` + +Removes all address links from this model. + +```php +$removed = $user->detachAllAddresses(); // 5 +``` + +**Returns:** Number of links removed. + +> **Note:** These methods only remove the `AddressLink` pivot rows. The `Address` records themselves are never deleted by these operations. + +--- + +## Querying + +### `addressesOfType(AddressLinkType|string $type): Collection` + +Get all addresses linked with a specific type. + +```php +$offices = $user->addressesOfType(AddressLinkType::Office); +$homes = $user->addressesOfType('home'); // string value also works +``` + +### `activeAddressLinks(): Collection` + +Get all address links that are currently active (respecting `active_from` / `active_until`). + +```php +$activeLinks = $user->activeAddressLinks(); + +foreach ($activeLinks as $link) { + echo $link->address->formatted; +} +``` + +A link is active when: +- `active_from` is `null` OR in the past/present **AND** +- `active_until` is `null` OR in the future + +### `primaryAddress(AddressLinkType|string|null $type = null): ?Address` + +Get the primary address, optionally filtered by type. + +```php +// Primary address across all types +$primary = $user->primaryAddress(); + +// Primary office address specifically +$primaryOffice = $user->primaryAddress(AddressLinkType::Office); +``` + +**Returns:** `Address` or `null`. + +### `hasAddresses(): bool` + +Check whether this model has any address linked. + +```php +if ($user->hasAddresses()) { + // ... +} +``` + +### `hasAddressOfType(AddressLinkType|string $type): bool` + +Check whether this model has an address of a specific type. + +```php +if (! $user->hasAddressOfType(AddressLinkType::Billing)) { + // prompt user to add a billing address +} +``` + +--- + +## Updating + +### `setPrimaryAddressLink(int $linkId): bool` + +Set an address link as the primary for its type. Automatically unsets any previous primary of the same type on this model. + +```php +$officeA = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); +$officeB = $user->addAddress(['city' => 'Berlin'], AddressLinkType::Office); + +$user->setPrimaryAddressLink($officeA->id); // Vienna is primary +$user->setPrimaryAddressLink($officeB->id); // Berlin is now primary, Vienna is unset +``` + +**Returns:** `true` on success, `false` if the link ID was not found. + +--- + +## Temporal Validity + +Address links support time-based activation via `active_from` and `active_until`: + +```php +$link = $user->addAddress([ + 'street' => 'Summer Cottage Lane 3', + 'city' => 'Hallstatt', +], AddressLinkType::SecondaryResidence, [ + 'active_from' => '2025-06-01', + 'active_until' => '2025-09-01', +]); + +// Check if a specific link is active +$link->isActive(); // depends on current date + +// Get only active links +$user->activeAddressLinks(); + +// Query scopes on AddressLink +AddressLink::active()->get(); +AddressLink::expired()->get(); +``` + +--- + +## Working with the pivot + +When accessing addresses through the `addresses()` relationship, all pivot columns are available: + +```php +foreach ($user->addresses as $address) { + $address->pivot->type; // "office" + $address->pivot->label; // "Main Office" + $address->pivot->is_primary; // true + $address->pivot->active_from; // "2025-01-01 00:00:00" + $address->pivot->active_until; // null + $address->pivot->meta; // "{...}" +} +``` + +--- + +## Full Example + +```php +use App\Models\User; +use Blax\Addresses\Enums\AddressLinkType; + +$user = User::create(['name' => 'Jane Doe', 'email' => 'jane@example.com']); + +// Add a home address (primary) +$home = $user->addAddress([ + 'street' => 'Musterstraße 42', + 'postal_code' => '1010', + 'city' => 'Vienna', + 'country_code' => 'AT', + 'latitude' => 48.2082, + 'longitude' => 16.3738, +], AddressLinkType::Home, [ + 'is_primary' => true, +]); + +// Add an office address +$office = $user->addAddress([ + 'street' => 'Kärntner Straße 21', + 'postal_code' => '1010', + 'city' => 'Vienna', + 'country_code' => 'AT', +], AddressLinkType::Office, [ + 'label' => 'Downtown Office', +]); + +// Query +$user->hasAddresses(); // true +$user->hasAddressOfType(AddressLinkType::Shipping); // false +$user->primaryAddress(AddressLinkType::Home); // → Address { Musterstraße 42 } +$user->addressesOfType(AddressLinkType::Office); // → Collection with 1 Address + +// Switch primary +$newHome = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home); +$user->setPrimaryAddressLink($newHome->id); +$user->primaryAddress(AddressLinkType::Home); // → Address { city: Graz } + +// Clean up +$user->removeAddressLink($office->id); // remove office link +$user->detachAllAddresses(); // remove everything +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..e23a8e8 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,106 @@ +# Installation & Configuration + +## Requirements + +- PHP 8.1 or higher +- Laravel 9.x, 10.x, 11.x or 12.x +- `blax-software/laravel-workkit` (pulled in automatically as a dependency) + +## Install via Composer + +```bash +composer require blax-software/laravel-addresses +``` + +The service provider is registered automatically via Laravel's package auto-discovery. + +## Publish Migrations + +```bash +php artisan vendor:publish --tag="addresses-migrations" +php artisan migrate +``` + +This creates three tables: + +| Table | Purpose | +|-----------------------|--------------------------------------------------------| +| `addresses` | Physical address records (street, city, coordinates …) | +| `address_links` | Polymorphic pivot connecting addresses to models | +| `address_assignments` | References an address link from another model context | + +## Publish Configuration (optional) + +```bash +php artisan vendor:publish --tag="addresses-config" +``` + +This copies `config/addresses.php` into your application's `config/` directory. + +## Configuration Reference + +```php +// config/addresses.php + +return [ + /* + |-------------------------------------------------------------------------- + | Model Classes + |-------------------------------------------------------------------------- + | + | Override with your own model classes if you need to extend the package + | models. Your custom models should extend the corresponding package model. + | + */ + 'models' => [ + 'address' => \Blax\Addresses\Models\Address::class, + 'address_link' => \Blax\Addresses\Models\AddressLink::class, + 'address_assignment' => \Blax\Addresses\Models\AddressAssignment::class, + ], + + /* + |-------------------------------------------------------------------------- + | Table Names + |-------------------------------------------------------------------------- + | + | Change these if the default names collide with existing tables. + | + */ + 'table_names' => [ + 'addresses' => 'addresses', + 'address_links' => 'address_links', + 'address_assignments' => 'address_assignments', + ], + + /* + |-------------------------------------------------------------------------- + | Default Address Link Type + |-------------------------------------------------------------------------- + | + | Applied when attaching an address without specifying a type. + | + */ + 'default_link_type' => \Blax\Addresses\Enums\AddressLinkType::Other, +]; +``` + +### Models + +Each model key maps to a fully-qualified class name. To customise behaviour, create your own model that extends the package model and update the config: + +```php +'models' => [ + 'address' => \App\Models\CustomAddress::class, + // ... +], +``` + +See [Customization](customization.md) for details. + +### Table Names + +All three table names are configurable. If you change them, make sure to update the published migration before running it. + +### Default Link Type + +When you call `$model->addAddress([...])` without a second argument, this type is used. Defaults to `AddressLinkType::Other`. diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..818d395 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..4ee3110 --- /dev/null +++ b/pint.json @@ -0,0 +1,4 @@ +{ + "preset": "laravel", + "rules": {} +} \ No newline at end of file diff --git a/src/AddressesServiceProvider.php b/src/AddressesServiceProvider.php new file mode 100644 index 0000000..a573612 --- /dev/null +++ b/src/AddressesServiceProvider.php @@ -0,0 +1,111 @@ +mergeConfigFrom( + __DIR__ . '/../config/addresses.php', + 'addresses' + ); + + // Register AddressService as a singleton. + $this->app->singleton(AddressService::class); + } + + /** + * Bootstrap the application events. + * + * Publishes config and migration stubs, and registers model bindings + * so that the container always resolves the (possibly overridden) model. + */ + public function boot(): void + { + $this->offerPublishing(); + + $this->registerModelBindings(); + } + + /* + |-------------------------------------------------------------------------- + | Publishing + |-------------------------------------------------------------------------- + */ + + /** + * Set up publishing of config and migration files for `php artisan vendor:publish`. + */ + protected function offerPublishing(): void + { + if (! $this->app->runningInConsole()) { + return; + } + + // Config + $this->publishes([ + __DIR__ . '/../config/addresses.php' => $this->app->configPath('addresses.php'), + ], 'addresses-config'); + + // Migrations + $this->publishes([ + __DIR__ . '/../database/migrations/create_blax_address_tables.php.stub' => $this->getMigrationFileName('create_blax_address_tables.php'), + ], 'addresses-migrations'); + } + + /** + * Returns an existing migration file if one is already published, + * otherwise generates a timestamped path. + */ + protected function getMigrationFileName(string $migrationFileName): string + { + $timestamp = date('Y_m_d_His'); + + $filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class); + + return \Illuminate\Support\Collection::make([ + $this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR, + ]) + ->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName)) + ->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}") + ->first(); + } + + /* + |-------------------------------------------------------------------------- + | Model Bindings + |-------------------------------------------------------------------------- + */ + + /** + * Bind the package model abstractions to the (potentially customised) + * concrete classes from config. + */ + protected function registerModelBindings(): void + { + $this->app->bind( + \Blax\Addresses\Models\Address::class, + fn($app) => $app->make($app->config['addresses.models.address']) + ); + + $this->app->bind( + \Blax\Addresses\Models\AddressLink::class, + fn($app) => $app->make($app->config['addresses.models.address_link']) + ); + + $this->app->bind( + \Blax\Addresses\Models\AddressAssignment::class, + fn($app) => $app->make($app->config['addresses.models.address_assignment']) + ); + } +} diff --git a/src/Enums/AddressLinkType.php b/src/Enums/AddressLinkType.php new file mode 100644 index 0000000..dd3a630 --- /dev/null +++ b/src/Enums/AddressLinkType.php @@ -0,0 +1,120 @@ + 'Home', + self::SecondaryResidence => 'Secondary Residence', + self::Office => 'Office', + self::Headquarters => 'Headquarters', + self::Branch => 'Branch', + self::Factory => 'Factory', + self::Warehouse => 'Warehouse', + self::Shipping => 'Shipping', + self::Billing => 'Billing', + self::Return => 'Return', + self::Pickup => 'Pick-up', + self::PointOfInterest => 'Point of Interest', + self::Site => 'Site', + self::Temporary => 'Temporary', + self::Contact => 'Contact', + self::Legal => 'Legal', + self::Other => 'Other', + }; + } +} diff --git a/src/Models/Address.php b/src/Models/Address.php new file mode 100644 index 0000000..74f21cc --- /dev/null +++ b/src/Models/Address.php @@ -0,0 +1,165 @@ + 'float', + 'longitude' => 'float', + 'altitude' => 'float', + 'meta' => 'object', + ]; + + /* + |-------------------------------------------------------------------------- + | Constructor — configurable table name + |-------------------------------------------------------------------------- + */ + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->table = config('addresses.table_names.addresses') ?: parent::getTable(); + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * All links that reference this address (polymorphic pivot rows). + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function links() + { + return $this->hasMany( + config('addresses.models.address_link', AddressLink::class), + 'address_id' + ); + } + + /* + |-------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------- + */ + + /** + * Whether the address has geographic coordinates set. + */ + public function hasCoordinates(): bool + { + return $this->latitude !== null && $this->longitude !== null; + } + + /** + * Whether the address has altitude (AMSL) set. + */ + public function hasAltitude(): bool + { + return $this->altitude !== null; + } + + /** + * Build a single-line formatted representation of the address. + * + * Useful for display purposes — joins non-empty components with ", ". + */ + public function getFormattedAttribute(): string + { + return collect([ + $this->street, + $this->street_extra, + $this->building ? "({$this->building})" : null, + $this->floor ? "Floor {$this->floor}" : null, + $this->room ? "Room {$this->room}" : null, + $this->postal_code, + $this->city, + $this->state, + $this->county, + $this->country_code, + ])->filter()->implode(', '); + } + + /** + * Return coordinates as an associative array. + * + * @return array{latitude: float|null, longitude: float|null, altitude: float|null} + */ + public function toCoordinates(): array + { + return [ + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'altitude' => $this->altitude, + ]; + } +} diff --git a/src/Models/AddressAssignment.php b/src/Models/AddressAssignment.php new file mode 100644 index 0000000..425e9ac --- /dev/null +++ b/src/Models/AddressAssignment.php @@ -0,0 +1,130 @@ + 'object', + ]; + + /* + |-------------------------------------------------------------------------- + | Constructor — configurable table name + |-------------------------------------------------------------------------- + */ + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->table = config('addresses.table_names.address_assignments') ?: parent::getTable(); + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * The address link this assignment references. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function addressLink() + { + return $this->belongsTo( + config('addresses.models.address_link', AddressLink::class), + 'address_link_id' + ); + } + + /** + * The address (shortcut through the link). + * + * @return \Illuminate\Database\Eloquent\Relations\HasOneThrough + */ + public function address() + { + $addressModel = config('addresses.models.address', Address::class); + $linkModel = config('addresses.models.address_link', AddressLink::class); + + return $this->hasOneThrough( + $addressModel, + $linkModel, + 'id', // FK on address_links (intermediate) + 'id', // FK on addresses (final) + 'address_link_id', // local key on address_assignments + 'address_id' // FK on address_links → addresses + ); + } + + /** + * The model this address link is assigned to (Job, Order, Event …). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function assignable() + { + return $this->morphTo(); + } + + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ + + /** + * Only assignments with a specific role. + */ + public function scopeForRole($query, string $role) + { + return $query->where('role', $role); + } +} diff --git a/src/Models/AddressLink.php b/src/Models/AddressLink.php new file mode 100644 index 0000000..3ff87a9 --- /dev/null +++ b/src/Models/AddressLink.php @@ -0,0 +1,181 @@ + AddressLinkType::class, + 'is_primary' => 'boolean', + 'active_from' => 'datetime', + 'active_until' => 'datetime', + 'meta' => 'object', + ]; + + /* + |-------------------------------------------------------------------------- + | Constructor — configurable table name + |-------------------------------------------------------------------------- + */ + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->table = config('addresses.table_names.address_links') ?: parent::getTable(); + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * The address record this link points to. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function address() + { + return $this->belongsTo( + config('addresses.models.address', Address::class), + 'address_id' + ); + } + + /** + * The owning model (User, Company, Order …). + * + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function addressable() + { + return $this->morphTo(); + } + + /** + * All assignments that reference this link from other models. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function assignments() + { + return $this->hasMany( + config('addresses.models.address_assignment', AddressAssignment::class), + 'address_link_id' + ); + } + + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ + + /** + * Only links that are currently active (respects active_from / active_until). + * + * A link is active when: + * - active_from is null OR in the past/present, AND + * - active_until is null OR in the future. + */ + public function scopeActive($query) + { + return $query->where(function ($q) { + $q->whereNull('active_from') + ->orWhere('active_from', '<=', now()); + })->where(function ($q) { + $q->whereNull('active_until') + ->orWhere('active_until', '>', now()); + }); + } + + /** + * Only expired links (active_until is in the past). + */ + public function scopeExpired($query) + { + return $query->whereNotNull('active_until') + ->where('active_until', '<=', now()); + } + + /** + * Only links of a specific type. + */ + public function scopeOfType($query, AddressLinkType|string $type) + { + $value = $type instanceof AddressLinkType ? $type->value : $type; + + return $query->where('type', $value); + } + + /** + * Only primary links. + */ + public function scopePrimary($query) + { + return $query->where('is_primary', true); + } + + /* + |-------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------- + */ + + /** + * Whether this link is currently active. + */ + public function isActive(): bool + { + $fromOk = $this->active_from === null || $this->active_from->isPast() || $this->active_from->isToday(); + $untilOk = $this->active_until === null || $this->active_until->isFuture(); + + return $fromOk && $untilOk; + } +} diff --git a/src/Services/AddressService.php b/src/Services/AddressService.php new file mode 100644 index 0000000..de1314c --- /dev/null +++ b/src/Services/AddressService.php @@ -0,0 +1,553 @@ +distanceBetween($a, $b); + * address()->nearby($lat, $lng, 5); + */ +class AddressService +{ + /** + * Mean radius of the Earth in kilometres (WGS-84 volumetric mean). + */ + public const EARTH_RADIUS_KM = 6371.0; + + /** + * Mean radius of the Earth in miles. + */ + public const EARTH_RADIUS_MI = 3958.8; + + /* + |-------------------------------------------------------------------------- + | Distance Calculation + |-------------------------------------------------------------------------- + */ + + /** + * Calculate the great-circle distance between two addresses using the + * Haversine formula. + * + * Both addresses must have latitude and longitude set; returns null if + * either is missing coordinates. + * + * @param Address $from + * @param Address $to + * @param string $unit 'km' (default) or 'mi' + * @return float|null Distance in the requested unit, or null if coordinates are missing + */ + public function distanceBetween(Address $from, Address $to, string $unit = 'km'): ?float + { + if (! $from->hasCoordinates() || ! $to->hasCoordinates()) { + return null; + } + + return $this->haversine( + $from->latitude, + $from->longitude, + $to->latitude, + $to->longitude, + $unit + ); + } + + /** + * Calculate the great-circle distance between two coordinate pairs. + * + * @param float $lat1 Latitude of point A (decimal degrees) + * @param float $lng1 Longitude of point A (decimal degrees) + * @param float $lat2 Latitude of point B (decimal degrees) + * @param float $lng2 Longitude of point B (decimal degrees) + * @param string $unit 'km' (default) or 'mi' + * @return float Distance in the requested unit + */ + public function haversine(float $lat1, float $lng1, float $lat2, float $lng2, string $unit = 'km'): float + { + $radius = $unit === 'mi' ? self::EARTH_RADIUS_MI : self::EARTH_RADIUS_KM; + + $dLat = deg2rad($lat2 - $lat1); + $dLng = deg2rad($lng2 - $lng1); + + $a = sin($dLat / 2) ** 2 + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) + * sin($dLng / 2) ** 2; + + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + + return $radius * $c; + } + + /** + * Calculate the altitude difference between two addresses in metres. + * + * Returns null if either address is missing altitude data. + * + * @param Address $from + * @param Address $to + * @return float|null Signed difference (to − from) in metres + */ + public function altitudeDifference(Address $from, Address $to): ?float + { + if (! $from->hasAltitude() || ! $to->hasAltitude()) { + return null; + } + + return $to->altitude - $from->altitude; + } + + /* + |-------------------------------------------------------------------------- + | Proximity Queries + |-------------------------------------------------------------------------- + */ + + /** + * Find all addresses within a given radius of a coordinate point. + * + * Uses a bounding-box pre-filter for performance, then refines with + * Haversine. Results are ordered by distance (nearest first). + * + * @param float $latitude Centre latitude (decimal degrees) + * @param float $longitude Centre longitude (decimal degrees) + * @param float $radius Search radius + * @param string $unit 'km' (default) or 'mi' + * @return Collection Each model has a `->distance` attribute appended + */ + public function nearby(float $latitude, float $longitude, float $radius, string $unit = 'km'): Collection + { + $earthRadius = $unit === 'mi' ? self::EARTH_RADIUS_MI : self::EARTH_RADIUS_KM; + + // Bounding box pre-filter (rough but fast — avoids full-table Haversine) + $boundingBox = $this->boundingBox($latitude, $longitude, $radius, $unit); + + $addressModel = config('addresses.models.address', Address::class); + + return $addressModel::query() + ->whereNotNull('latitude') + ->whereNotNull('longitude') + ->whereBetween('latitude', [$boundingBox['minLat'], $boundingBox['maxLat']]) + ->whereBetween('longitude', [$boundingBox['minLng'], $boundingBox['maxLng']]) + ->get() + ->map(function (Address $address) use ($latitude, $longitude, $unit) { + $address->distance = $this->haversine( + $latitude, + $longitude, + $address->latitude, + $address->longitude, + $unit + ); + + return $address; + }) + ->filter(fn(Address $address) => $address->distance <= $radius) + ->sortBy('distance') + ->values(); + } + + /** + * Find addresses near a given address (convenience wrapper around nearby()). + * + * @param Address $address The reference address (must have coordinates) + * @param float $radius Search radius + * @param string $unit 'km' or 'mi' + * @param bool $excludeSelf Whether to exclude the reference address from results + * @return Collection + */ + public function nearbyAddress(Address $address, float $radius, string $unit = 'km', bool $excludeSelf = true): Collection + { + if (! $address->hasCoordinates()) { + return collect(); + } + + $results = $this->nearby($address->latitude, $address->longitude, $radius, $unit); + + if ($excludeSelf) { + $results = $results->reject(fn(Address $a) => $a->id === $address->id)->values(); + } + + return $results; + } + + /** + * Get the closest address to a coordinate point. + * + * @param float $latitude + * @param float $longitude + * @return Address|null The nearest address, with `->distance` appended (km) + */ + public function closest(float $latitude, float $longitude): ?Address + { + $addressModel = config('addresses.models.address', Address::class); + + $addresses = $addressModel::query() + ->whereNotNull('latitude') + ->whereNotNull('longitude') + ->get(); + + if ($addresses->isEmpty()) { + return null; + } + + return $addresses + ->map(function (Address $address) use ($latitude, $longitude) { + $address->distance = $this->haversine( + $latitude, + $longitude, + $address->latitude, + $address->longitude, + ); + + return $address; + }) + ->sortBy('distance') + ->first(); + } + + /* + |-------------------------------------------------------------------------- + | Bounding Box + |-------------------------------------------------------------------------- + */ + + /** + * Calculate a latitude/longitude bounding box around a centre point. + * + * Useful as a fast pre-filter before applying the more expensive + * Haversine calculation. + * + * @param float $latitude + * @param float $longitude + * @param float $radius + * @param string $unit 'km' or 'mi' + * @return array{minLat: float, maxLat: float, minLng: float, maxLng: float} + */ + public function boundingBox(float $latitude, float $longitude, float $radius, string $unit = 'km'): array + { + $earthRadius = $unit === 'mi' ? self::EARTH_RADIUS_MI : self::EARTH_RADIUS_KM; + + // Angular radius in degrees + $angularRadius = rad2deg($radius / $earthRadius); + + // Latitude bounds are straightforward + $minLat = $latitude - $angularRadius; + $maxLat = $latitude + $angularRadius; + + // Longitude bounds must account for meridian convergence + $lngDelta = rad2deg(asin(sin(deg2rad($angularRadius)) / cos(deg2rad($latitude)))); + $minLng = $longitude - $lngDelta; + $maxLng = $longitude + $lngDelta; + + return [ + 'minLat' => max($minLat, -90), + 'maxLat' => min($maxLat, 90), + 'minLng' => max($minLng, -180), + 'maxLng' => min($maxLng, 180), + ]; + } + + /* + |-------------------------------------------------------------------------- + | Duplicate / Match Detection + |-------------------------------------------------------------------------- + */ + + /** + * Find addresses that look like potential duplicates of the given address. + * + * Matches on the combination of street, postal_code, city and country_code. + * Coordinates are NOT considered (two records for the same street may have + * slightly different GPS positions). + * + * @param Address $address + * @return Collection Potential duplicates (excluding the address itself) + */ + public function findDuplicates(Address $address): Collection + { + $addressModel = config('addresses.models.address', Address::class); + + return $addressModel::query() + ->where('id', '!=', $address->id) + ->when($address->street, fn(Builder $q) => $q->where('street', $address->street)) + ->when($address->postal_code, fn(Builder $q) => $q->where('postal_code', $address->postal_code)) + ->when($address->city, fn(Builder $q) => $q->where('city', $address->city)) + ->when($address->country_code, fn(Builder $q) => $q->where('country_code', $address->country_code)) + ->get(); + } + + /** + * Merge a duplicate address into a target address. + * + * All address_links currently pointing to `$duplicate` are re-pointed to + * `$target`. The duplicate address is then soft-deleted. + * + * @param Address $target The address to keep + * @param Address $duplicate The address to merge away + * @return int Number of links reassigned + */ + public function merge(Address $target, Address $duplicate): int + { + $linkModel = config('addresses.models.address_link', AddressLink::class); + + $affected = $linkModel::where('address_id', $duplicate->id) + ->update(['address_id' => $target->id]); + + $duplicate->delete(); + + return $affected; + } + + /* + |-------------------------------------------------------------------------- + | Query Scopes / Builders + |-------------------------------------------------------------------------- + */ + + /** + * Get a query builder for addresses filtered by country code. + * + * @param string $countryCode ISO 3166-1 alpha-2 code + * @return Builder + */ + public function inCountry(string $countryCode): Builder + { + $addressModel = config('addresses.models.address', Address::class); + + return $addressModel::where('country_code', strtoupper($countryCode)); + } + + /** + * Get a query builder for addresses in a specific city. + * + * @param string $city + * @param string|null $countryCode Optional ISO alpha-2 filter + * @return Builder + */ + public function inCity(string $city, ?string $countryCode = null): Builder + { + $addressModel = config('addresses.models.address', Address::class); + + $query = $addressModel::where('city', $city); + + if ($countryCode) { + $query->where('country_code', strtoupper($countryCode)); + } + + return $query; + } + + /** + * Get a query builder for addresses matching a postal code. + * + * @param string $postalCode + * @param string|null $countryCode Optional ISO alpha-2 filter + * @return Builder + */ + public function inPostalCode(string $postalCode, ?string $countryCode = null): Builder + { + $addressModel = config('addresses.models.address', Address::class); + + $query = $addressModel::where('postal_code', $postalCode); + + if ($countryCode) { + $query->where('country_code', strtoupper($countryCode)); + } + + return $query; + } + + /** + * Get all addresses that have coordinates set. + * + * @return Builder + */ + public function withCoordinates(): Builder + { + $addressModel = config('addresses.models.address', Address::class); + + return $addressModel::whereNotNull('latitude') + ->whereNotNull('longitude'); + } + + /* + |-------------------------------------------------------------------------- + | Formatting + |-------------------------------------------------------------------------- + */ + + /** + * Build a single-line formatted string from an address. + * + * Joins non-empty components with the given separator. + * + * @param Address $address + * @param string $separator Glue between parts (default: ", ") + * @return string + */ + public function format(Address $address, string $separator = ', '): string + { + return collect([ + $address->street, + $address->street_extra, + $address->building ? "({$address->building})" : null, + $address->floor ? "Floor {$address->floor}" : null, + $address->room ? "Room {$address->room}" : null, + $address->postal_code, + $address->city, + $address->state, + $address->county, + $address->country_code, + ])->filter()->implode($separator); + } + + /** + * Build a multi-line formatted string from an address. + * + * Produces a postal-style block, e.g.: + * + * 350 Fifth Avenue, Suite 3200 + * Empire State Building, Floor 32, Room 3201 + * 10118 New York, NY + * US + * + * @param Address $address + * @return string + */ + public function formatMultiline(Address $address): string + { + $lines = []; + + // Line 1: street + $street = collect([$address->street, $address->street_extra])->filter()->implode(', '); + if ($street) { + $lines[] = $street; + } + + // Line 2: building / indoor + $indoor = collect([ + $address->building, + $address->floor ? "Floor {$address->floor}" : null, + $address->room ? "Room {$address->room}" : null, + ])->filter()->implode(', '); + if ($indoor) { + $lines[] = $indoor; + } + + // Line 3: postal code + city + state + $cityLine = collect([ + collect([$address->postal_code, $address->city])->filter()->implode(' '), + $address->state, + ])->filter()->implode(', '); + if ($cityLine) { + $lines[] = $cityLine; + } + + // Line 4: county (if present) + if ($address->county) { + $lines[] = $address->county; + } + + // Line 5: country + if ($address->country_code) { + $lines[] = $address->country_code; + } + + return implode("\n", $lines); + } + + /** + * Format coordinates as a human-readable string. + * + * Example: "48.2082000°N, 16.3738000°E (alt: 171.00m AMSL)" + * + * @param Address $address + * @return string|null null if no coordinates are set + */ + public function formatCoordinates(Address $address): ?string + { + if (! $address->hasCoordinates()) { + return null; + } + + $lat = number_format(abs($address->latitude), 7); + $latDir = $address->latitude >= 0 ? 'N' : 'S'; + + $lng = number_format(abs($address->longitude), 7); + $lngDir = $address->longitude >= 0 ? 'E' : 'W'; + + $result = "{$lat}°{$latDir}, {$lng}°{$lngDir}"; + + if ($address->hasAltitude()) { + $alt = number_format($address->altitude, 2); + $result .= " (alt: {$alt}m AMSL)"; + } + + return $result; + } + + /* + |-------------------------------------------------------------------------- + | Coordinate Conversion + |-------------------------------------------------------------------------- + */ + + /** + * Convert degrees, minutes, seconds (DMS) to decimal degrees. + * + * @param int $degrees + * @param int $minutes + * @param float $seconds + * @param string $direction 'N', 'S', 'E' or 'W' + * @return float Decimal degrees (negative for S/W) + */ + public function dmsToDecimal(int $degrees, int $minutes, float $seconds, string $direction): float + { + $decimal = $degrees + ($minutes / 60) + ($seconds / 3600); + + if (in_array(strtoupper($direction), ['S', 'W'])) { + $decimal *= -1; + } + + return $decimal; + } + + /** + * Convert decimal degrees to degrees, minutes, seconds (DMS). + * + * @param float $decimal Decimal degrees + * @param string $axis 'lat' or 'lng' — determines the direction letter + * @return array{degrees: int, minutes: int, seconds: float, direction: string} + */ + public function decimalToDms(float $decimal, string $axis = 'lat'): array + { + $direction = $axis === 'lat' + ? ($decimal >= 0 ? 'N' : 'S') + : ($decimal >= 0 ? 'E' : 'W'); + + $decimal = abs($decimal); + $degrees = (int) floor($decimal); + $minutesDecimal = ($decimal - $degrees) * 60; + $minutes = (int) floor($minutesDecimal); + $seconds = round(($minutesDecimal - $minutes) * 60, 4); + + return [ + 'degrees' => $degrees, + 'minutes' => $minutes, + 'seconds' => $seconds, + 'direction' => $direction, + ]; + } +} diff --git a/src/Traits/HasAddressAssignments.php b/src/Traits/HasAddressAssignments.php new file mode 100644 index 0000000..8aba9fd --- /dev/null +++ b/src/Traits/HasAddressAssignments.php @@ -0,0 +1,193 @@ +addAddress([…], AddressLinkType::Office); + * + * // Job references it + * $job->assignAddressLink($link, 'pickup'); + */ +trait HasAddressAssignments +{ + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * All address assignments for this model. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphMany + */ + public function addressAssignments() + { + return $this->morphMany( + config('addresses.models.address_assignment', AddressAssignment::class), + 'assignable' + ); + } + + /* + |-------------------------------------------------------------------------- + | Assigning / unassigning + |-------------------------------------------------------------------------- + */ + + /** + * Assign an existing address link to this model. + * + * @param AddressLink|int $addressLink AddressLink model or ID + * @param string|null $role Context-specific role ("pickup", "delivery", …) + * @param array $extra Additional attributes (label, meta) + * @return AddressAssignment + */ + public function assignAddressLink(AddressLink|int $addressLink, ?string $role = null, array $extra = []): AddressAssignment + { + $linkId = $addressLink instanceof AddressLink ? $addressLink->id : $addressLink; + + $assignmentModel = config('addresses.models.address_assignment', AddressAssignment::class); + + /** @var AddressAssignment $assignment */ + $assignment = $assignmentModel::create(array_merge([ + 'address_link_id' => $linkId, + 'assignable_type' => $this->getMorphClass(), + 'assignable_id' => $this->getKey(), + 'role' => $role, + ], $extra)); + + $assignment->load('addressLink.address'); + + return $assignment; + } + + /** + * Remove a specific address assignment by its ID. + * + * @param int $assignmentId + * @return bool + */ + public function removeAddressAssignment(int $assignmentId): bool + { + return (bool) $this->addressAssignments()->where('id', $assignmentId)->delete(); + } + + /** + * Remove all assignments for a specific role. + * + * @param string $role + * @return int Number of assignments removed + */ + public function removeAssignmentsForRole(string $role): int + { + return $this->addressAssignments()->where('role', $role)->delete(); + } + + /** + * Remove all address assignments from this model. + * + * @return int Number of assignments removed + */ + public function removeAllAddressAssignments(): int + { + return $this->addressAssignments()->delete(); + } + + /* + |-------------------------------------------------------------------------- + | Querying + |-------------------------------------------------------------------------- + */ + + /** + * Get the assignment for a specific role. + * + * @param string $role + * @return AddressAssignment|null + */ + public function addressAssignmentForRole(string $role): ?AddressAssignment + { + return $this->addressAssignments() + ->where('role', $role) + ->with('addressLink.address') + ->first(); + } + + /** + * Get all assignments for a specific role. + * + * @param string $role + * @return Collection + */ + public function addressAssignmentsForRole(string $role): Collection + { + return $this->addressAssignments() + ->where('role', $role) + ->with('addressLink.address') + ->get(); + } + + /** + * Get the address for a specific assignment role (convenience shortcut). + * + * @param string $role + * @return Address|null + */ + public function assignedAddressForRole(string $role): ?Address + { + $assignment = $this->addressAssignmentForRole($role); + + return $assignment?->addressLink?->address; + } + + /** + * Get all addresses assigned to this model (through their links). + * + * @return Collection
+ */ + public function assignedAddresses(): Collection + { + return $this->addressAssignments() + ->with('addressLink.address') + ->get() + ->map(fn(AddressAssignment $a) => $a->addressLink?->address) + ->filter() + ->values(); + } + + /** + * Check whether this model has any address assignments. + */ + public function hasAddressAssignments(): bool + { + return $this->addressAssignments()->exists(); + } + + /** + * Check whether this model has an assignment for the given role. + * + * @param string $role + */ + public function hasAssignmentForRole(string $role): bool + { + return $this->addressAssignments()->where('role', $role)->exists(); + } +} diff --git a/src/Traits/HasAddresses.php b/src/Traits/HasAddresses.php new file mode 100644 index 0000000..4be0799 --- /dev/null +++ b/src/Traits/HasAddresses.php @@ -0,0 +1,283 @@ +addAddress([…], AddressLinkType::Office); + * $customer->addresses; // all linked addresses + * $customer->addressesOfType('billing'); // filtered by type + */ +trait HasAddresses +{ + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * All address links for this model (polymorphic one-to-many through pivot). + * + * Access the actual Address via `$link->address`. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphMany + */ + public function addressLinks() + { + return $this->morphMany( + config('addresses.models.address_link', AddressLink::class), + 'addressable' + ); + } + + /** + * All addresses attached to this model (many-to-many through address_links). + * + * Pivot columns (type, label, is_primary, active_from, active_until, meta) + * are included automatically. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + */ + public function addresses() + { + $linkTable = config('addresses.table_names.address_links', 'address_links'); + + return $this->morphToMany( + config('addresses.models.address', Address::class), + 'addressable', + $linkTable, + )->withPivot('id', 'type', 'label', 'is_primary', 'active_from', 'active_until', 'meta') + ->withTimestamps(); + } + + /* + |-------------------------------------------------------------------------- + | Adding / attaching + |-------------------------------------------------------------------------- + */ + + /** + * Create a new address and link it to this model in one step. + * + * @param array $attributes Address attributes (street, city, …) + * @param AddressLinkType|string|null $type Purpose of the link (default from config) + * @param array $pivot Extra pivot data (label, is_primary, active_from, active_until, meta) + * @return AddressLink The created link (with address relation loaded) + */ + public function addAddress(array $attributes, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink + { + $addressModel = config('addresses.models.address', Address::class); + + /** @var Address $address */ + $address = $addressModel::create($attributes); + + return $this->linkAddress($address, $type, $pivot); + } + + /** + * Link an existing address to this model. + * + * @param Address|int $address Address model or ID + * @param AddressLinkType|string|null $type Purpose of the link + * @param array $pivot Extra pivot data + * @return AddressLink The created link + */ + public function linkAddress(Address|int $address, AddressLinkType|string|null $type = null, array $pivot = []): AddressLink + { + $addressId = $address instanceof Address ? $address->id : $address; + + $type = $type instanceof AddressLinkType + ? $type->value + : ($type ?? config('addresses.default_link_type', AddressLinkType::Other)->value); + + $linkModel = config('addresses.models.address_link', AddressLink::class); + + /** @var AddressLink $link */ + $link = $linkModel::create(array_merge([ + 'address_id' => $addressId, + 'addressable_type' => $this->getMorphClass(), + 'addressable_id' => $this->getKey(), + 'type' => $type, + ], $pivot)); + + $link->load('address'); + + return $link; + } + + /* + |-------------------------------------------------------------------------- + | Removing / detaching + |-------------------------------------------------------------------------- + */ + + /** + * Remove a specific address link by its pivot ID. + * + * This only removes the link — the address itself is preserved. + * + * @param int $linkId The AddressLink ID + * @return bool + */ + public function removeAddressLink(int $linkId): bool + { + return (bool) $this->addressLinks()->where('id', $linkId)->delete(); + } + + /** + * Detach an address completely from this model (removes all links to it). + * + * The address record itself is preserved. + * + * @param Address|int $address Address model or ID + * @return int Number of links removed + */ + public function detachAddress(Address|int $address): int + { + $addressId = $address instanceof Address ? $address->id : $address; + + return $this->addressLinks()->where('address_id', $addressId)->delete(); + } + + /** + * Remove all address links from this model. + * + * Address records remain untouched. + * + * @return int Number of links removed + */ + public function detachAllAddresses(): int + { + return $this->addressLinks()->delete(); + } + + /* + |-------------------------------------------------------------------------- + | Querying + |-------------------------------------------------------------------------- + */ + + /** + * Get all addresses of a specific link type. + * + * @param AddressLinkType|string $type + * @return Collection
+ */ + public function addressesOfType(AddressLinkType|string $type): Collection + { + $value = $type instanceof AddressLinkType ? $type->value : $type; + + return $this->addresses() + ->wherePivot('type', $value) + ->get(); + } + + /** + * Get all currently active address links. + * + * @return Collection + */ + public function activeAddressLinks(): Collection + { + return $this->addressLinks() + ->where(function ($q) { + $q->whereNull('active_from') + ->orWhere('active_from', '<=', now()); + }) + ->where(function ($q) { + $q->whereNull('active_until') + ->orWhere('active_until', '>', now()); + }) + ->with('address') + ->get(); + } + + /** + * Get the primary address for a given type (or across all types if null). + * + * @param AddressLinkType|string|null $type + * @return Address|null + */ + public function primaryAddress(AddressLinkType|string|null $type = null): ?Address + { + $query = $this->addressLinks() + ->where('is_primary', true); + + if ($type !== null) { + $value = $type instanceof AddressLinkType ? $type->value : $type; + $query->where('type', $value); + } + + /** @var AddressLink|null $link */ + $link = $query->with('address')->first(); + + return $link?->address; + } + + /** + * Check whether this model has any address linked. + */ + public function hasAddresses(): bool + { + return $this->addressLinks()->exists(); + } + + /** + * Check whether this model has an address of the given type. + * + * @param AddressLinkType|string $type + */ + public function hasAddressOfType(AddressLinkType|string $type): bool + { + $value = $type instanceof AddressLinkType ? $type->value : $type; + + return $this->addressLinks()->where('type', $value)->exists(); + } + + /* + |-------------------------------------------------------------------------- + | Updating + |-------------------------------------------------------------------------- + */ + + /** + * Set an address link as the primary for its type, unsetting any previous primary. + * + * @param int $linkId The AddressLink ID to promote + * @return bool + */ + public function setPrimaryAddressLink(int $linkId): bool + { + $linkModel = config('addresses.models.address_link', AddressLink::class); + + /** @var AddressLink|null $link */ + $link = $this->addressLinks()->where('id', $linkId)->first(); + + if (! $link) { + return false; + } + + // Unset any existing primary for the same type on this model. + $this->addressLinks() + ->where('type', $link->type instanceof AddressLinkType ? $link->type->value : $link->type) + ->where('is_primary', true) + ->update(['is_primary' => false]); + + $link->update(['is_primary' => true]); + + return true; + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..843a918 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,15 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); + } + + // ─── basic relationship ────────────────────────────────────── + + public function test_model_has_no_addresses_by_default(): void + { + $user = User::factory()->create(); + + $this->assertCount(0, $user->addresses); + $this->assertCount(0, $user->addressLinks); + } + + // ─── addAddress ────────────────────────────────────────────── + + public function test_add_address_creates_address_and_link(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress([ + 'street' => 'Hauptstraße 1', + 'city' => 'Vienna', + 'postal_code' => '1010', + 'country_code' => 'AT', + ], AddressLinkType::Home); + + $this->assertInstanceOf(AddressLink::class, $link); + $this->assertInstanceOf(Address::class, $link->address); + $this->assertEquals('Hauptstraße 1', $link->address->street); + $this->assertEquals('Vienna', $link->address->city); + $this->assertEquals(AddressLinkType::Home, $link->type); + } + + public function test_add_address_with_default_type(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress([ + 'city' => 'London', + 'country_code' => 'GB', + ]); + + $this->assertEquals(AddressLinkType::Other, $link->type); + } + + public function test_add_address_with_string_type(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress([ + 'city' => 'Berlin', + ], 'office'); + + $this->assertEquals(AddressLinkType::Office, $link->type); + } + + public function test_add_address_with_full_details(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress([ + 'street' => '350 Fifth Avenue', + 'street_extra' => 'Suite 3200', + 'building' => 'Empire State Building', + 'floor' => '32', + 'room' => '3201', + 'postal_code' => '10118', + 'city' => 'New York', + 'state' => 'NY', + 'county' => 'New York County', + 'country_code' => 'US', + 'latitude' => 40.7484405, + 'longitude' => -73.9856644, + 'altitude' => 373.0, // approximate AMSL for floor 32 + 'notes' => 'Reception on the left', + ], AddressLinkType::Office, ['label' => 'Empire State Office']); + + $address = $link->address; + + $this->assertEquals('Empire State Office', $link->label); + $this->assertEquals('350 Fifth Avenue', $address->street); + $this->assertEquals('Suite 3200', $address->street_extra); + $this->assertEquals('Empire State Building', $address->building); + $this->assertEquals('32', $address->floor); + $this->assertEquals('3201', $address->room); + $this->assertEquals('10118', $address->postal_code); + $this->assertEquals('New York', $address->city); + $this->assertEquals('NY', $address->state); + $this->assertEquals('New York County', $address->county); + $this->assertEquals('US', $address->country_code); + $this->assertEqualsWithDelta(40.7484405, $address->latitude, 0.0001); + $this->assertEqualsWithDelta(-73.9856644, $address->longitude, 0.0001); + $this->assertEqualsWithDelta(373.0, $address->altitude, 0.01); + $this->assertEquals('Reception on the left', $address->notes); + } + + public function test_add_address_coordinates_only(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress([ + 'latitude' => 47.0707, + 'longitude' => 15.4395, + 'altitude' => 853.2, + ], AddressLinkType::PointOfInterest); + + $address = $link->address; + + $this->assertTrue($address->hasCoordinates()); + $this->assertTrue($address->hasAltitude()); + $this->assertNull($address->street); + $this->assertNull($address->city); + $this->assertEquals(AddressLinkType::PointOfInterest, $link->type); + } + + // ─── linkAddress ───────────────────────────────────────────── + + public function test_link_existing_address(): void + { + $user = User::factory()->create(); + $address = Address::create([ + 'street' => 'Reusable Street 5', + 'city' => 'Graz', + 'country_code' => 'AT', + ]); + + $link = $user->linkAddress($address, AddressLinkType::Home); + + $this->assertEquals($address->id, $link->address_id); + $this->assertTrue($user->hasAddresses()); + } + + public function test_link_address_by_id(): void + { + $user = User::factory()->create(); + $address = Address::create(['city' => 'Salzburg', 'country_code' => 'AT']); + + $link = $user->linkAddress($address->id, AddressLinkType::Billing); + + $this->assertEquals($address->id, $link->address_id); + $this->assertEquals(AddressLinkType::Billing, $link->type); + } + + public function test_same_address_linked_multiple_times_with_different_types(): void + { + $user = User::factory()->create(); + $address = Address::create(['city' => 'Linz', 'country_code' => 'AT']); + + $user->linkAddress($address, AddressLinkType::Office); + $user->linkAddress($address, AddressLinkType::Billing); + + $this->assertCount(2, $user->addressLinks()->get()); + $this->assertCount(2, $user->addresses()->get()); + } + + public function test_same_address_linked_to_different_models(): void + { + $user = User::factory()->create(); + $company = Company::create(['name' => 'ACME Corp']); + $address = Address::create(['city' => 'Innsbruck', 'country_code' => 'AT']); + + $user->linkAddress($address, AddressLinkType::Home); + $company->linkAddress($address, AddressLinkType::Headquarters); + + $this->assertCount(2, $address->links()->get()); + $this->assertTrue($user->hasAddresses()); + $this->assertTrue($company->hasAddresses()); + } + + // ─── pivot data ────────────────────────────────────────────── + + public function test_link_with_active_from_and_active_until(): void + { + $user = User::factory()->create(); + + $from = now()->subDay(); + $until = now()->addYear(); + + $link = $user->addAddress( + ['city' => 'Munich', 'country_code' => 'DE'], + AddressLinkType::Temporary, + [ + 'active_from' => $from, + 'active_until' => $until, + ] + ); + + $this->assertNotNull($link->active_from); + $this->assertNotNull($link->active_until); + $this->assertTrue($link->isActive()); + } + + public function test_expired_link(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress( + ['city' => 'Paris', 'country_code' => 'FR'], + AddressLinkType::Temporary, + [ + 'active_from' => now()->subYear(), + 'active_until' => now()->subDay(), + ] + ); + + $this->assertFalse($link->isActive()); + } + + public function test_link_with_meta(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress( + ['city' => 'Tokyo', 'country_code' => 'JP'], + AddressLinkType::Office, + [ + 'meta' => ['department' => 'Engineering', 'access_code' => 'A-123'], + ] + ); + + $meta = $link->getMeta(); + $this->assertEquals('Engineering', $meta->department); + $this->assertEquals('A-123', $meta->access_code); + } + + public function test_link_with_custom_label(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress( + ['city' => 'Rome', 'country_code' => 'IT'], + AddressLinkType::Other, + ['label' => 'Aunt Maria\'s house'] + ); + + $this->assertEquals('Aunt Maria\'s house', $link->label); + $this->assertEquals(AddressLinkType::Other, $link->type); + } + + public function test_link_with_is_primary(): void + { + $user = User::factory()->create(); + + $link = $user->addAddress( + ['city' => 'Madrid', 'country_code' => 'ES'], + AddressLinkType::Home, + ['is_primary' => true] + ); + + $this->assertTrue($link->is_primary); + } + + // ─── querying ──────────────────────────────────────────────── + + public function test_addresses_of_type(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'A'], AddressLinkType::Home); + $user->addAddress(['city' => 'B'], AddressLinkType::Office); + $user->addAddress(['city' => 'C'], AddressLinkType::Home); + + $homes = $user->addressesOfType(AddressLinkType::Home); + $offices = $user->addressesOfType(AddressLinkType::Office); + + $this->assertCount(2, $homes); + $this->assertCount(1, $offices); + } + + public function test_addresses_of_type_with_string(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'A'], AddressLinkType::Billing); + + $result = $user->addressesOfType('billing'); + $this->assertCount(1, $result); + } + + public function test_has_address_of_type(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'A'], AddressLinkType::Shipping); + + $this->assertTrue($user->hasAddressOfType(AddressLinkType::Shipping)); + $this->assertFalse($user->hasAddressOfType(AddressLinkType::Billing)); + } + + public function test_has_addresses(): void + { + $user = User::factory()->create(); + $this->assertFalse($user->hasAddresses()); + + $user->addAddress(['city' => 'X']); + $this->assertTrue($user->hasAddresses()); + } + + public function test_primary_address(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'First'], AddressLinkType::Home); + $user->addAddress(['city' => 'Second'], AddressLinkType::Home, ['is_primary' => true]); + + $primary = $user->primaryAddress(AddressLinkType::Home); + $this->assertNotNull($primary); + $this->assertEquals('Second', $primary->city); + } + + public function test_primary_address_returns_null_when_none(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'NoPrimary'], AddressLinkType::Home); + + $this->assertNull($user->primaryAddress(AddressLinkType::Home)); + } + + public function test_active_address_links(): void + { + $user = User::factory()->create(); + + // Active link + $user->addAddress(['city' => 'Active'], AddressLinkType::Home, [ + 'active_from' => now()->subDay(), + 'active_until' => now()->addYear(), + ]); + + // Expired link + $user->addAddress(['city' => 'Expired'], AddressLinkType::Office, [ + 'active_from' => now()->subYear(), + 'active_until' => now()->subDay(), + ]); + + // No temporal constraints (always active) + $user->addAddress(['city' => 'Always'], AddressLinkType::Billing); + + $active = $user->activeAddressLinks(); + $this->assertCount(2, $active); + + $cities = $active->pluck('address.city')->sort()->values()->toArray(); + $this->assertEquals(['Active', 'Always'], $cities); + } + + // ─── removing / detaching ──────────────────────────────────── + + public function test_remove_address_link(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'ToRemove'], AddressLinkType::Home); + + $this->assertTrue($user->removeAddressLink($link->id)); + $this->assertFalse($user->hasAddresses()); + + // Address record still exists + $this->assertDatabaseHas('addresses', ['city' => 'ToRemove']); + } + + public function test_detach_address(): void + { + $user = User::factory()->create(); + $address = Address::create(['city' => 'Shared', 'country_code' => 'AT']); + $user->linkAddress($address, AddressLinkType::Home); + $user->linkAddress($address, AddressLinkType::Billing); + + $removed = $user->detachAddress($address); + + $this->assertEquals(2, $removed); + $this->assertFalse($user->hasAddresses()); + // Address record still exists + $this->assertDatabaseHas('addresses', ['city' => 'Shared']); + } + + public function test_detach_all_addresses(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'A'], AddressLinkType::Home); + $user->addAddress(['city' => 'B'], AddressLinkType::Office); + $user->addAddress(['city' => 'C'], AddressLinkType::Billing); + + $removed = $user->detachAllAddresses(); + + $this->assertEquals(3, $removed); + $this->assertFalse($user->hasAddresses()); + } + + // ─── set primary ───────────────────────────────────────────── + + public function test_set_primary_address_link(): void + { + $user = User::factory()->create(); + $link1 = $user->addAddress(['city' => 'First'], AddressLinkType::Home, ['is_primary' => true]); + $link2 = $user->addAddress(['city' => 'Second'], AddressLinkType::Home); + + $result = $user->setPrimaryAddressLink($link2->id); + + $this->assertTrue($result); + $this->assertFalse($link1->fresh()->is_primary); + $this->assertTrue($link2->fresh()->is_primary); + } + + public function test_set_primary_returns_false_for_nonexistent_link(): void + { + $user = User::factory()->create(); + + $this->assertFalse($user->setPrimaryAddressLink(999)); + } + + public function test_set_primary_does_not_affect_other_types(): void + { + $user = User::factory()->create(); + $homeLink = $user->addAddress(['city' => 'Home'], AddressLinkType::Home, ['is_primary' => true]); + $officeLink = $user->addAddress(['city' => 'Office'], AddressLinkType::Office); + + $user->setPrimaryAddressLink($officeLink->id); + + // Home primary should remain untouched + $this->assertTrue($homeLink->fresh()->is_primary); + $this->assertTrue($officeLink->fresh()->is_primary); + } + + // ─── Address model ─────────────────────────────────────────── + + public function test_address_has_coordinates(): void + { + $address = Address::create([ + 'latitude' => 48.2082, + 'longitude' => 16.3738, + ]); + + $this->assertTrue($address->hasCoordinates()); + } + + public function test_address_without_coordinates(): void + { + $address = Address::create([ + 'city' => 'NoCoords', + ]); + + $this->assertFalse($address->hasCoordinates()); + $this->assertFalse($address->hasAltitude()); + } + + public function test_address_to_coordinates(): void + { + $address = Address::create([ + 'latitude' => 47.0707, + 'longitude' => 15.4395, + 'altitude' => 353.0, + ]); + + $coords = $address->toCoordinates(); + + $this->assertArrayHasKey('latitude', $coords); + $this->assertArrayHasKey('longitude', $coords); + $this->assertArrayHasKey('altitude', $coords); + $this->assertEqualsWithDelta(353.0, $coords['altitude'], 0.01); + } + + public function test_address_formatted_attribute(): void + { + $address = Address::create([ + 'street' => 'Rainerstraße 4', + 'postal_code' => '4020', + 'city' => 'Linz', + 'country_code' => 'AT', + ]); + + $formatted = $address->formatted; + + $this->assertStringContainsString('Rainerstraße 4', $formatted); + $this->assertStringContainsString('4020', $formatted); + $this->assertStringContainsString('Linz', $formatted); + $this->assertStringContainsString('AT', $formatted); + } + + public function test_address_meta(): void + { + $address = Address::create([ + 'city' => 'MetaCity', + 'meta' => ['plus_code' => '8FWR39JJ+XX', 'timezone' => 'Europe/Vienna'], + ]); + + $meta = $address->getMeta(); + $this->assertEquals('8FWR39JJ+XX', $meta->plus_code); + $this->assertEquals('Europe/Vienna', $meta->timezone); + } + + public function test_address_soft_delete(): void + { + $address = Address::create(['city' => 'SoftDelete']); + $address->delete(); + + $this->assertSoftDeleted('addresses', ['city' => 'SoftDelete']); + $this->assertNull(Address::find($address->id)); + $this->assertNotNull(Address::withTrashed()->find($address->id)); + } + + // ─── AddressLink model scopes ──────────────────────────────── + + public function test_address_link_scope_active(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'Active'], AddressLinkType::Home, [ + 'active_from' => now()->subDay(), + 'active_until' => now()->addDay(), + ]); + $user->addAddress(['city' => 'Expired'], AddressLinkType::Office, [ + 'active_until' => now()->subDay(), + ]); + + $active = $user->addressLinks()->active()->get(); + $this->assertCount(1, $active); + $this->assertEquals('Active', $active->first()->address->city); + } + + public function test_address_link_scope_expired(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'Current'], AddressLinkType::Home); + $user->addAddress(['city' => 'Old'], AddressLinkType::Office, [ + 'active_until' => now()->subDay(), + ]); + + $expired = $user->addressLinks()->expired()->get(); + $this->assertCount(1, $expired); + } + + public function test_address_link_scope_of_type(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'A'], AddressLinkType::Home); + $user->addAddress(['city' => 'B'], AddressLinkType::Office); + $user->addAddress(['city' => 'C'], AddressLinkType::Home); + + $homes = $user->addressLinks()->ofType(AddressLinkType::Home)->get(); + $this->assertCount(2, $homes); + } + + public function test_address_link_scope_primary(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'Primary'], AddressLinkType::Home, ['is_primary' => true]); + $user->addAddress(['city' => 'Secondary'], AddressLinkType::Home); + + $primaries = $user->addressLinks()->primary()->get(); + $this->assertCount(1, $primaries); + } + + // ─── AddressLinkType enum ──────────────────────────────────── + + public function test_enum_values_are_strings(): void + { + $this->assertIsString(AddressLinkType::Home->value); + $this->assertEquals('home', AddressLinkType::Home->value); + $this->assertEquals('office', AddressLinkType::Office->value); + $this->assertEquals('billing', AddressLinkType::Billing->value); + } + + public function test_enum_labels(): void + { + $this->assertEquals('Home', AddressLinkType::Home->label()); + $this->assertEquals('Office', AddressLinkType::Office->label()); + $this->assertEquals('Point of Interest', AddressLinkType::PointOfInterest->label()); + $this->assertEquals('Secondary Residence', AddressLinkType::SecondaryResidence->label()); + } + + public function test_enum_can_be_cast_from_string(): void + { + $type = AddressLinkType::from('shipping'); + $this->assertEquals(AddressLinkType::Shipping, $type); + } + + // ─── polymorphic: Company model ────────────────────────────── + + public function test_company_can_have_addresses(): void + { + $company = Company::create(['name' => 'Test Corp']); + + $link = $company->addAddress([ + 'street' => 'Business Ave 42', + 'city' => 'Zurich', + 'country_code' => 'CH', + ], AddressLinkType::Headquarters); + + $this->assertTrue($company->hasAddresses()); + $this->assertEquals(AddressLinkType::Headquarters, $link->type); + } + + public function test_address_links_back_to_addressable(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Backref'], AddressLinkType::Home); + + $freshLink = AddressLink::find($link->id); + $this->assertInstanceOf(User::class, $freshLink->addressable); + $this->assertEquals($user->id, $freshLink->addressable->id); + } + + // ─── negative altitude (below sea level) ───────────────────── + + public function test_negative_altitude(): void + { + $address = Address::create([ + 'latitude' => 31.5, + 'longitude' => 35.5, + 'altitude' => -430.5, + 'country_code' => 'IL', + ]); + + $this->assertEqualsWithDelta(-430.5, $address->altitude, 0.01); + $this->assertTrue($address->hasAltitude()); + } + + // ─── cascade delete ────────────────────────────────────────── + + public function test_deleting_address_cascades_to_links(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Cascade'], AddressLinkType::Home); + $addressId = $link->address_id; + + // Force-delete the address to trigger cascade + Address::withTrashed()->find($addressId)->forceDelete(); + + $this->assertDatabaseMissing('address_links', ['address_id' => $addressId]); + } + + // ═══════════════════════════════════════════════════════════════ + // AddressService + // ═══════════════════════════════════════════════════════════════ + + protected function service(): AddressService + { + return app(AddressService::class); + } + + // ─── helper function ───────────────────────────────────────── + + public function test_address_helper_returns_service(): void + { + $this->assertInstanceOf(AddressService::class, address()); + } + + // ─── haversine / distance ──────────────────────────────────── + + public function test_haversine_same_point_returns_zero(): void + { + $distance = $this->service()->haversine(48.2082, 16.3738, 48.2082, 16.3738); + + $this->assertEquals(0.0, $distance); + } + + public function test_haversine_known_distance(): void + { + // Vienna (48.2082, 16.3738) → Graz (47.0707, 15.4395) ≈ 145 km + $distance = $this->service()->haversine(48.2082, 16.3738, 47.0707, 15.4395); + + $this->assertEqualsWithDelta(145.0, $distance, 5.0); + } + + public function test_haversine_miles(): void + { + $km = $this->service()->haversine(48.2082, 16.3738, 47.0707, 15.4395, 'km'); + $mi = $this->service()->haversine(48.2082, 16.3738, 47.0707, 15.4395, 'mi'); + + // 1 km ≈ 0.621 mi + $this->assertLessThan($km, $mi); + } + + public function test_distance_between_addresses(): void + { + $vienna = Address::create(['latitude' => 48.2082, 'longitude' => 16.3738]); + $graz = Address::create(['latitude' => 47.0707, 'longitude' => 15.4395]); + + $distance = $this->service()->distanceBetween($vienna, $graz); + + $this->assertNotNull($distance); + $this->assertEqualsWithDelta(145.0, $distance, 5.0); + } + + public function test_distance_between_returns_null_without_coordinates(): void + { + $a = Address::create(['city' => 'A']); + $b = Address::create(['city' => 'B']); + + $this->assertNull($this->service()->distanceBetween($a, $b)); + } + + public function test_altitude_difference(): void + { + $low = Address::create(['latitude' => 48.0, 'longitude' => 16.0, 'altitude' => 170.0]); + $high = Address::create(['latitude' => 47.0, 'longitude' => 15.0, 'altitude' => 850.0]); + + $diff = $this->service()->altitudeDifference($low, $high); + + $this->assertEqualsWithDelta(680.0, $diff, 0.01); + } + + public function test_altitude_difference_returns_null_when_missing(): void + { + $a = Address::create(['latitude' => 48.0, 'longitude' => 16.0]); + $b = Address::create(['latitude' => 47.0, 'longitude' => 15.0, 'altitude' => 850.0]); + + $this->assertNull($this->service()->altitudeDifference($a, $b)); + } + + // ─── proximity ─────────────────────────────────────────────── + + public function test_nearby_finds_addresses_within_radius(): void + { + // Vienna centre + Address::create(['city' => 'Centre', 'latitude' => 48.2082, 'longitude' => 16.3738]); + // ~5 km away + Address::create(['city' => 'Near', 'latitude' => 48.24, 'longitude' => 16.40]); + // ~145 km away (Graz) + Address::create(['city' => 'Far', 'latitude' => 47.0707, 'longitude' => 15.4395]); + + $results = $this->service()->nearby(48.2082, 16.3738, 20); + + $cities = $results->pluck('city')->toArray(); + $this->assertContains('Centre', $cities); + $this->assertContains('Near', $cities); + $this->assertNotContains('Far', $cities); + } + + public function test_nearby_results_are_sorted_by_distance(): void + { + Address::create(['city' => 'Far', 'latitude' => 48.30, 'longitude' => 16.50]); + Address::create(['city' => 'Near', 'latitude' => 48.21, 'longitude' => 16.38]); + Address::create(['city' => 'Mid', 'latitude' => 48.25, 'longitude' => 16.42]); + + $results = $this->service()->nearby(48.2082, 16.3738, 50); + + $cities = $results->pluck('city')->toArray(); + $this->assertEquals('Near', $cities[0]); + } + + public function test_nearby_results_have_distance_attribute(): void + { + Address::create(['city' => 'A', 'latitude' => 48.21, 'longitude' => 16.38]); + + $results = $this->service()->nearby(48.2082, 16.3738, 50); + + $this->assertNotEmpty($results); + $this->assertIsFloat($results->first()->distance); + } + + public function test_nearby_address_excludes_self(): void + { + $ref = Address::create(['city' => 'Ref', 'latitude' => 48.2082, 'longitude' => 16.3738]); + Address::create(['city' => 'Buddy', 'latitude' => 48.21, 'longitude' => 16.38]); + + $results = $this->service()->nearbyAddress($ref, 50); + + $ids = $results->pluck('id')->toArray(); + $this->assertNotContains($ref->id, $ids); + $this->assertCount(1, $results); + } + + public function test_nearby_address_include_self(): void + { + $ref = Address::create(['city' => 'Ref', 'latitude' => 48.2082, 'longitude' => 16.3738]); + Address::create(['city' => 'Buddy', 'latitude' => 48.21, 'longitude' => 16.38]); + + $results = $this->service()->nearbyAddress($ref, 50, 'km', false); + + $this->assertCount(2, $results); + } + + public function test_nearby_address_without_coordinates_returns_empty(): void + { + $ref = Address::create(['city' => 'NoCoords']); + + $results = $this->service()->nearbyAddress($ref, 50); + + $this->assertEmpty($results); + } + + public function test_closest_address(): void + { + Address::create(['city' => 'Far', 'latitude' => 47.0707, 'longitude' => 15.4395]); + Address::create(['city' => 'Close', 'latitude' => 48.21, 'longitude' => 16.38]); + + $closest = $this->service()->closest(48.2082, 16.3738); + + $this->assertNotNull($closest); + $this->assertEquals('Close', $closest->city); + } + + public function test_closest_returns_null_when_no_addresses(): void + { + $this->assertNull($this->service()->closest(48.0, 16.0)); + } + + // ─── bounding box ──────────────────────────────────────────── + + public function test_bounding_box(): void + { + $box = $this->service()->boundingBox(48.2082, 16.3738, 10); + + $this->assertLessThan(48.2082, $box['minLat']); + $this->assertGreaterThan(48.2082, $box['maxLat']); + $this->assertLessThan(16.3738, $box['minLng']); + $this->assertGreaterThan(16.3738, $box['maxLng']); + } + + // ─── duplicates ────────────────────────────────────────────── + + public function test_find_duplicates(): void + { + $addr = Address::create(['street' => 'Hauptplatz 1', 'city' => 'Linz', 'postal_code' => '4020', 'country_code' => 'AT']); + $dup = Address::create(['street' => 'Hauptplatz 1', 'city' => 'Linz', 'postal_code' => '4020', 'country_code' => 'AT']); + Address::create(['street' => 'Nebenstraße 5', 'city' => 'Linz', 'postal_code' => '4020', 'country_code' => 'AT']); + + $duplicates = $this->service()->findDuplicates($addr); + + $this->assertCount(1, $duplicates); + $this->assertEquals($dup->id, $duplicates->first()->id); + } + + public function test_merge_reassigns_links_and_soft_deletes(): void + { + $user = User::factory()->create(); + $target = Address::create(['city' => 'Target']); + $duplicate = Address::create(['city' => 'Duplicate']); + + $user->linkAddress($duplicate, AddressLinkType::Home); + $user->linkAddress($duplicate, AddressLinkType::Office); + + $reassigned = $this->service()->merge($target, $duplicate); + + $this->assertEquals(2, $reassigned); + $this->assertSoftDeleted('addresses', ['id' => $duplicate->id]); + $this->assertEquals(2, $user->addressLinks()->where('address_id', $target->id)->count()); + } + + // ─── query builders ────────────────────────────────────────── + + public function test_in_country(): void + { + Address::create(['city' => 'Vienna', 'country_code' => 'AT']); + Address::create(['city' => 'Berlin', 'country_code' => 'DE']); + + $result = $this->service()->inCountry('AT')->get(); + + $this->assertCount(1, $result); + $this->assertEquals('Vienna', $result->first()->city); + } + + public function test_in_city(): void + { + Address::create(['city' => 'Vienna', 'country_code' => 'AT']); + Address::create(['city' => 'Vienna', 'country_code' => 'US']); // Vienna, Virginia + + $all = $this->service()->inCity('Vienna')->get(); + $atOnly = $this->service()->inCity('Vienna', 'AT')->get(); + + $this->assertCount(2, $all); + $this->assertCount(1, $atOnly); + } + + public function test_in_postal_code(): void + { + Address::create(['postal_code' => '1010', 'country_code' => 'AT']); + Address::create(['postal_code' => '1010', 'country_code' => 'DE']); + + $result = $this->service()->inPostalCode('1010', 'AT')->get(); + + $this->assertCount(1, $result); + } + + public function test_with_coordinates(): void + { + Address::create(['city' => 'HasCoords', 'latitude' => 48.0, 'longitude' => 16.0]); + Address::create(['city' => 'NoCoords']); + + $result = $this->service()->withCoordinates()->get(); + + $this->assertCount(1, $result); + $this->assertEquals('HasCoords', $result->first()->city); + } + + // ─── formatting ────────────────────────────────────────────── + + public function test_format_single_line(): void + { + $addr = Address::create([ + 'street' => 'Rainerstraße 4', + 'postal_code' => '4020', + 'city' => 'Linz', + 'country_code' => 'AT', + ]); + + $formatted = $this->service()->format($addr); + + $this->assertStringContainsString('Rainerstraße 4', $formatted); + $this->assertStringContainsString('4020', $formatted); + $this->assertStringContainsString('Linz', $formatted); + } + + public function test_format_with_custom_separator(): void + { + $addr = Address::create(['street' => 'A', 'city' => 'B']); + + $formatted = $this->service()->format($addr, ' | '); + + $this->assertEquals('A | B', $formatted); + } + + public function test_format_multiline(): void + { + $addr = Address::create([ + 'street' => '350 Fifth Avenue', + 'street_extra' => 'Suite 3200', + 'building' => 'Empire State Building', + 'floor' => '32', + 'room' => '3201', + 'postal_code' => '10118', + 'city' => 'New York', + 'state' => 'NY', + 'country_code' => 'US', + ]); + + $lines = explode("\n", $this->service()->formatMultiline($addr)); + + $this->assertStringContainsString('350 Fifth Avenue', $lines[0]); + $this->assertStringContainsString('Suite 3200', $lines[0]); + $this->assertStringContainsString('Empire State Building', $lines[1]); + $this->assertStringContainsString('Floor 32', $lines[1]); + $this->assertStringContainsString('10118 New York', $lines[2]); + $this->assertEquals('US', $lines[3]); + } + + public function test_format_coordinates(): void + { + $addr = Address::create(['latitude' => 48.2082, 'longitude' => 16.3738, 'altitude' => 171.0]); + + $result = $this->service()->formatCoordinates($addr); + + $this->assertStringContainsString('N', $result); + $this->assertStringContainsString('E', $result); + $this->assertStringContainsString('AMSL', $result); + } + + public function test_format_coordinates_southern_western(): void + { + $addr = Address::create(['latitude' => -33.8688, 'longitude' => -151.2093]); + + $result = $this->service()->formatCoordinates($addr); + + $this->assertStringContainsString('S', $result); + // Note: -151 is actually W + $this->assertStringContainsString('W', $result); + } + + public function test_format_coordinates_returns_null_without_coords(): void + { + $addr = Address::create(['city' => 'NoCoordsCity']); + + $this->assertNull($this->service()->formatCoordinates($addr)); + } + + // ─── coordinate conversion ─────────────────────────────────── + + public function test_dms_to_decimal(): void + { + // 48°12'29.5"N → 48.2082 + $result = $this->service()->dmsToDecimal(48, 12, 29.52, 'N'); + + $this->assertEqualsWithDelta(48.2082, $result, 0.001); + } + + public function test_dms_to_decimal_south(): void + { + $result = $this->service()->dmsToDecimal(33, 52, 7.7, 'S'); + + $this->assertLessThan(0, $result); + $this->assertEqualsWithDelta(-33.8688, $result, 0.01); + } + + public function test_decimal_to_dms_latitude(): void + { + $result = $this->service()->decimalToDms(48.2082, 'lat'); + + $this->assertEquals(48, $result['degrees']); + $this->assertEquals(12, $result['minutes']); + $this->assertEquals('N', $result['direction']); + } + + public function test_decimal_to_dms_longitude_west(): void + { + $result = $this->service()->decimalToDms(-73.9856, 'lng'); + + $this->assertEquals(73, $result['degrees']); + $this->assertEquals('W', $result['direction']); + } + + public function test_dms_roundtrip(): void + { + $original = 48.2082; + $dms = $this->service()->decimalToDms($original, 'lat'); + $back = $this->service()->dmsToDecimal($dms['degrees'], $dms['minutes'], $dms['seconds'], $dms['direction']); + + $this->assertEqualsWithDelta($original, $back, 0.0001); + } + + // ═══════════════════════════════════════════════════════════════ + // AddressAssignment — assign an AddressLink to another model + // ═══════════════════════════════════════════════════════════════ + + private function createJobWithAssignment(string $role = 'pickup'): array + { + $user = User::factory()->create(); + $link = $user->addAddress([ + 'street' => 'Hauptstraße 1', + 'city' => 'Vienna', + 'country_code' => 'AT', + ], AddressLinkType::Office); + + $job = Job::create(['title' => 'Piano Move']); + $assignment = $job->assignAddressLink($link, $role); + + return compact('user', 'link', 'job', 'assignment'); + } + + // ─── assign address link ───────────────────────────────────── + + public function test_assign_address_link_creates_assignment(): void + { + ['job' => $job, 'assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment(); + + $this->assertInstanceOf(AddressAssignment::class, $assignment); + $this->assertEquals($link->id, $assignment->address_link_id); + $this->assertEquals('pickup', $assignment->role); + $this->assertEquals($job->getMorphClass(), $assignment->assignable_type); + $this->assertEquals($job->id, $assignment->assignable_id); + } + + public function test_assign_address_link_by_id(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Berlin'], AddressLinkType::Office); + + $job = Job::create(['title' => 'Delivery']); + $assignment = $job->assignAddressLink($link->id, 'delivery'); + + $this->assertEquals($link->id, $assignment->address_link_id); + $this->assertEquals('delivery', $assignment->role); + } + + public function test_assign_address_link_with_label_and_meta(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Home); + + $job = Job::create(['title' => 'Move']); + $assignment = $job->assignAddressLink($link, 'origin', [ + 'label' => 'Customer Home', + 'meta' => ['floor_access' => 'elevator'], + ]); + + $this->assertEquals('Customer Home', $assignment->label); + $this->assertEquals('elevator', $assignment->meta->floor_access); + } + + public function test_assign_address_link_without_role(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Graz']); + + $job = Job::create(['title' => 'Task']); + $assignment = $job->assignAddressLink($link); + + $this->assertNull($assignment->role); + } + + public function test_assignment_loads_address_link_and_address(): void + { + ['assignment' => $assignment] = $this->createJobWithAssignment(); + + $this->assertTrue($assignment->relationLoaded('addressLink')); + $this->assertTrue($assignment->addressLink->relationLoaded('address')); + $this->assertEquals('Vienna', $assignment->addressLink->address->city); + } + + // ─── relationships ─────────────────────────────────────────── + + public function test_address_assignments_morphmany(): void + { + ['job' => $job] = $this->createJobWithAssignment(); + + $this->assertCount(1, $job->addressAssignments); + } + + public function test_assignment_belongs_to_address_link(): void + { + ['assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment(); + + $freshAssignment = AddressAssignment::find($assignment->id); + $this->assertEquals($link->id, $freshAssignment->addressLink->id); + } + + public function test_assignment_address_shortcut(): void + { + ['assignment' => $assignment] = $this->createJobWithAssignment(); + + $freshAssignment = AddressAssignment::with('address')->find($assignment->id); + $this->assertNotNull($freshAssignment->address); + $this->assertEquals('Vienna', $freshAssignment->address->city); + } + + public function test_assignment_assignable_morphto(): void + { + ['assignment' => $assignment, 'job' => $job] = $this->createJobWithAssignment(); + + $fresh = AddressAssignment::find($assignment->id); + $this->assertInstanceOf(Job::class, $fresh->assignable); + $this->assertEquals($job->id, $fresh->assignable->id); + } + + public function test_address_link_has_many_assignments(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + + $job1 = Job::create(['title' => 'Job 1']); + $job2 = Job::create(['title' => 'Job 2']); + + $job1->assignAddressLink($link, 'pickup'); + $job2->assignAddressLink($link, 'delivery'); + + $this->assertCount(2, $link->fresh()->assignments); + } + + // ─── multiple assignments on one model ─────────────────────── + + public function test_multiple_assignments_on_one_model(): void + { + $user = User::factory()->create(); + $officeLink = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + $homeLink = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home); + + $job = Job::create(['title' => 'Piano Move']); + $job->assignAddressLink($officeLink, 'pickup'); + $job->assignAddressLink($homeLink, 'delivery'); + + $this->assertCount(2, $job->fresh()->addressAssignments); + } + + // ─── removing assignments ──────────────────────────────────── + + public function test_remove_address_assignment(): void + { + ['job' => $job, 'assignment' => $assignment] = $this->createJobWithAssignment(); + + $this->assertTrue($job->removeAddressAssignment($assignment->id)); + $this->assertCount(0, $job->fresh()->addressAssignments); + } + + public function test_remove_assignments_for_role(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + $link2 = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home); + + $job = Job::create(['title' => 'Move']); + $job->assignAddressLink($link, 'pickup'); + $job->assignAddressLink($link2, 'pickup'); + $job->assignAddressLink($link, 'delivery'); + + $removed = $job->removeAssignmentsForRole('pickup'); + + $this->assertEquals(2, $removed); + $this->assertCount(1, $job->fresh()->addressAssignments); + } + + public function test_remove_all_address_assignments(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + + $job = Job::create(['title' => 'Move']); + $job->assignAddressLink($link, 'pickup'); + $job->assignAddressLink($link, 'delivery'); + + $removed = $job->removeAllAddressAssignments(); + + $this->assertEquals(2, $removed); + $this->assertCount(0, $job->fresh()->addressAssignments); + } + + // ─── cascade delete ────────────────────────────────────────── + + public function test_deleting_address_link_cascades_to_assignments(): void + { + ['link' => $link, 'assignment' => $assignment] = $this->createJobWithAssignment(); + + $link->delete(); + + $this->assertNull(AddressAssignment::find($assignment->id)); + } + + public function test_deleting_address_cascades_through_link_to_assignments(): void + { + ['link' => $link, 'assignment' => $assignment] = $this->createJobWithAssignment(); + + $link->address->forceDelete(); + + $this->assertNull(AddressLink::find($link->id)); + $this->assertNull(AddressAssignment::find($assignment->id)); + } + + // ─── querying ──────────────────────────────────────────────── + + public function test_address_assignment_for_role(): void + { + ['job' => $job] = $this->createJobWithAssignment('pickup'); + + $result = $job->addressAssignmentForRole('pickup'); + + $this->assertInstanceOf(AddressAssignment::class, $result); + $this->assertEquals('pickup', $result->role); + $this->assertTrue($result->relationLoaded('addressLink')); + } + + public function test_address_assignment_for_role_returns_null_when_missing(): void + { + ['job' => $job] = $this->createJobWithAssignment('pickup'); + + $this->assertNull($job->addressAssignmentForRole('delivery')); + } + + public function test_address_assignments_for_role(): void + { + $user = User::factory()->create(); + $link1 = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + $link2 = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home); + + $job = Job::create(['title' => 'Move']); + $job->assignAddressLink($link1, 'stop'); + $job->assignAddressLink($link2, 'stop'); + $job->assignAddressLink($link1, 'delivery'); + + $stops = $job->addressAssignmentsForRole('stop'); + + $this->assertCount(2, $stops); + } + + public function test_assigned_address_for_role(): void + { + ['job' => $job] = $this->createJobWithAssignment('pickup'); + + $address = $job->assignedAddressForRole('pickup'); + + $this->assertInstanceOf(Address::class, $address); + $this->assertEquals('Vienna', $address->city); + } + + public function test_assigned_address_for_role_returns_null_when_missing(): void + { + ['job' => $job] = $this->createJobWithAssignment('pickup'); + + $this->assertNull($job->assignedAddressForRole('delivery')); + } + + public function test_assigned_addresses(): void + { + $user = User::factory()->create(); + $link1 = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + $link2 = $user->addAddress(['city' => 'Graz'], AddressLinkType::Home); + + $job = Job::create(['title' => 'Move']); + $job->assignAddressLink($link1, 'pickup'); + $job->assignAddressLink($link2, 'delivery'); + + $addresses = $job->assignedAddresses(); + + $this->assertCount(2, $addresses); + $this->assertContains('Vienna', $addresses->pluck('city')->all()); + $this->assertContains('Graz', $addresses->pluck('city')->all()); + } + + public function test_has_address_assignments(): void + { + $job = Job::create(['title' => 'Empty Job']); + $this->assertFalse($job->hasAddressAssignments()); + + ['job' => $jobWithAssignment] = $this->createJobWithAssignment(); + $this->assertTrue($jobWithAssignment->hasAddressAssignments()); + } + + public function test_has_assignment_for_role(): void + { + ['job' => $job] = $this->createJobWithAssignment('pickup'); + + $this->assertTrue($job->hasAssignmentForRole('pickup')); + $this->assertFalse($job->hasAssignmentForRole('delivery')); + } + + // ─── scope: forRole ────────────────────────────────────────── + + public function test_for_role_scope_on_assignment(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Vienna'], AddressLinkType::Office); + + $job = Job::create(['title' => 'Move']); + $job->assignAddressLink($link, 'pickup'); + $job->assignAddressLink($link, 'delivery'); + + $pickups = AddressAssignment::forRole('pickup')->get(); + $this->assertCount(1, $pickups); + $this->assertEquals('pickup', $pickups->first()->role); + } + + // ─── cross-model assignment ────────────────────────────────── + + public function test_same_address_link_assigned_to_different_models(): void + { + $user = User::factory()->create(); + $link = $user->addAddress([ + 'street' => 'Ringstraße 10', + 'city' => 'Vienna', + 'country_code' => 'AT', + ], AddressLinkType::Office); + + $job1 = Job::create(['title' => 'Job Alpha']); + $job2 = Job::create(['title' => 'Job Beta']); + + $a1 = $job1->assignAddressLink($link, 'pickup'); + $a2 = $job2->assignAddressLink($link, 'delivery'); + + // Both assignments point to the same address link + $this->assertEquals($link->id, $a1->address_link_id); + $this->assertEquals($link->id, $a2->address_link_id); + + // Each job has its own assignment + $this->assertCount(1, $job1->fresh()->addressAssignments); + $this->assertCount(1, $job2->fresh()->addressAssignments); + + // The address link knows about both + $this->assertCount(2, $link->fresh()->assignments); + } + + // ═══════════════════════════════════════════════════════════════ + // EXHAUSTIVE TESTS — every developer interaction surface + // ═══════════════════════════════════════════════════════════════ + + // ─── Address model — edge cases ────────────────────────────── + + public function test_create_completely_empty_address(): void + { + $address = Address::create([]); + + $this->assertNotNull($address->id); + $this->assertNull($address->street); + $this->assertNull($address->city); + $this->assertNull($address->country_code); + $this->assertFalse($address->hasCoordinates()); + $this->assertFalse($address->hasAltitude()); + } + + public function test_update_address_fields(): void + { + $address = Address::create(['city' => 'Vienna', 'country_code' => 'AT']); + + $address->update(['city' => 'Linz', 'street' => 'Hauptplatz 1']); + + $fresh = $address->fresh(); + $this->assertEquals('Linz', $fresh->city); + $this->assertEquals('Hauptplatz 1', $fresh->street); + $this->assertEquals('AT', $fresh->country_code); + } + + public function test_address_formatted_with_building_floor_room(): void + { + $address = Address::create([ + 'building' => 'Tower A', + 'floor' => 'B2', + 'room' => '42', + ]); + + $formatted = $address->formatted; + + $this->assertStringContainsString('(Tower A)', $formatted); + $this->assertStringContainsString('Floor B2', $formatted); + $this->assertStringContainsString('Room 42', $formatted); + } + + public function test_address_formatted_when_all_empty(): void + { + $address = Address::create([]); + + $this->assertEquals('', $address->formatted); + } + + public function test_address_partial_coordinates_lat_only(): void + { + $address = Address::create(['latitude' => 48.2082]); + + $this->assertFalse($address->hasCoordinates()); + } + + public function test_address_partial_coordinates_lng_only(): void + { + $address = Address::create(['longitude' => 16.3738]); + + $this->assertFalse($address->hasCoordinates()); + } + + public function test_address_to_coordinates_without_any(): void + { + $address = Address::create(['city' => 'NoCoords']); + + $coords = $address->toCoordinates(); + + $this->assertNull($coords['latitude']); + $this->assertNull($coords['longitude']); + $this->assertNull($coords['altitude']); + } + + public function test_address_links_relationship(): void + { + $user = User::factory()->create(); + $company = Company::create(['name' => 'Corp']); + + $address = Address::create(['city' => 'Vienna']); + + $user->linkAddress($address, AddressLinkType::Home); + $company->linkAddress($address, AddressLinkType::Headquarters); + + $links = $address->links; + + $this->assertCount(2, $links); + } + + public function test_restore_soft_deleted_address(): void + { + $address = Address::create(['city' => 'Restored']); + $id = $address->id; + + $address->delete(); + $this->assertNull(Address::find($id)); + + Address::withTrashed()->find($id)->restore(); + $this->assertNotNull(Address::find($id)); + $this->assertEquals('Restored', Address::find($id)->city); + } + + public function test_address_fillable_notes(): void + { + $address = Address::create([ + 'notes' => 'Ring doorbell twice. Dog in yard.', + ]); + + $this->assertEquals('Ring doorbell twice. Dog in yard.', $address->notes); + } + + public function test_address_meta_via_has_meta(): void + { + $address = Address::create([ + 'city' => 'MetaTest', + 'meta' => ['what3words' => 'filled.count.soap'], + ]); + + $meta = $address->getMeta(); + $this->assertEquals('filled.count.soap', $meta->what3words); + } + + public function test_address_latitude_longitude_cast_to_float(): void + { + $address = Address::create([ + 'latitude' => '48.2082', + 'longitude' => '16.3738', + 'altitude' => '171.5', + ]); + + $this->assertIsFloat($address->latitude); + $this->assertIsFloat($address->longitude); + $this->assertIsFloat($address->altitude); + } + + // ─── AddressLink — edge cases ──────────────────────────────── + + public function test_update_link_type(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'X'], AddressLinkType::Home); + + $link->update(['type' => AddressLinkType::Office->value]); + + $this->assertEquals(AddressLinkType::Office, $link->fresh()->type); + } + + public function test_update_link_label(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'X'], AddressLinkType::Other, ['label' => 'Old']); + + $link->update(['label' => 'New Label']); + + $this->assertEquals('New Label', $link->fresh()->label); + } + + public function test_is_active_with_future_active_from(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Future'], AddressLinkType::Home, [ + 'active_from' => now()->addMonth(), + ]); + + $this->assertFalse($link->isActive()); + } + + public function test_is_active_with_past_active_from_only(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Past'], AddressLinkType::Home, [ + 'active_from' => now()->subMonth(), + ]); + + $this->assertTrue($link->isActive()); + } + + public function test_is_active_with_future_active_until_only(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'StillGood'], AddressLinkType::Home, [ + 'active_until' => now()->addYear(), + ]); + + $this->assertTrue($link->isActive()); + } + + public function test_is_active_with_neither_temporal_bound(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Forever'], AddressLinkType::Home); + + $this->assertTrue($link->isActive()); + } + + public function test_scope_chaining_active_and_of_type(): void + { + $user = User::factory()->create(); + + // Active home + $user->addAddress(['city' => 'ActiveHome'], AddressLinkType::Home, [ + 'active_from' => now()->subDay(), + 'active_until' => now()->addDay(), + ]); + // Expired home + $user->addAddress(['city' => 'ExpiredHome'], AddressLinkType::Home, [ + 'active_until' => now()->subDay(), + ]); + // Active office + $user->addAddress(['city' => 'ActiveOffice'], AddressLinkType::Office); + + $activeHomes = $user->addressLinks()->active()->ofType(AddressLinkType::Home)->get(); + + $this->assertCount(1, $activeHomes); + $this->assertEquals('ActiveHome', $activeHomes->first()->address->city); + } + + public function test_scope_primary_and_of_type(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'A'], AddressLinkType::Home, ['is_primary' => true]); + $user->addAddress(['city' => 'B'], AddressLinkType::Home); + $user->addAddress(['city' => 'C'], AddressLinkType::Office, ['is_primary' => true]); + + $primaryHomes = $user->addressLinks()->primary()->ofType(AddressLinkType::Home)->get(); + + $this->assertCount(1, $primaryHomes); + } + + public function test_scope_of_type_with_string(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'A'], AddressLinkType::Shipping); + + $result = $user->addressLinks()->ofType('shipping')->get(); + $this->assertCount(1, $result); + } + + public function test_addressable_for_company(): void + { + $company = Company::create(['name' => 'Widget Inc']); + $link = $company->addAddress(['city' => 'Zurich'], AddressLinkType::Headquarters); + + $fresh = AddressLink::find($link->id); + $this->assertInstanceOf(Company::class, $fresh->addressable); + $this->assertEquals('Widget Inc', $fresh->addressable->name); + } + + // ─── HasAddresses trait — more interactions ────────────────── + + public function test_same_address_reused_across_three_users(): void + { + $address = Address::create([ + 'street' => 'Shared Office 1', + 'city' => 'Vienna', + 'country_code' => 'AT', + ]); + + $u1 = User::factory()->create(); + $u2 = User::factory()->create(); + $u3 = User::factory()->create(); + + $u1->linkAddress($address, AddressLinkType::Office); + $u2->linkAddress($address, AddressLinkType::Office); + $u3->linkAddress($address, AddressLinkType::Office); + + $this->assertCount(3, $address->links); + $this->assertTrue($u1->hasAddresses()); + $this->assertTrue($u2->hasAddresses()); + $this->assertTrue($u3->hasAddresses()); + + // All three see the same physical address + $this->assertEquals($address->id, $u1->addresses->first()->id); + $this->assertEquals($address->id, $u2->addresses->first()->id); + $this->assertEquals($address->id, $u3->addresses->first()->id); + } + + public function test_multiple_addresses_of_same_type(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'Office A'], AddressLinkType::Office); + $user->addAddress(['city' => 'Office B'], AddressLinkType::Office); + $user->addAddress(['city' => 'Office C'], AddressLinkType::Office); + + $offices = $user->addressesOfType(AddressLinkType::Office); + $this->assertCount(3, $offices); + } + + public function test_primary_address_without_type_filter(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'NotPrimary'], AddressLinkType::Home); + $user->addAddress(['city' => 'IsPrimary'], AddressLinkType::Office, ['is_primary' => true]); + + $primary = $user->primaryAddress(); + + $this->assertNotNull($primary); + $this->assertEquals('IsPrimary', $primary->city); + } + + public function test_primary_address_null_when_no_primary_at_all(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'A'], AddressLinkType::Home); + $user->addAddress(['city' => 'B'], AddressLinkType::Office); + + $this->assertNull($user->primaryAddress()); + } + + public function test_remove_address_link_returns_false_for_nonexistent(): void + { + $user = User::factory()->create(); + + // removeAddressLink deletes by query; 0 rows affected → bool(false) + $this->assertFalse($user->removeAddressLink(99999)); + } + + public function test_detach_address_by_id(): void + { + $user = User::factory()->create(); + $address = Address::create(['city' => 'ById']); + $user->linkAddress($address, AddressLinkType::Home); + $user->linkAddress($address, AddressLinkType::Billing); + + $removed = $user->detachAddress($address->id); + + $this->assertEquals(2, $removed); + $this->assertFalse($user->hasAddresses()); + } + + public function test_link_address_with_all_pivot_fields(): void + { + $user = User::factory()->create(); + $address = Address::create(['city' => 'Full Pivot']); + + $link = $user->linkAddress($address, AddressLinkType::Temporary, [ + 'label' => 'Summer Rental', + 'is_primary' => true, + 'active_from' => now()->subDay(), + 'active_until' => now()->addMonths(3), + 'meta' => ['lease_id' => 'L-2026-001'], + ]); + + $this->assertEquals('Summer Rental', $link->label); + $this->assertTrue($link->is_primary); + $this->assertNotNull($link->active_from); + $this->assertNotNull($link->active_until); + $this->assertTrue($link->isActive()); + $this->assertEquals('L-2026-001', $link->getMeta()->lease_id); + } + + public function test_addresses_morphtomany_pivot_fields(): void + { + $user = User::factory()->create(); + $user->addAddress( + ['city' => 'PivotTest'], + AddressLinkType::Home, + ['label' => 'My Flat', 'is_primary' => true] + ); + + $address = $user->addresses()->first(); + $pivot = $address->pivot; + + $this->assertEquals('home', $pivot->type); + $this->assertEquals('My Flat', $pivot->label); + $this->assertEquals(1, $pivot->is_primary); + $this->assertNotNull($pivot->id); + $this->assertNotNull($pivot->created_at); + } + + public function test_same_address_billing_and_shipping(): void + { + $user = User::factory()->create(); + $address = Address::create([ + 'street' => 'Main Street 1', + 'city' => 'Graz', + 'country_code' => 'AT', + ]); + + $billingLink = $user->linkAddress($address, AddressLinkType::Billing, ['is_primary' => true]); + $shippingLink = $user->linkAddress($address, AddressLinkType::Shipping, ['is_primary' => true]); + + // Both link types exist + $this->assertTrue($user->hasAddressOfType(AddressLinkType::Billing)); + $this->assertTrue($user->hasAddressOfType(AddressLinkType::Shipping)); + + // Both resolve to same address + $billingAddr = $user->primaryAddress(AddressLinkType::Billing); + $shippingAddr = $user->primaryAddress(AddressLinkType::Shipping); + $this->assertEquals($billingAddr->id, $shippingAddr->id); + } + + public function test_set_primary_clears_only_same_type(): void + { + $user = User::factory()->create(); + + $home1 = $user->addAddress(['city' => 'Home1'], AddressLinkType::Home, ['is_primary' => true]); + $home2 = $user->addAddress(['city' => 'Home2'], AddressLinkType::Home); + $office1 = $user->addAddress(['city' => 'Office1'], AddressLinkType::Office, ['is_primary' => true]); + + $user->setPrimaryAddressLink($home2->id); + + $this->assertFalse($home1->fresh()->is_primary); + $this->assertTrue($home2->fresh()->is_primary); + $this->assertTrue($office1->fresh()->is_primary); // untouched + } + + public function test_multiple_primary_addresses_across_types(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'HomeP'], AddressLinkType::Home, ['is_primary' => true]); + $user->addAddress(['city' => 'OfficeP'], AddressLinkType::Office, ['is_primary' => true]); + $user->addAddress(['city' => 'BillingP'], AddressLinkType::Billing, ['is_primary' => true]); + + $this->assertEquals('HomeP', $user->primaryAddress(AddressLinkType::Home)->city); + $this->assertEquals('OfficeP', $user->primaryAddress(AddressLinkType::Office)->city); + $this->assertEquals('BillingP', $user->primaryAddress(AddressLinkType::Billing)->city); + } + + // ─── AddressLinkType enum — exhaustive ─────────────────────── + + public function test_all_enum_cases_exist(): void + { + $cases = AddressLinkType::cases(); + $this->assertCount(17, $cases); + + $values = array_map(fn($c) => $c->value, $cases); + $this->assertContains('home', $values); + $this->assertContains('secondary_residence', $values); + $this->assertContains('office', $values); + $this->assertContains('headquarters', $values); + $this->assertContains('branch', $values); + $this->assertContains('factory', $values); + $this->assertContains('warehouse', $values); + $this->assertContains('shipping', $values); + $this->assertContains('billing', $values); + $this->assertContains('return', $values); + $this->assertContains('pickup', $values); + $this->assertContains('point_of_interest', $values); + $this->assertContains('site', $values); + $this->assertContains('temporary', $values); + $this->assertContains('contact', $values); + $this->assertContains('legal', $values); + $this->assertContains('other', $values); + } + + public function test_all_enum_values_are_unique(): void + { + $values = array_map(fn($c) => $c->value, AddressLinkType::cases()); + $this->assertEquals(count($values), count(array_unique($values))); + } + + public function test_all_enum_labels_are_nonempty(): void + { + foreach (AddressLinkType::cases() as $case) { + $this->assertNotEmpty($case->label(), "Label for {$case->value} is empty"); + } + } + + public function test_enum_try_from_invalid_returns_null(): void + { + $this->assertNull(AddressLinkType::tryFrom('nonexistent')); + $this->assertNull(AddressLinkType::tryFrom('')); + } + + public function test_each_enum_type_can_be_used_as_link(): void + { + $user = User::factory()->create(); + + foreach (AddressLinkType::cases() as $case) { + $link = $user->addAddress(['city' => "City_{$case->value}"], $case); + $this->assertEquals($case, $link->type, "Failed to link with type {$case->value}"); + } + + $this->assertCount(17, $user->addressLinks); + } + + // ─── Address deletion cascades ─────────────────────────────── + + public function test_force_delete_address_cascades_links_and_assignments(): void + { + $user = User::factory()->create(); + $link = $user->addAddress([ + 'city' => 'CascadeAll', + ], AddressLinkType::Office); + + $job = Job::create(['title' => 'CascadeJob']); + $assignment = $job->assignAddressLink($link, 'pickup'); + + $addressId = $link->address_id; + + // Force-delete the address (not soft-delete) + Address::withTrashed()->find($addressId)->forceDelete(); + + // Link gone + $this->assertDatabaseMissing('address_links', ['id' => $link->id]); + // Assignment gone + $this->assertDatabaseMissing('address_assignments', ['id' => $assignment->id]); + } + + public function test_soft_delete_address_preserves_links(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'SoftDel'], AddressLinkType::Home); + + $link->address->delete(); // soft delete + + // Links remain because we only soft-deleted + $this->assertDatabaseHas('address_links', ['id' => $link->id]); + } + + // ─── AddressAssignment — additional interactions ───────────── + + public function test_update_assignment_role(): void + { + ['assignment' => $assignment] = $this->createJobWithAssignment('pickup'); + + $assignment->update(['role' => 'origin']); + + $this->assertEquals('origin', $assignment->fresh()->role); + } + + public function test_update_assignment_label(): void + { + ['assignment' => $assignment] = $this->createJobWithAssignment('pickup'); + + $assignment->update(['label' => 'Updated Label']); + + $this->assertEquals('Updated Label', $assignment->fresh()->label); + } + + public function test_assignment_meta_get_and_set(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Y']); + + $job = Job::create(['title' => 'Meta Job']); + $assignment = $job->assignAddressLink($link, 'delivery', [ + 'meta' => ['eta' => '14:00', 'requires_signature' => true], + ]); + + $meta = $assignment->getMeta(); + $this->assertEquals('14:00', $meta->eta); + $this->assertTrue($meta->requires_signature); + } + + public function test_remove_address_assignment_returns_false_for_nonexistent(): void + { + $job = Job::create(['title' => 'Empty']); + + $this->assertFalse($job->removeAddressAssignment(99999)); + } + + public function test_duplicate_assignment_same_link_same_role(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Dup']); + + $job = Job::create(['title' => 'Dup Test']); + $a1 = $job->assignAddressLink($link, 'stop'); + $a2 = $job->assignAddressLink($link, 'stop'); + + // Both assignments are created (no unique constraint) + $this->assertNotEquals($a1->id, $a2->id); + $this->assertCount(2, $job->fresh()->addressAssignments); + } + + public function test_replace_assignment_for_role(): void + { + $user = User::factory()->create(); + $link1 = $user->addAddress(['city' => 'Old'], AddressLinkType::Home); + $link2 = $user->addAddress(['city' => 'New'], AddressLinkType::Office); + + $job = Job::create(['title' => 'Replace']); + $job->assignAddressLink($link1, 'pickup'); + + // Replace: remove old, add new + $job->removeAssignmentsForRole('pickup'); + $job->assignAddressLink($link2, 'pickup'); + + $address = $job->assignedAddressForRole('pickup'); + $this->assertEquals('New', $address->city); + $this->assertCount(1, $job->addressAssignmentsForRole('pickup')); + } + + public function test_assignment_address_through_returns_null_for_soft_deleted_address(): void + { + ['assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment(); + + $link->address->delete(); // soft delete + + // HasOneThrough does not traverse soft-deleted by default + $fresh = AddressAssignment::with('address')->find($assignment->id); + $this->assertNull($fresh->address); + } + + public function test_assignment_address_link_has_full_access_to_address(): void + { + $user = User::factory()->create(); + $link = $user->addAddress([ + 'street' => 'Long Road 42', + 'city' => 'Salzburg', + 'postal_code' => '5020', + 'country_code' => 'AT', + 'latitude' => 47.8095, + 'longitude' => 13.0550, + ], AddressLinkType::Home); + + $job = Job::create(['title' => 'Full Access']); + $assignment = $job->assignAddressLink($link, 'delivery'); + + // Access through loaded relations + $address = $assignment->addressLink->address; + $this->assertEquals('Long Road 42', $address->street); + $this->assertEquals('Salzburg', $address->city); + $this->assertEquals('5020', $address->postal_code); + $this->assertEquals('AT', $address->country_code); + $this->assertTrue($address->hasCoordinates()); + $this->assertStringContainsString('Salzburg', $address->formatted); + } + + // ─── AddressService — additional coverage ──────────────────── + + public function test_merge_reassigns_assignments_through_repointed_links(): void + { + $user = User::factory()->create(); + $target = Address::create(['city' => 'Target']); + $duplicate = Address::create(['city' => 'Dup']); + + $link = $user->linkAddress($duplicate, AddressLinkType::Office); + $job = Job::create(['title' => 'MergeJob']); + $assignment = $job->assignAddressLink($link, 'pickup'); + + $this->service()->merge($target, $duplicate); + + // Link now points to target + $freshLink = AddressLink::find($link->id); + $this->assertEquals($target->id, $freshLink->address_id); + + // Assignment still works through the link + $assignedAddr = $job->assignedAddressForRole('pickup'); + $this->assertEquals('Target', $assignedAddr->city); + } + + public function test_nearby_with_miles(): void + { + Address::create(['city' => 'Close', 'latitude' => 48.21, 'longitude' => 16.38]); + Address::create(['city' => 'Far', 'latitude' => 47.0707, 'longitude' => 15.4395]); + + // ~3 mi radius + $results = $this->service()->nearby(48.2082, 16.3738, 3, 'mi'); + + $this->assertCount(1, $results); + $this->assertEquals('Close', $results->first()->city); + } + + public function test_nearby_empty_results(): void + { + Address::create(['city' => 'Far', 'latitude' => -33.8688, 'longitude' => 151.2093]); + + // Search near Vienna, nothing within 1 km + $results = $this->service()->nearby(48.2082, 16.3738, 1); + + $this->assertEmpty($results); + } + + public function test_closest_among_many(): void + { + Address::create(['city' => 'A', 'latitude' => 48.30, 'longitude' => 16.50]); + Address::create(['city' => 'B', 'latitude' => 48.25, 'longitude' => 16.42]); + Address::create(['city' => 'C', 'latitude' => 48.21, 'longitude' => 16.38]); + Address::create(['city' => 'D', 'latitude' => 47.07, 'longitude' => 15.44]); + + $closest = $this->service()->closest(48.2082, 16.3738); + + $this->assertEquals('C', $closest->city); + } + + public function test_in_country_with_lowercase(): void + { + Address::create(['city' => 'Vienna', 'country_code' => 'AT']); + + // Service uppercases the input + $result = $this->service()->inCountry('at')->get(); + + $this->assertCount(1, $result); + } + + public function test_find_duplicates_none(): void + { + $address = Address::create(['street' => 'Unique', 'city' => 'A']); + Address::create(['street' => 'Different', 'city' => 'B']); + + $dups = $this->service()->findDuplicates($address); + + $this->assertCount(0, $dups); + } + + public function test_find_duplicates_ignores_soft_deleted(): void + { + $addr = Address::create(['street' => 'Same', 'city' => 'X', 'country_code' => 'AT']); + $dup = Address::create(['street' => 'Same', 'city' => 'X', 'country_code' => 'AT']); + $dup->delete(); // soft delete + + $dups = $this->service()->findDuplicates($addr); + + $this->assertCount(0, $dups); + } + + public function test_format_minimal(): void + { + $addr = Address::create(['city' => 'Lonely']); + + $this->assertEquals('Lonely', $this->service()->format($addr)); + } + + public function test_format_multiline_minimal(): void + { + $addr = Address::create(['city' => 'Solo']); + + $this->assertEquals('Solo', $this->service()->formatMultiline($addr)); + } + + public function test_format_multiline_with_county(): void + { + $addr = Address::create([ + 'street' => 'Main St', + 'city' => 'Springfield', + 'state' => 'IL', + 'county' => 'Sangamon', + 'country_code' => 'US', + ]); + + $multi = $this->service()->formatMultiline($addr); + + $this->assertStringContainsString('Sangamon', $multi); + $this->assertStringContainsString('Springfield', $multi); + $this->assertStringContainsString('US', $multi); + } + + public function test_format_coordinates_with_altitude_negative(): void + { + $addr = Address::create([ + 'latitude' => 31.5, + 'longitude' => 35.5, + 'altitude' => -430.5, + ]); + + $result = $this->service()->formatCoordinates($addr); + + $this->assertStringContainsString('N', $result); + $this->assertStringContainsString('E', $result); + $this->assertStringContainsString('-430.50', $result); + $this->assertStringContainsString('AMSL', $result); + } + + public function test_dms_to_decimal_east(): void + { + // 16°22'25.68"E → 16.3738 + $result = $this->service()->dmsToDecimal(16, 22, 25.68, 'E'); + $this->assertEqualsWithDelta(16.3738, $result, 0.001); + } + + public function test_dms_to_decimal_west(): void + { + $result = $this->service()->dmsToDecimal(73, 59, 8.4, 'W'); + $this->assertLessThan(0, $result); + } + + public function test_decimal_to_dms_south(): void + { + $result = $this->service()->decimalToDms(-33.8688, 'lat'); + $this->assertEquals('S', $result['direction']); + $this->assertEquals(33, $result['degrees']); + } + + public function test_decimal_to_dms_east(): void + { + $result = $this->service()->decimalToDms(16.3738, 'lng'); + $this->assertEquals('E', $result['direction']); + $this->assertEquals(16, $result['degrees']); + } + + public function test_dms_roundtrip_longitude(): void + { + $original = -73.9856; + $dms = $this->service()->decimalToDms($original, 'lng'); + $back = $this->service()->dmsToDecimal($dms['degrees'], $dms['minutes'], $dms['seconds'], $dms['direction']); + + $this->assertEqualsWithDelta($original, $back, 0.0001); + } + + // ═══════════════════════════════════════════════════════════════ + // INTEGRATION — complex multi-model scenarios + // ═══════════════════════════════════════════════════════════════ + + public function test_full_lifecycle_create_link_assign_reassign_remove(): void + { + // 1. Create user + address + $user = User::factory()->create(); + $link = $user->addAddress([ + 'street' => 'Lifecycle Str. 1', + 'city' => 'Vienna', + 'country_code' => 'AT', + ], AddressLinkType::Office, ['is_primary' => true]); + + $this->assertTrue($user->hasAddresses()); + + // 2. Create job, assign the link + $job = Job::create(['title' => 'Lifecycle Job']); + $assignment = $job->assignAddressLink($link, 'pickup'); + + $this->assertTrue($job->hasAddressAssignments()); + $this->assertEquals('Vienna', $job->assignedAddressForRole('pickup')->city); + + // 3. User moves: create new address, reassign job + $newLink = $user->addAddress([ + 'street' => 'New Place 5', + 'city' => 'Graz', + 'country_code' => 'AT', + ], AddressLinkType::Office); + + // Promote new link as primary (unsets old primary for same type) + $user->setPrimaryAddressLink($newLink->id); + + $job->removeAssignmentsForRole('pickup'); + $job->assignAddressLink($newLink, 'pickup'); + + $this->assertEquals('Graz', $job->assignedAddressForRole('pickup')->city); + + // 4. Old link still exists but no longer primary + $this->assertFalse($link->fresh()->is_primary); + $this->assertTrue($newLink->fresh()->is_primary); + + // 5. Remove job assignments completely + $job->removeAllAddressAssignments(); + $this->assertFalse($job->hasAddressAssignments()); + + // 6. User still has 2 addresses + $this->assertCount(2, $user->fresh()->addressLinks); + } + + public function test_one_address_shared_user_company_job(): void + { + // One physical address used by 3 different models at different layers + $address = Address::create([ + 'street' => 'Shared Tower', + 'city' => 'Vienna', + 'country_code' => 'AT', + ]); + + $user = User::factory()->create(); + $company = Company::create(['name' => 'SharedCorp']); + + // User & Company own links to the same address + $userLink = $user->linkAddress($address, AddressLinkType::Office, ['label' => 'My Office']); + $companyLink = $company->linkAddress($address, AddressLinkType::Headquarters); + + // Job is assigned both links for different roles + $job = Job::create(['title' => 'Shared Job']); + $job->assignAddressLink($userLink, 'pickup'); + $job->assignAddressLink($companyLink, 'billing'); + + // Verify all three models reference the same address + $this->assertEquals($address->id, $user->addresses->first()->id); + $this->assertEquals($address->id, $company->addresses->first()->id); + $this->assertEquals($address->id, $job->assignedAddressForRole('pickup')->id); + $this->assertEquals($address->id, $job->assignedAddressForRole('billing')->id); + + // Address has 2 links + $this->assertCount(2, $address->links); + + // But job has 2 assignments + $this->assertCount(2, $job->addressAssignments); + } + + public function test_labels_same_address_same_model_different_labels(): void + { + $user = User::factory()->create(); + $address = Address::create([ + 'street' => 'Multi-Label Avenue', + 'city' => 'Munich', + ]); + + $user->linkAddress($address, AddressLinkType::Other, ['label' => 'Start of project']); + $user->linkAddress($address, AddressLinkType::Other, ['label' => 'End of project']); + $user->linkAddress($address, AddressLinkType::Other, ['label' => 'Meeting point']); + + $links = $user->addressLinks()->get(); + $labels = $links->pluck('label')->sort()->values()->toArray(); + + $this->assertCount(3, $links); + $this->assertEquals(['End of project', 'Meeting point', 'Start of project'], $labels); + } + + public function test_relabel_a_link(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'X'], AddressLinkType::Other, ['label' => 'Temp Name']); + + $link->update(['label' => 'Permanent Name']); + + $this->assertEquals('Permanent Name', $link->fresh()->label); + } + + public function test_counting_addresses_links_assignments(): void + { + $user = User::factory()->create(); + $link1 = $user->addAddress(['city' => 'A'], AddressLinkType::Home); + $link2 = $user->addAddress(['city' => 'B'], AddressLinkType::Office); + $link3 = $user->addAddress(['city' => 'C'], AddressLinkType::Billing); + + $job1 = Job::create(['title' => 'J1']); + $job2 = Job::create(['title' => 'J2']); + + $job1->assignAddressLink($link1, 'pickup'); + $job1->assignAddressLink($link2, 'delivery'); + $job2->assignAddressLink($link1, 'pickup'); + + $this->assertEquals(3, Address::count()); + $this->assertEquals(3, AddressLink::count()); + $this->assertEquals(3, AddressAssignment::count()); + $this->assertCount(3, $user->addressLinks); + $this->assertCount(2, $job1->addressAssignments); + $this->assertCount(1, $job2->addressAssignments); + } + + public function test_temporal_link_not_started_yet(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Future'], AddressLinkType::Office, [ + 'active_from' => now()->addWeek(), + ]); + + $this->assertFalse($link->isActive()); + + $activeLinks = $user->activeAddressLinks(); + $this->assertCount(0, $activeLinks); + } + + public function test_temporal_link_started_today(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Today'], AddressLinkType::Office, [ + 'active_from' => now(), + ]); + + $this->assertTrue($link->isActive()); + } + + public function test_address_used_for_distance_after_linking(): void + { + $user = User::factory()->create(); + $link1 = $user->addAddress([ + 'city' => 'Vienna', + 'latitude' => 48.2082, + 'longitude' => 16.3738, + ], AddressLinkType::Home); + + $link2 = $user->addAddress([ + 'city' => 'Graz', + 'latitude' => 47.0707, + 'longitude' => 15.4395, + ], AddressLinkType::Office); + + $dist = $this->service()->distanceBetween($link1->address, $link2->address); + + $this->assertNotNull($dist); + $this->assertEqualsWithDelta(145.0, $dist, 5.0); + } + + public function test_nearby_through_assigned_address(): void + { + $user = User::factory()->create(); + Address::create(['city' => 'Near', 'latitude' => 48.21, 'longitude' => 16.38]); + $link = $user->addAddress([ + 'city' => 'Vienna', + 'latitude' => 48.2082, + 'longitude' => 16.3738, + ], AddressLinkType::Office); + + $job = Job::create(['title' => 'Nearby Job']); + $job->assignAddressLink($link, 'origin'); + + // Get the address through the assignment, then find nearby + $origin = $job->assignedAddressForRole('origin'); + $nearby = $this->service()->nearbyAddress($origin, 10); + + $this->assertNotEmpty($nearby); + $this->assertNotContains($origin->id, $nearby->pluck('id')->all()); + } + + public function test_merge_with_assignments_full_scenario(): void + { + $user = User::factory()->create(); + + // Two duplicate addresses + $addr1 = Address::create(['street' => 'Same St', 'city' => 'Linz', 'country_code' => 'AT']); + $addr2 = Address::create(['street' => 'Same St', 'city' => 'Linz', 'country_code' => 'AT']); + + $link1 = $user->linkAddress($addr1, AddressLinkType::Home); + $link2 = $user->linkAddress($addr2, AddressLinkType::Office); + + $job = Job::create(['title' => 'Merge Job']); + $job->assignAddressLink($link2, 'pickup'); + + // Merge addr2 into addr1 + $reassigned = $this->service()->merge($addr1, $addr2); + + // link2 now points to addr1 + $this->assertEquals($addr1->id, AddressLink::find($link2->id)->address_id); + + // addr2 is soft-deleted + $this->assertSoftDeleted('addresses', ['id' => $addr2->id]); + + // Assignment still works, now resolving to addr1 + $address = $job->assignedAddressForRole('pickup'); + $this->assertEquals($addr1->id, $address->id); + } + + public function test_company_with_multiple_branch_addresses(): void + { + $company = Company::create(['name' => 'Multi-Branch Corp']); + + $company->addAddress(['city' => 'Vienna'], AddressLinkType::Headquarters, [ + 'label' => 'Main HQ', + 'is_primary' => true, + ]); + $company->addAddress(['city' => 'Graz'], AddressLinkType::Branch, ['label' => 'South Branch']); + $company->addAddress(['city' => 'Linz'], AddressLinkType::Branch, ['label' => 'North Branch']); + $company->addAddress(['city' => 'Salzburg'], AddressLinkType::Warehouse); + + $this->assertCount(4, $company->addresses); + $this->assertCount(2, $company->addressesOfType(AddressLinkType::Branch)); + $this->assertEquals('Vienna', $company->primaryAddress(AddressLinkType::Headquarters)->city); + $this->assertNull($company->primaryAddress(AddressLinkType::Branch)); + } + + public function test_user_and_company_share_address_with_different_types_and_labels(): void + { + $address = Address::create([ + 'street' => 'Business Park 5', + 'city' => 'Vienna', + 'country_code' => 'AT', + ]); + + $user = User::factory()->create(); + $company = Company::create(['name' => 'Co-Located Inc']); + + $userLink = $user->linkAddress($address, AddressLinkType::Office, ['label' => 'My desk at CO']); + $companyLink = $company->linkAddress($address, AddressLinkType::Headquarters, ['label' => 'Official HQ']); + + // Same address, different contexts + $this->assertEquals($address->id, $userLink->address->id); + $this->assertEquals($address->id, $companyLink->address->id); + $this->assertEquals('My desk at CO', $userLink->label); + $this->assertEquals('Official HQ', $companyLink->label); + $this->assertEquals(AddressLinkType::Office, $userLink->type); + $this->assertEquals(AddressLinkType::Headquarters, $companyLink->type); + } + + public function test_job_with_pickup_delivery_waypoints(): void + { + $user = User::factory()->create(); + + $home = $user->addAddress([ + 'street' => 'Home St 1', + 'city' => 'Vienna', + 'latitude' => 48.2082, + 'longitude' => 16.3738, + ], AddressLinkType::Home); + + $office = $user->addAddress([ + 'street' => 'Office Blvd 42', + 'city' => 'Graz', + 'latitude' => 47.0707, + 'longitude' => 15.4395, + ], AddressLinkType::Office); + + $company = Company::create(['name' => 'Warehouse Co']); + $warehouse = $company->addAddress([ + 'street' => 'Storage Lane 7', + 'city' => 'Linz', + 'latitude' => 48.3069, + 'longitude' => 14.2858, + ], AddressLinkType::Warehouse); + + $job = Job::create(['title' => 'Piano Transport']); + $job->assignAddressLink($home, 'pickup', ['label' => 'Customer home']); + $job->assignAddressLink($warehouse, 'waypoint', ['label' => 'Temporary storage']); + $job->assignAddressLink($office, 'delivery', ['label' => 'Customer office']); + + // All 3 assignments exist + $this->assertCount(3, $job->addressAssignments); + + // Verify each role resolves to correct city + $this->assertEquals('Vienna', $job->assignedAddressForRole('pickup')->city); + $this->assertEquals('Linz', $job->assignedAddressForRole('waypoint')->city); + $this->assertEquals('Graz', $job->assignedAddressForRole('delivery')->city); + + // Calculate distances along the route + $pickupAddr = $job->assignedAddressForRole('pickup'); + $waypointAddr = $job->assignedAddressForRole('waypoint'); + $deliveryAddr = $job->assignedAddressForRole('delivery'); + + $leg1 = $this->service()->distanceBetween($pickupAddr, $waypointAddr); + $leg2 = $this->service()->distanceBetween($waypointAddr, $deliveryAddr); + $total = $leg1 + $leg2; + + $this->assertGreaterThan(0, $leg1); + $this->assertGreaterThan(0, $leg2); + $this->assertGreaterThan($leg1, $total); + } + + public function test_addresses_survive_link_removal(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Survive'], AddressLinkType::Home); + $addressId = $link->address_id; + + $user->removeAddressLink($link->id); + + // Address record still exists + $this->assertNotNull(Address::find($addressId)); + $this->assertEquals('Survive', Address::find($addressId)->city); + } + + public function test_assignment_survives_when_job_is_deleted(): void + { + ['job' => $job, 'assignment' => $assignment, 'link' => $link] = $this->createJobWithAssignment(); + $assignmentId = $assignment->id; + $linkId = $link->id; + + $job->delete(); + + // Assignment row still exists in DB (no cascade from assignable) + $this->assertNotNull(AddressAssignment::find($assignmentId)); + // Link still exists + $this->assertNotNull(AddressLink::find($linkId)); + } + + public function test_bulk_detach_then_reattach(): void + { + $user = User::factory()->create(); + $user->addAddress(['city' => 'A'], AddressLinkType::Home); + $user->addAddress(['city' => 'B'], AddressLinkType::Office); + $user->addAddress(['city' => 'C'], AddressLinkType::Billing); + + $this->assertCount(3, $user->fresh()->addresses); + + $user->detachAllAddresses(); + $this->assertCount(0, $user->fresh()->addresses); + + // Addresses still exist, can re-link + $addresses = Address::where('city', 'A')->orWhere('city', 'B')->get(); + foreach ($addresses as $address) { + $user->linkAddress($address, AddressLinkType::Office); + } + + $this->assertCount(2, $user->fresh()->addresses); + } + + public function test_address_with_all_worldwide_formats(): void + { + // Japanese address + $jp = Address::create([ + 'street' => '丸の内1-9-2', + 'building' => 'グラントウキョウサウスタワー', + 'floor' => '20', + 'room' => '2001', + 'postal_code' => '100-6920', + 'city' => '東京都千代田区', + 'country_code' => 'JP', + ]); + $this->assertStringContainsString('丸の内', $jp->formatted); + $this->assertStringContainsString('JP', $jp->formatted); + + // German format + $de = Address::create([ + 'street' => 'Friedrichstraße 43-45', + 'postal_code' => '10117', + 'city' => 'Berlin', + 'state' => 'Berlin', + 'country_code' => 'DE', + ]); + $this->assertStringContainsString('Friedrichstraße', $de->formatted); + + // US format + $us = Address::create([ + 'street' => '1600 Pennsylvania Avenue NW', + 'city' => 'Washington', + 'state' => 'DC', + 'postal_code' => '20500', + 'country_code' => 'US', + ]); + $this->assertStringContainsString('1600 Pennsylvania', $us->formatted); + + // Rural GPS-only "address" + $rural = Address::create([ + 'latitude' => -23.5505, + 'longitude' => -46.6333, + 'notes' => 'Third rock on the left past the baobab tree', + ]); + $this->assertTrue($rural->hasCoordinates()); + $this->assertEquals('', $rural->formatted); // no postal fields + + // Coordinates-only verification + $coords = $this->service()->formatCoordinates($rural); + $this->assertStringContainsString('S', $coords); + $this->assertStringContainsString('W', $coords); + } + + public function test_address_floor_with_non_numeric_values(): void + { + $tests = ['GF', 'B2', 'Mezzanine', 'P3', 'Rooftop', '½']; + + foreach ($tests as $floor) { + $addr = Address::create(['floor' => $floor]); + $this->assertEquals($floor, $addr->floor); + $this->assertStringContainsString("Floor {$floor}", $addr->formatted); + } + } + + public function test_extreme_coordinates(): void + { + // North pole + $np = Address::create(['latitude' => 90.0, 'longitude' => 0.0]); + $this->assertTrue($np->hasCoordinates()); + + // South pole + $sp = Address::create(['latitude' => -90.0, 'longitude' => 0.0]); + $this->assertTrue($sp->hasCoordinates()); + + // Antimeridian + $am = Address::create(['latitude' => 0.0, 'longitude' => 180.0]); + $this->assertTrue($am->hasCoordinates()); + $this->assertStringContainsString('E', $this->service()->formatCoordinates($am)); + + // Negative antimeridian + $amn = Address::create(['latitude' => 0.0, 'longitude' => -180.0]); + $this->assertStringContainsString('W', $this->service()->formatCoordinates($amn)); + } + + public function test_distance_between_poles(): void + { + $north = Address::create(['latitude' => 90.0, 'longitude' => 0.0]); + $south = Address::create(['latitude' => -90.0, 'longitude' => 0.0]); + + $dist = $this->service()->distanceBetween($north, $south); + + // Half circumference ≈ 20,015 km + $this->assertEqualsWithDelta(20015.0, $dist, 100.0); + } + + public function test_haversine_zero_distance(): void + { + $dist = $this->service()->haversine(0.0, 0.0, 0.0, 0.0); + $this->assertEquals(0.0, $dist); + } + + public function test_address_service_is_singleton(): void + { + $a = app(AddressService::class); + $b = app(AddressService::class); + $c = address(); + + $this->assertSame($a, $b); + $this->assertSame($a, $c); + } + + public function test_bounding_box_miles(): void + { + $boxKm = $this->service()->boundingBox(48.2082, 16.3738, 10, 'km'); + $boxMi = $this->service()->boundingBox(48.2082, 16.3738, 10, 'mi'); + + // 10 mi > 10 km, so mile box should be larger + $this->assertGreaterThan( + $boxKm['maxLat'] - $boxKm['minLat'], + $boxMi['maxLat'] - $boxMi['minLat'] + ); + } + + public function test_in_city_without_country(): void + { + Address::create(['city' => 'Springfield', 'country_code' => 'US', 'state' => 'IL']); + Address::create(['city' => 'Springfield', 'country_code' => 'US', 'state' => 'MO']); + + $result = $this->service()->inCity('Springfield')->get(); + + $this->assertCount(2, $result); + } + + public function test_find_duplicates_multiple(): void + { + $origial = Address::create(['street' => 'A', 'city' => 'B', 'postal_code' => '1', 'country_code' => 'AT']); + Address::create(['street' => 'A', 'city' => 'B', 'postal_code' => '1', 'country_code' => 'AT']); + Address::create(['street' => 'A', 'city' => 'B', 'postal_code' => '1', 'country_code' => 'AT']); + + $dups = $this->service()->findDuplicates($origial); + + $this->assertCount(2, $dups); + } + + public function test_format_multiline_street_only(): void + { + $addr = Address::create(['street' => 'Just a street']); + + $this->assertEquals('Just a street', $this->service()->formatMultiline($addr)); + } + + public function test_format_multiline_postal_and_city(): void + { + $addr = Address::create(['postal_code' => '1010', 'city' => 'Vienna']); + + $this->assertEquals('1010 Vienna', $this->service()->formatMultiline($addr)); + } + + // ─── trait coexistence ──────────────────────────────────────── + + public function test_model_can_be_link_owner_and_assignment_consumer(): void + { + // This tests the scenario where a model uses BOTH traits — + // we'll approximate by manually creating cross-references + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + // User1 owns an address + $link = $user1->addAddress([ + 'city' => 'Vienna', + ], AddressLinkType::Home); + + // User2 also owns an address + $link2 = $user2->addAddress([ + 'city' => 'Graz', + ], AddressLinkType::Home); + + // A Job references both + $job = Job::create(['title' => 'Cross']); + $a1 = $job->assignAddressLink($link, 'pickup'); + $a2 = $job->assignAddressLink($link2, 'delivery'); + + // Through the assignment we can traverse back to the owner + $pickupOwner = AddressAssignment::find($a1->id)->addressLink->addressable; + $deliveryOwner = AddressAssignment::find($a2->id)->addressLink->addressable; + + $this->assertInstanceOf(User::class, $pickupOwner); + $this->assertInstanceOf(User::class, $deliveryOwner); + $this->assertEquals($user1->id, $pickupOwner->id); + $this->assertEquals($user2->id, $deliveryOwner->id); + } + + public function test_traverse_full_chain_assignment_to_owner(): void + { + $user = User::factory()->create(); + $link = $user->addAddress([ + 'street' => 'Full Chain 1', + 'city' => 'Salzburg', + 'country_code' => 'AT', + ], AddressLinkType::Office, ['label' => 'Salzburg Office']); + + $job = Job::create(['title' => 'Chain Job']); + $assignment = $job->assignAddressLink($link, 'pickup', ['label' => 'Pickup Point']); + + // Start from assignment, traverse the full chain + $fresh = AddressAssignment::with(['addressLink.address', 'addressLink.addressable'])->find($assignment->id); + + // Assignment → AddressLink + $this->assertEquals('Salzburg Office', $fresh->addressLink->label); + $this->assertEquals(AddressLinkType::Office, $fresh->addressLink->type); + + // AddressLink → Address + $this->assertEquals('Full Chain 1', $fresh->addressLink->address->street); + $this->assertEquals('Salzburg', $fresh->addressLink->address->city); + + // AddressLink → Owner + $this->assertInstanceOf(User::class, $fresh->addressLink->addressable); + $this->assertEquals($user->id, $fresh->addressLink->addressable->id); + + // Assignment → Assignable (Job) + $this->assertEquals($job->id, $fresh->assignable->id); + } + + public function test_addresses_with_all_enum_types_queryable(): void + { + $user = User::factory()->create(); + + foreach (AddressLinkType::cases() as $type) { + $user->addAddress(['city' => "City_{$type->value}"], $type); + } + + // Query each type individually + foreach (AddressLinkType::cases() as $type) { + $result = $user->addressesOfType($type); + $this->assertCount(1, $result, "Expected 1 address for type {$type->value}"); + } + } + + public function test_detach_specific_address_keeps_other_links(): void + { + $user = User::factory()->create(); + $addr1 = Address::create(['city' => 'Keep']); + $addr2 = Address::create(['city' => 'Remove']); + + $user->linkAddress($addr1, AddressLinkType::Home); + $user->linkAddress($addr2, AddressLinkType::Office); + $user->linkAddress($addr2, AddressLinkType::Billing); + + $user->detachAddress($addr2); + + $remaining = $user->fresh()->addressLinks; + $this->assertCount(1, $remaining); + $this->assertEquals($addr1->id, $remaining->first()->address_id); + } + + public function test_active_links_with_mixed_temporal_data(): void + { + $user = User::factory()->create(); + + // Always active (no bounds) + $user->addAddress(['city' => 'Always'], AddressLinkType::Home); + + // Currently active (started yesterday, ends tomorrow) + $user->addAddress(['city' => 'Current'], AddressLinkType::Office, [ + 'active_from' => now()->subDay(), + 'active_until' => now()->addDay(), + ]); + + // Expired (ended yesterday) + $user->addAddress(['city' => 'Expired'], AddressLinkType::Billing, [ + 'active_until' => now()->subDay(), + ]); + + // Not yet started (starts tomorrow) + $user->addAddress(['city' => 'Future'], AddressLinkType::Temporary, [ + 'active_from' => now()->addDay(), + ]); + + $activeLinks = $user->activeAddressLinks(); + $cities = $activeLinks->pluck('address.city')->sort()->values()->toArray(); + + $this->assertCount(2, $activeLinks); + $this->assertEquals(['Always', 'Current'], $cities); + } + + public function test_expired_scope_excludes_active_and_future(): void + { + $user = User::factory()->create(); + + $user->addAddress(['city' => 'Active'], AddressLinkType::Home); + $user->addAddress(['city' => 'Expired1'], AddressLinkType::Office, [ + 'active_until' => now()->subDay(), + ]); + $user->addAddress(['city' => 'Expired2'], AddressLinkType::Billing, [ + 'active_until' => now()->subHour(), + ]); + $user->addAddress(['city' => 'Future'], AddressLinkType::Temporary, [ + 'active_from' => now()->addDay(), + 'active_until' => now()->addMonth(), + ]); + + $expired = $user->addressLinks()->expired()->get(); + $this->assertCount(2, $expired); + + $cities = $expired->pluck('address.city')->sort()->values()->toArray(); + $this->assertContains('Expired1', $cities); + $this->assertContains('Expired2', $cities); + } + + public function test_format_multiline_full_address(): void + { + $addr = Address::create([ + 'street' => '350 Fifth Avenue', + 'street_extra' => 'Suite 3200', + 'building' => 'Empire State Building', + 'floor' => '32', + 'room' => '3201', + 'postal_code' => '10118', + 'city' => 'New York', + 'state' => 'NY', + 'county' => 'New York County', + 'country_code' => 'US', + ]); + + $lines = explode("\n", $this->service()->formatMultiline($addr)); + + $this->assertCount(5, $lines); + $this->assertEquals('350 Fifth Avenue, Suite 3200', $lines[0]); + $this->assertEquals('Empire State Building, Floor 32, Room 3201', $lines[1]); + $this->assertEquals('10118 New York, NY', $lines[2]); + $this->assertEquals('New York County', $lines[3]); + $this->assertEquals('US', $lines[4]); + } + + public function test_with_coordinates_excludes_partial(): void + { + Address::create(['city' => 'Full', 'latitude' => 48.0, 'longitude' => 16.0]); + Address::create(['city' => 'LatOnly', 'latitude' => 48.0]); + Address::create(['city' => 'LngOnly', 'longitude' => 16.0]); + Address::create(['city' => 'None']); + + $result = $this->service()->withCoordinates()->get(); + + $this->assertCount(1, $result); + $this->assertEquals('Full', $result->first()->city); + } + + public function test_nearby_does_not_include_no_coords_addresses(): void + { + Address::create(['city' => 'NoCoords']); + Address::create(['city' => 'HasCoords', 'latitude' => 48.21, 'longitude' => 16.38]); + + $results = $this->service()->nearby(48.2082, 16.3738, 50); + + $cities = $results->pluck('city')->toArray(); + $this->assertContains('HasCoords', $cities); + $this->assertNotContains('NoCoords', $cities); + } + + public function test_address_assignment_meta_empty_object(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Z']); + $job = Job::create(['title' => 'NoMeta']); + + $assignment = $job->assignAddressLink($link, 'pickup'); + + $this->assertNull($assignment->meta); + } + + public function test_link_meta_complex_nested(): void + { + $user = User::factory()->create(); + $link = $user->addAddress(['city' => 'Complex'], AddressLinkType::Office, [ + 'meta' => [ + 'access' => [ + 'code' => '4567', + 'hours' => ['from' => '08:00', 'to' => '18:00'], + ], + 'contacts' => ['reception', 'security'], + ], + ]); + + $meta = $link->getMeta(); + $this->assertEquals('4567', $meta->access->code); + $this->assertEquals('08:00', $meta->access->hours->from); + $this->assertCount(2, (array) $meta->contacts); + } +}