feat(backend): complete Module 6 (dumpsites + geofence)
dumpsites table with both coordinates POINT and boundary_polygon
POLYGON (SRID 4326). Admin CRUD accepts boundary as a list of {lat,lng}
points; ring is auto-closed. Dumpsite::containsPoint() runs ST_Contains
for geofence checks (Module 10 will fire arrived_at_dumpsite from this).
dumpsite_releases schema in place — trip_id stays nullable bigint until
Module 10 adds the FK.
109 feature tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -160,8 +160,21 @@ bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in
|
||||
`assigned_drop_off_point_id` to nearest active DOP within 25km)
|
||||
- `POST /households/{uuid}/reassign-drop-off` re-runs the lookup
|
||||
- SampleDropOffPointsSeeder creates 5 DOPs around the sample barangays
|
||||
- All 97 feature tests passing
|
||||
- [ ] Module 6+: see `../docs/development-roadmap.md`
|
||||
- [x] Module 6: Dumpsites — complete
|
||||
- `dumpsites` (uuid, name, code, city_municipality_id, coordinates POINT
|
||||
4326 with `SPATIAL INDEX`, boundary_polygon POLYGON 4326, capacity_tons,
|
||||
operating_hours JSON, accepted_waste_types JSON, permit_number, contacts)
|
||||
- `dumpsite_releases` schema in place (trip_id is nullable bigint without
|
||||
FK; Module 10 will add the constraint when `trips` exists)
|
||||
- Admin CRUD: `GET/POST/PATCH/DELETE /admin/dumpsites`
|
||||
- Boundary input is `[{lat,lng}, ...]` (≥3 points); auto-closes the ring
|
||||
if the client doesn't repeat the first point
|
||||
- `Dumpsite::containsPoint(lat, lng)` for geofence checks via
|
||||
`ST_Contains` — Module 10 fires `arrived_at_dumpsite` based on this
|
||||
- SampleDumpsitesSeeder creates a sample Payatas-area dumpsite with a
|
||||
rectangular boundary so geofence tests + dev work
|
||||
- All 109 feature tests passing
|
||||
- [ ] Module 7+: see `../docs/development-roadmap.md`
|
||||
|
||||
### Geo notes
|
||||
- Boundary polygons + centroids stored nullable for now. Once a full PSGC
|
||||
|
||||
156
app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php
Normal file
156
app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Http\Requests\Dumpsite\StoreDumpsiteRequest;
|
||||
use App\Http\Requests\Dumpsite\UpdateDumpsiteRequest;
|
||||
use App\Http\Resources\DumpsiteResource;
|
||||
use App\Models\Dumpsite;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use MatanYadaev\EloquentSpatial\Objects\LineString;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
|
||||
class AdminDumpsiteController extends ApiController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'status' => ['nullable', 'in:active,maintenance,closed'],
|
||||
'city_municipality_id' => ['nullable', 'integer'],
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$perPage = (int) $request->input('per_page', 25);
|
||||
|
||||
$dumpsites = Dumpsite::query()
|
||||
->with('cityMunicipality')
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->when(
|
||||
$request->filled('city_municipality_id'),
|
||||
fn ($q) => $q->where('city_municipality_id', $request->integer('city_municipality_id')),
|
||||
)
|
||||
->when($request->filled('q'), function ($q) use ($request) {
|
||||
$term = '%'.$request->string('q').'%';
|
||||
$q->where(fn ($qq) => $qq->where('name', 'like', $term)
|
||||
->orWhere('code', 'like', $term)
|
||||
->orWhere('address_line', 'like', $term));
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
return $this->ok(
|
||||
DumpsiteResource::collection($dumpsites),
|
||||
null,
|
||||
[
|
||||
'page' => $dumpsites->currentPage(),
|
||||
'per_page' => $dumpsites->perPage(),
|
||||
'total' => $dumpsites->total(),
|
||||
'last_page' => $dumpsites->lastPage(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreDumpsiteRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$payload = $this->mapPayload($data);
|
||||
$payload['name'] = $data['name'];
|
||||
$payload['code'] = $data['code'];
|
||||
$payload['address_line'] = $data['address_line'];
|
||||
$payload['city_municipality_id'] = $data['city_municipality_id'] ?? null;
|
||||
$payload['accepted_waste_types'] = $data['accepted_waste_types'] ?? null;
|
||||
$payload['operating_hours'] = $data['operating_hours'] ?? null;
|
||||
$payload['capacity_tons'] = $data['capacity_tons'] ?? null;
|
||||
$payload['contact_person'] = $data['contact_person'] ?? null;
|
||||
$payload['contact_phone'] = $data['contact_phone'] ?? null;
|
||||
$payload['permit_number'] = $data['permit_number'] ?? null;
|
||||
$payload['status'] = $data['status'] ?? Dumpsite::STATUS_ACTIVE;
|
||||
|
||||
$dumpsite = Dumpsite::create($payload);
|
||||
|
||||
return $this->created(
|
||||
new DumpsiteResource($dumpsite->fresh()->load('cityMunicipality')),
|
||||
'Dumpsite created',
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Dumpsite $dumpsite): JsonResponse
|
||||
{
|
||||
$dumpsite->load('cityMunicipality.province.region');
|
||||
|
||||
return $this->ok(new DumpsiteResource($dumpsite));
|
||||
}
|
||||
|
||||
public function update(UpdateDumpsiteRequest $request, Dumpsite $dumpsite): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$payload = $this->mapPayload($data);
|
||||
|
||||
// Carry through scalar/json fields that mapPayload doesn't touch.
|
||||
foreach ([
|
||||
'name', 'code', 'address_line', 'city_municipality_id',
|
||||
'accepted_waste_types', 'operating_hours', 'capacity_tons',
|
||||
'contact_person', 'contact_phone', 'permit_number', 'status',
|
||||
] as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$payload[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$dumpsite->update($payload);
|
||||
|
||||
return $this->ok(
|
||||
new DumpsiteResource($dumpsite->fresh()->load('cityMunicipality')),
|
||||
'Dumpsite updated',
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Dumpsite $dumpsite): JsonResponse
|
||||
{
|
||||
$dumpsite->delete();
|
||||
|
||||
return $this->ok(null, 'Dumpsite deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate lat/lng + boundary_polygon[{lat,lng}, ...] inputs into
|
||||
* spatial Polygon/Point objects suitable for assignment to the model.
|
||||
*/
|
||||
private function mapPayload(array $data): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
if (isset($data['lat'], $data['lng'])) {
|
||||
$payload['coordinates'] = new Point((float) $data['lat'], (float) $data['lng'], 4326);
|
||||
}
|
||||
|
||||
if (array_key_exists('boundary_polygon', $data)) {
|
||||
$payload['boundary_polygon'] = $data['boundary_polygon']
|
||||
? $this->buildPolygon($data['boundary_polygon'])
|
||||
: null;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function buildPolygon(array $points): Polygon
|
||||
{
|
||||
$ring = array_map(
|
||||
fn ($p) => new Point((float) $p['lat'], (float) $p['lng'], 4326),
|
||||
$points,
|
||||
);
|
||||
|
||||
$first = $ring[0];
|
||||
$last = $ring[count($ring) - 1];
|
||||
if ($first->latitude !== $last->latitude || $first->longitude !== $last->longitude) {
|
||||
$ring[] = new Point($first->latitude, $first->longitude, 4326);
|
||||
}
|
||||
|
||||
return new Polygon([new LineString($ring)], 4326);
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Dumpsite/StoreDumpsiteRequest.php
Normal file
42
app/Http/Requests/Dumpsite/StoreDumpsiteRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Dumpsite;
|
||||
|
||||
use App\Models\Dumpsite;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreDumpsiteRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:191'],
|
||||
'code' => ['required', 'string', 'max:32', Rule::unique('dumpsites', 'code')->whereNull('deleted_at')],
|
||||
'address_line' => ['required', 'string', 'max:255'],
|
||||
'city_municipality_id' => ['nullable', 'integer', 'exists:cities_municipalities,id'],
|
||||
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||
'lng' => ['required', 'numeric', 'between:-180,180'],
|
||||
'boundary_polygon' => ['nullable', 'array', 'min:3'],
|
||||
'boundary_polygon.*.lat' => ['required_with:boundary_polygon', 'numeric', 'between:-90,90'],
|
||||
'boundary_polygon.*.lng' => ['required_with:boundary_polygon', 'numeric', 'between:-180,180'],
|
||||
'accepted_waste_types' => ['nullable', 'array'],
|
||||
'accepted_waste_types.*' => ['string', 'max:50'],
|
||||
'operating_hours' => ['nullable', 'array'],
|
||||
'capacity_tons' => ['nullable', 'integer', 'min:0'],
|
||||
'contact_person' => ['nullable', 'string', 'max:191'],
|
||||
'contact_phone' => ['nullable', 'string', 'max:32'],
|
||||
'permit_number' => ['nullable', 'string', 'max:100'],
|
||||
'status' => ['nullable', Rule::in([
|
||||
Dumpsite::STATUS_ACTIVE,
|
||||
Dumpsite::STATUS_MAINTENANCE,
|
||||
Dumpsite::STATUS_CLOSED,
|
||||
])],
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Dumpsite/UpdateDumpsiteRequest.php
Normal file
46
app/Http/Requests/Dumpsite/UpdateDumpsiteRequest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Dumpsite;
|
||||
|
||||
use App\Models\Dumpsite;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateDumpsiteRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$id = $this->route('dumpsite')?->id;
|
||||
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:191'],
|
||||
'code' => [
|
||||
'sometimes', 'required', 'string', 'max:32',
|
||||
Rule::unique('dumpsites', 'code')->whereNull('deleted_at')->ignore($id),
|
||||
],
|
||||
'address_line' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'city_municipality_id' => ['sometimes', 'nullable', 'integer', 'exists:cities_municipalities,id'],
|
||||
'lat' => ['sometimes', 'required_with:lng', 'numeric', 'between:-90,90'],
|
||||
'lng' => ['sometimes', 'required_with:lat', 'numeric', 'between:-180,180'],
|
||||
'boundary_polygon' => ['sometimes', 'nullable', 'array', 'min:3'],
|
||||
'boundary_polygon.*.lat' => ['required_with:boundary_polygon', 'numeric', 'between:-90,90'],
|
||||
'boundary_polygon.*.lng' => ['required_with:boundary_polygon', 'numeric', 'between:-180,180'],
|
||||
'accepted_waste_types' => ['sometimes', 'nullable', 'array'],
|
||||
'operating_hours' => ['sometimes', 'nullable', 'array'],
|
||||
'capacity_tons' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'contact_person' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
'contact_phone' => ['sometimes', 'nullable', 'string', 'max:32'],
|
||||
'permit_number' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'status' => ['sometimes', Rule::in([
|
||||
Dumpsite::STATUS_ACTIVE,
|
||||
Dumpsite::STATUS_MAINTENANCE,
|
||||
Dumpsite::STATUS_CLOSED,
|
||||
])],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Resources/DumpsiteResource.php
Normal file
44
app/Http/Resources/DumpsiteResource.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class DumpsiteResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$boundaryPoints = null;
|
||||
if ($this->boundary_polygon) {
|
||||
$rings = $this->boundary_polygon->getGeometries();
|
||||
$ring = $rings->first();
|
||||
if ($ring) {
|
||||
$boundaryPoints = $ring->getGeometries()
|
||||
->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'code' => $this->code,
|
||||
'address_line' => $this->address_line,
|
||||
'coordinates' => $this->coordinates ? [
|
||||
'lat' => $this->coordinates->latitude,
|
||||
'lng' => $this->coordinates->longitude,
|
||||
] : null,
|
||||
'boundary_polygon' => $boundaryPoints,
|
||||
'accepted_waste_types' => $this->accepted_waste_types,
|
||||
'operating_hours' => $this->operating_hours,
|
||||
'capacity_tons' => $this->capacity_tons,
|
||||
'permit_number' => $this->permit_number,
|
||||
'status' => $this->status,
|
||||
'contact_person' => $this->contact_person,
|
||||
'contact_phone' => $this->contact_phone,
|
||||
'city_municipality' => CityMunicipalityResource::make($this->whenLoaded('cityMunicipality')),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
94
app/Models/Dumpsite.php
Normal file
94
app/Models/Dumpsite.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
|
||||
|
||||
class Dumpsite extends Model
|
||||
{
|
||||
use HasFactory, HasSpatial, SoftDeletes;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_MAINTENANCE = 'maintenance';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'code',
|
||||
'address_line',
|
||||
'city_municipality_id',
|
||||
'coordinates',
|
||||
'boundary_polygon',
|
||||
'accepted_waste_types',
|
||||
'operating_hours',
|
||||
'capacity_tons',
|
||||
'contact_person',
|
||||
'contact_phone',
|
||||
'permit_number',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'coordinates' => Point::class,
|
||||
'boundary_polygon' => Polygon::class,
|
||||
'accepted_waste_types' => 'array',
|
||||
'operating_hours' => 'array',
|
||||
'capacity_tons' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $d): void {
|
||||
if (empty($d->uuid)) {
|
||||
$d->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function cityMunicipality(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CityMunicipality::class);
|
||||
}
|
||||
|
||||
public function releases(): HasMany
|
||||
{
|
||||
return $this->hasMany(DumpsiteRelease::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geofence check: is the given (lat, lng) inside this dumpsite's
|
||||
* boundary polygon? Returns false if no boundary is configured.
|
||||
*/
|
||||
public function containsPoint(float $latitude, float $longitude): bool
|
||||
{
|
||||
if (! $this->boundary_polygon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'SELECT ST_Contains(boundary_polygon, ST_SRID(POINT(?, ?), 4326)) AS contained
|
||||
FROM dumpsites WHERE id = ? LIMIT 1',
|
||||
[$longitude, $latitude, $this->id],
|
||||
);
|
||||
|
||||
return (bool) ($row->contained ?? false);
|
||||
}
|
||||
}
|
||||
52
app/Models/DumpsiteRelease.php
Normal file
52
app/Models/DumpsiteRelease.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
|
||||
|
||||
/**
|
||||
* Records a load released at a dumpsite at the end of a trip. Populated
|
||||
* by Module 10 (Trips & Timeline). Module 6 only creates the schema.
|
||||
*/
|
||||
class DumpsiteRelease extends Model
|
||||
{
|
||||
use HasFactory, HasSpatial;
|
||||
|
||||
protected $fillable = [
|
||||
'trip_id',
|
||||
'dumpsite_id',
|
||||
'released_at',
|
||||
'released_by_driver_id',
|
||||
'weight_kg',
|
||||
'waste_type_breakdown',
|
||||
'gate_pass_number',
|
||||
'dumpsite_attendant_name',
|
||||
'photo_evidence_path',
|
||||
'coordinates_at_release',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'released_at' => 'datetime',
|
||||
'weight_kg' => 'integer',
|
||||
'waste_type_breakdown' => 'array',
|
||||
'coordinates_at_release' => Point::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function dumpsite(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Dumpsite::class);
|
||||
}
|
||||
|
||||
public function releasedByDriver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'released_by_driver_id');
|
||||
}
|
||||
}
|
||||
42
database/factories/DumpsiteFactory.php
Normal file
42
database/factories/DumpsiteFactory.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Dumpsite;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
use MatanYadaev\EloquentSpatial\Objects\LineString;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Dumpsite>
|
||||
*/
|
||||
class DumpsiteFactory extends Factory
|
||||
{
|
||||
protected $model = Dumpsite::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$lat = 14.7;
|
||||
$lng = 121.1;
|
||||
$half = 0.003;
|
||||
|
||||
return [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => fake()->city().' Dumpsite',
|
||||
'code' => 'DS-'.strtoupper(Str::random(6)),
|
||||
'address_line' => fake()->streetAddress(),
|
||||
'coordinates' => new Point($lat, $lng, 4326),
|
||||
'boundary_polygon' => new Polygon([new LineString([
|
||||
new Point($lat - $half, $lng - $half, 4326),
|
||||
new Point($lat - $half, $lng + $half, 4326),
|
||||
new Point($lat + $half, $lng + $half, 4326),
|
||||
new Point($lat + $half, $lng - $half, 4326),
|
||||
new Point($lat - $half, $lng - $half, 4326),
|
||||
])], 4326),
|
||||
'capacity_tons' => 1000,
|
||||
'status' => Dumpsite::STATUS_ACTIVE,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dumpsites', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('name', 191);
|
||||
$table->string('code', 32)->unique();
|
||||
$table->string('address_line', 255);
|
||||
$table->foreignId('city_municipality_id')
|
||||
->nullable()
|
||||
->constrained('cities_municipalities')
|
||||
->nullOnDelete();
|
||||
$table->geometry('coordinates', subtype: 'point', srid: 4326);
|
||||
$table->geometry('boundary_polygon', subtype: 'polygon', srid: 4326)->nullable();
|
||||
$table->json('accepted_waste_types')->nullable();
|
||||
$table->json('operating_hours')->nullable();
|
||||
$table->unsignedInteger('capacity_tons')->nullable();
|
||||
$table->string('contact_person', 191)->nullable();
|
||||
$table->string('contact_phone', 32)->nullable();
|
||||
$table->string('permit_number', 100)->nullable();
|
||||
$table->enum('status', ['active', 'maintenance', 'closed'])
|
||||
->default('active')
|
||||
->index();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['city_municipality_id', 'status']);
|
||||
});
|
||||
|
||||
DB::statement(
|
||||
'ALTER TABLE dumpsites ADD SPATIAL INDEX dumpsites_coords_spx (coordinates)',
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dumpsites');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dumpsite_releases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// trip_id will reference `trips` once Module 10 lands; add the
|
||||
// FK constraint there. For now keep it nullable bigint.
|
||||
$table->unsignedBigInteger('trip_id')->nullable()->index();
|
||||
$table->foreignId('dumpsite_id')->constrained('dumpsites')->cascadeOnDelete();
|
||||
$table->timestamp('released_at');
|
||||
$table->foreignId('released_by_driver_id')
|
||||
->nullable()
|
||||
->constrained('users')
|
||||
->nullOnDelete();
|
||||
$table->unsignedInteger('weight_kg');
|
||||
$table->json('waste_type_breakdown')->nullable();
|
||||
$table->string('gate_pass_number', 100)->nullable();
|
||||
$table->string('dumpsite_attendant_name', 191)->nullable();
|
||||
$table->string('photo_evidence_path')->nullable();
|
||||
$table->geometry('coordinates_at_release', subtype: 'point', srid: 4326)->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['dumpsite_id', 'released_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dumpsite_releases');
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
|
||||
AdminUserSeeder::class,
|
||||
SamplePsgcSeeder::class,
|
||||
SampleDropOffPointsSeeder::class,
|
||||
SampleDumpsitesSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
62
database/seeders/SampleDumpsitesSeeder.php
Normal file
62
database/seeders/SampleDumpsitesSeeder.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CityMunicipality;
|
||||
use App\Models\Dumpsite;
|
||||
use Illuminate\Database\Seeder;
|
||||
use MatanYadaev\EloquentSpatial\Objects\LineString;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
|
||||
/**
|
||||
* Sample dumpsite for development. Real Payatas / Rizal MRF coordinates
|
||||
* with a small rectangular geofence so geofence-trigger logic in Module
|
||||
* 10 has something to fire against.
|
||||
*/
|
||||
class SampleDumpsitesSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$qc = CityMunicipality::where('code', 'QC')->first();
|
||||
|
||||
// Old Payatas dumpsite area (closed in real life — used here as a
|
||||
// sample only). Centered at ~14.7155, 121.1083.
|
||||
$center = ['lat' => 14.7155, 'lng' => 121.1083];
|
||||
$half = 0.0035; // ~390m at this latitude
|
||||
|
||||
$boundaryPoints = [
|
||||
new Point($center['lat'] - $half, $center['lng'] - $half, 4326),
|
||||
new Point($center['lat'] - $half, $center['lng'] + $half, 4326),
|
||||
new Point($center['lat'] + $half, $center['lng'] + $half, 4326),
|
||||
new Point($center['lat'] + $half, $center['lng'] - $half, 4326),
|
||||
new Point($center['lat'] - $half, $center['lng'] - $half, 4326),
|
||||
];
|
||||
|
||||
Dumpsite::updateOrCreate(
|
||||
['code' => 'DS-PAYATAS-01'],
|
||||
[
|
||||
'name' => 'Payatas Sanitary Landfill (sample)',
|
||||
'address_line' => 'Payatas, Quezon City',
|
||||
'city_municipality_id' => $qc?->id,
|
||||
'coordinates' => new Point($center['lat'], $center['lng'], 4326),
|
||||
'boundary_polygon' => new Polygon([new LineString($boundaryPoints)], 4326),
|
||||
'accepted_waste_types' => ['general', 'biodegradable', 'residual'],
|
||||
'operating_hours' => [
|
||||
'mon' => ['open' => '06:00', 'close' => '22:00'],
|
||||
'tue' => ['open' => '06:00', 'close' => '22:00'],
|
||||
'wed' => ['open' => '06:00', 'close' => '22:00'],
|
||||
'thu' => ['open' => '06:00', 'close' => '22:00'],
|
||||
'fri' => ['open' => '06:00', 'close' => '22:00'],
|
||||
'sat' => ['open' => '06:00', 'close' => '18:00'],
|
||||
'sun' => null,
|
||||
],
|
||||
'capacity_tons' => 5000,
|
||||
'permit_number' => 'DENR-NCR-SAMPLE-2026',
|
||||
'contact_person' => 'Site Manager',
|
||||
'contact_phone' => '+63281234567',
|
||||
'status' => Dumpsite::STATUS_ACTIVE,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminDropOffPointController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminDumpsiteController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminHouseholdController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminUserController;
|
||||
use App\Http\Controllers\Api\V1\Auth\ForgotPasswordController;
|
||||
@@ -110,6 +111,17 @@ Route::prefix('admin/drop-off-points')
|
||||
Route::post('/{drop_off_point}/capacity', [AdminDropOffPointController::class, 'logCapacity'])->name('capacity.store');
|
||||
});
|
||||
|
||||
Route::prefix('admin/dumpsites')
|
||||
->name('api.v1.admin.dumpsites.')
|
||||
->middleware(['auth:sanctum', 'role:admin'])
|
||||
->group(function () {
|
||||
Route::get('/', [AdminDumpsiteController::class, 'index'])->name('index');
|
||||
Route::post('/', [AdminDumpsiteController::class, 'store'])->name('store');
|
||||
Route::get('/{dumpsite}', [AdminDumpsiteController::class, 'show'])->name('show');
|
||||
Route::patch('/{dumpsite}', [AdminDumpsiteController::class, 'update'])->name('update');
|
||||
Route::delete('/{dumpsite}', [AdminDumpsiteController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
Route::middleware('auth:sanctum')->prefix('households')->name('api.v1.households.')->group(function () {
|
||||
Route::post('/{household}/reassign-drop-off', [HouseholdController::class, 'reassignDropOff'])
|
||||
->name('reassign-drop-off');
|
||||
|
||||
165
tests/Feature/Api/V1/Dumpsite/DumpsiteCrudTest.php
Normal file
165
tests/Feature/Api/V1/Dumpsite/DumpsiteCrudTest.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1\Dumpsite;
|
||||
|
||||
use App\Models\Dumpsite;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Database\Seeders\SamplePsgcSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DumpsiteCrudTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed([RoleSeeder::class, SamplePsgcSeeder::class]);
|
||||
$this->admin = User::factory()->create([
|
||||
'role' => User::ROLE_ADMIN,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_create_dumpsite_with_boundary(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
|
||||
$response = $this->postJson('/api/v1/admin/dumpsites', [
|
||||
'name' => 'Test Dumpsite',
|
||||
'code' => 'DS-TEST-01',
|
||||
'address_line' => '123 Landfill Rd',
|
||||
'lat' => 14.7,
|
||||
'lng' => 121.1,
|
||||
'boundary_polygon' => [
|
||||
['lat' => 14.695, 'lng' => 121.095],
|
||||
['lat' => 14.695, 'lng' => 121.105],
|
||||
['lat' => 14.705, 'lng' => 121.105],
|
||||
['lat' => 14.705, 'lng' => 121.095],
|
||||
],
|
||||
'accepted_waste_types' => ['general', 'biodegradable'],
|
||||
'capacity_tons' => 2000,
|
||||
'permit_number' => 'DENR-2026-001',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.code', 'DS-TEST-01')
|
||||
->assertJsonPath('data.permit_number', 'DENR-2026-001');
|
||||
|
||||
$this->assertDatabaseHas('dumpsites', ['code' => 'DS-TEST-01']);
|
||||
$boundary = $response->json('data.boundary_polygon');
|
||||
$this->assertCount(5, $boundary, 'Boundary should be auto-closed (4 points -> 5 with first repeated)');
|
||||
}
|
||||
|
||||
public function test_create_rejects_polygon_with_fewer_than_3_points(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
|
||||
$response = $this->postJson('/api/v1/admin/dumpsites', [
|
||||
'name' => 'Bad', 'code' => 'DS-BAD',
|
||||
'address_line' => 'X', 'lat' => 14, 'lng' => 121,
|
||||
'boundary_polygon' => [
|
||||
['lat' => 14, 'lng' => 121],
|
||||
['lat' => 14.1, 'lng' => 121.1],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['boundary_polygon']);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_boundary_polygon(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
$d = Dumpsite::factory()->create();
|
||||
|
||||
$newRing = [
|
||||
['lat' => 14.0, 'lng' => 121.0],
|
||||
['lat' => 14.0, 'lng' => 121.1],
|
||||
['lat' => 14.1, 'lng' => 121.1],
|
||||
['lat' => 14.1, 'lng' => 121.0],
|
||||
];
|
||||
|
||||
$response = $this->patchJson("/api/v1/admin/dumpsites/{$d->uuid}", [
|
||||
'boundary_polygon' => $newRing,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(5, $response->json('data.boundary_polygon'));
|
||||
}
|
||||
|
||||
public function test_admin_can_clear_boundary(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
$d = Dumpsite::factory()->create();
|
||||
|
||||
$response = $this->patchJson("/api/v1/admin/dumpsites/{$d->uuid}", [
|
||||
'boundary_polygon' => null,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.boundary_polygon', null);
|
||||
}
|
||||
|
||||
public function test_admin_can_list_dumpsites_with_filter(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
Dumpsite::factory()->create(['status' => 'active', 'code' => 'DS-A']);
|
||||
Dumpsite::factory()->create(['status' => 'closed', 'code' => 'DS-B']);
|
||||
|
||||
$response = $this->getJson('/api/v1/admin/dumpsites?status=active');
|
||||
|
||||
$response->assertOk();
|
||||
$codes = collect($response->json('data'))->pluck('code')->all();
|
||||
$this->assertContains('DS-A', $codes);
|
||||
$this->assertNotContains('DS-B', $codes);
|
||||
}
|
||||
|
||||
public function test_admin_can_show_and_delete_dumpsite(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
$d = Dumpsite::factory()->create();
|
||||
|
||||
$this->getJson("/api/v1/admin/dumpsites/{$d->uuid}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.code', $d->code);
|
||||
|
||||
$this->deleteJson("/api/v1/admin/dumpsites/{$d->uuid}")->assertOk();
|
||||
$this->assertSoftDeleted('dumpsites', ['id' => $d->id]);
|
||||
}
|
||||
|
||||
public function test_resident_blocked(): void
|
||||
{
|
||||
$resident = User::factory()->create([
|
||||
'role' => User::ROLE_RESIDENT,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
]);
|
||||
Sanctum::actingAs($resident);
|
||||
|
||||
$this->getJson('/api/v1/admin/dumpsites')->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_unauthed_blocked(): void
|
||||
{
|
||||
$this->getJson('/api/v1/admin/dumpsites')->assertStatus(401);
|
||||
}
|
||||
|
||||
public function test_duplicate_code_rejected(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
Dumpsite::factory()->create(['code' => 'DS-DUP']);
|
||||
|
||||
$response = $this->postJson('/api/v1/admin/dumpsites', [
|
||||
'name' => 'Other', 'code' => 'DS-DUP', 'address_line' => 'X',
|
||||
'lat' => 14, 'lng' => 121,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['code']);
|
||||
}
|
||||
}
|
||||
42
tests/Feature/Api/V1/Dumpsite/DumpsiteGeofenceTest.php
Normal file
42
tests/Feature/Api/V1/Dumpsite/DumpsiteGeofenceTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1\Dumpsite;
|
||||
|
||||
use App\Models\Dumpsite;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Database\Seeders\SampleDumpsitesSeeder;
|
||||
use Database\Seeders\SamplePsgcSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DumpsiteGeofenceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDumpsitesSeeder::class]);
|
||||
}
|
||||
|
||||
public function test_contains_point_returns_true_inside_boundary(): void
|
||||
{
|
||||
$d = Dumpsite::where('code', 'DS-PAYATAS-01')->firstOrFail();
|
||||
|
||||
$this->assertTrue($d->containsPoint(14.7155, 121.1083));
|
||||
}
|
||||
|
||||
public function test_contains_point_returns_false_outside_boundary(): void
|
||||
{
|
||||
$d = Dumpsite::where('code', 'DS-PAYATAS-01')->firstOrFail();
|
||||
|
||||
$this->assertFalse($d->containsPoint(14.5, 121.0));
|
||||
}
|
||||
|
||||
public function test_contains_point_false_when_no_boundary(): void
|
||||
{
|
||||
$d = Dumpsite::factory()->create(['boundary_polygon' => null]);
|
||||
|
||||
$this->assertFalse($d->containsPoint(14.7, 121.1));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user