fix(scanner): add team_id to collection_logs for robust stats tracking
- Created a database migration to add `team_id` to `collection_logs`. - Created a database migration to backfill `team_id` for past scans using `trip_id` and scanner's active team. - Updated `ScanService` to lookup the scanner's active team and store the `team_id` directly on the `CollectionLog`. - Modified the `CollectionTeam` model's `collectionLogs()` relation to be a direct `HasMany` using the new `team_id` instead of routing through `Trip`. - Updated `AdminTeamReportController` to query metrics directly from `collection_logs.team_id`, ensuring all past and future scans (including those done without an active trip) correctly increment team stats and daily charts.
This commit is contained in:
@@ -75,12 +75,10 @@ class AdminTeamReportController extends ApiController
|
||||
|
||||
$scanStats = Tenancy::withoutScope(function () use ($teamIds, $fromStr, $toStr) {
|
||||
return DB::table('collection_logs')
|
||||
->join('trips', 'trips.id', '=', 'collection_logs.trip_id')
|
||||
->select('trips.team_id', DB::raw('COUNT(collection_logs.id) as total_scans'))
|
||||
->whereIn('trips.team_id', $teamIds)
|
||||
->whereBetween('collection_logs.scanned_at', [$fromStr, $toStr])
|
||||
->whereNull('trips.deleted_at')
|
||||
->groupBy('trips.team_id')
|
||||
->select('team_id', DB::raw('COUNT(id) as total_scans'))
|
||||
->whereIn('team_id', $teamIds)
|
||||
->whereBetween('scanned_at', [$fromStr, $toStr])
|
||||
->groupBy('team_id')
|
||||
->get()
|
||||
->keyBy('team_id');
|
||||
});
|
||||
@@ -179,10 +177,10 @@ class AdminTeamReportController extends ApiController
|
||||
$kpis = $this->buildKpis($team->id, $tripIds, $fromDate, $toDate, $fromDT, $toDT);
|
||||
|
||||
// --- Daily scans (group by DATE) ---
|
||||
$dailyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
$dailyScans = Tenancy::withoutScope(function () use ($team, $fromDT, $toDT) {
|
||||
return DB::table('collection_logs')
|
||||
->selectRaw('DATE(scanned_at) as date, COUNT(*) as scans')
|
||||
->whereIn('trip_id', $tripIds)
|
||||
->where('team_id', $team->id)
|
||||
->whereBetween('scanned_at', [$fromDT, $toDT])
|
||||
->groupByRaw('DATE(scanned_at)')
|
||||
->orderBy('date')
|
||||
@@ -191,10 +189,10 @@ class AdminTeamReportController extends ApiController
|
||||
});
|
||||
|
||||
// --- Weekly scans (group by YEARWEEK) ---
|
||||
$weeklyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
$weeklyScans = Tenancy::withoutScope(function () use ($team, $fromDT, $toDT) {
|
||||
return DB::table('collection_logs')
|
||||
->selectRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY)) as week_start, COUNT(*) as scans")
|
||||
->whereIn('trip_id', $tripIds)
|
||||
->where('team_id', $team->id)
|
||||
->whereBetween('scanned_at', [$fromDT, $toDT])
|
||||
->groupByRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY))")
|
||||
->orderBy('week_start')
|
||||
@@ -314,10 +312,10 @@ class AdminTeamReportController extends ApiController
|
||||
->first();
|
||||
});
|
||||
|
||||
$scanStats = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
$scanStats = Tenancy::withoutScope(function () use ($teamId, $fromDT, $toDT) {
|
||||
return DB::table('collection_logs')
|
||||
->selectRaw('COUNT(*) as total_scans, SUM(COALESCE(weight_kg,0)) as total_scan_weight')
|
||||
->whereIn('trip_id', $tripIds)
|
||||
->where('team_id', $teamId)
|
||||
->whereBetween('scanned_at', [$fromDT, $toDT])
|
||||
->first();
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ class CollectionLog extends Model
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id', 'qr_code_id', 'household_id', 'drop_off_point_id',
|
||||
'tenant_id', 'qr_code_id', 'team_id', 'household_id', 'drop_off_point_id',
|
||||
'scanned_by_user_id', 'trip_id', 'trip_stop_id',
|
||||
'scanned_at', 'coordinates_at_scan',
|
||||
'weight_kg', 'waste_type', 'photo_path', 'notes',
|
||||
@@ -62,4 +62,9 @@ class CollectionLog extends Model
|
||||
{
|
||||
return $this->belongsTo(Trip::class);
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CollectionTeam::class, 'team_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +82,9 @@ class CollectionTeam extends Model
|
||||
->whereIn('status', [Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE]);
|
||||
}
|
||||
|
||||
public function collectionLogs(): HasManyThrough
|
||||
public function collectionLogs(): HasMany
|
||||
{
|
||||
return $this->hasManyThrough(CollectionLog::class, Trip::class, 'team_id', 'trip_id');
|
||||
return $this->hasMany(CollectionLog::class, 'team_id');
|
||||
}
|
||||
|
||||
public function getIsFullAttribute(): bool
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Scan;
|
||||
|
||||
use App\Models\CollectionLog;
|
||||
use App\Models\CollectionTeam;
|
||||
use App\Models\DropOffPoint;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\Trip;
|
||||
@@ -86,8 +87,13 @@ class ScanService
|
||||
'scanned_by_user_id' => $scanner->id,
|
||||
])->save();
|
||||
|
||||
$team = CollectionTeam::where('scanner_id', $scanner->id)
|
||||
->where('status', CollectionTeam::STATUS_ACTIVE)
|
||||
->first();
|
||||
|
||||
$log = CollectionLog::create([
|
||||
'qr_code_id' => $code->id,
|
||||
'team_id' => $team?->id,
|
||||
'household_id' => $code->assigned_to_household_id,
|
||||
'drop_off_point_id' => $dropOff->id,
|
||||
'scanned_by_user_id' => $scanner->id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
feat: implement trip incident dashboard, drop-off geofence validation, and calendar updates
|
||||
fix(scanner): add team_id to collection_logs for robust stats tracking
|
||||
|
||||
- **Trip Incidents**: Created the `AdminTripController@incidents` API endpoint, built the `incidents.blade.php` view, enabled the incidents sidebar link, and added test coverage (`TripIncidentDashboardTest.php`).
|
||||
- **Drop-off Points (LGU Geofence)**: Added a Barangay (LGU) dropdown to the drop-off point form. Integrated `Turf.js` to render the LGU boundary polygon on Leaflet and restrict pin placement (clicks, drags, and address search) to within the selected boundary.
|
||||
- **Trip Calendar**: Changed the shortcut for opening the Daily Digest from Ctrl+Click to Shift+Click on calendar dates.
|
||||
- **API fixes**: Corrected the Barangay fetch endpoint to `/api/v1/geo/barangays` in the Drop-off points view.
|
||||
- Created a database migration to add `team_id` to `collection_logs`.
|
||||
- Created a database migration to backfill `team_id` for past scans using `trip_id` and scanner's active team.
|
||||
- Updated `ScanService` to lookup the scanner's active team and store the `team_id` directly on the `CollectionLog`.
|
||||
- Modified the `CollectionTeam` model's `collectionLogs()` relation to be a direct `HasMany` using the new `team_id` instead of routing through `Trip`.
|
||||
- Updated `AdminTeamReportController` to query metrics directly from `collection_logs.team_id`, ensuring all past and future scans (including those done without an active trip) correctly increment team stats and daily charts.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('collection_logs', function (Blueprint $table) {
|
||||
$table->foreignId('team_id')->nullable()->after('qr_code_id')->constrained('collection_teams')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('collection_logs', function (Blueprint $table) {
|
||||
$table->dropForeign(['team_id']);
|
||||
$table->dropColumn('team_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Backfill team_id from trips where trip_id is present
|
||||
DB::statement('
|
||||
UPDATE collection_logs
|
||||
JOIN trips ON trips.id = collection_logs.trip_id
|
||||
SET collection_logs.team_id = trips.team_id
|
||||
WHERE collection_logs.trip_id IS NOT NULL
|
||||
AND collection_logs.team_id IS NULL
|
||||
');
|
||||
|
||||
// For scans without a trip, backfill from the scanner's active team if available
|
||||
// Unfortunately, if they were scanned without a trip, there was no trip_id.
|
||||
// But do we know who scanned it? Yes, we can join QrCode -> scanned_by_user_id -> CollectionTeam
|
||||
DB::statement('
|
||||
UPDATE collection_logs
|
||||
JOIN qr_codes ON qr_codes.id = collection_logs.qr_code_id
|
||||
JOIN collection_teams ON collection_teams.scanner_id = qr_codes.scanned_by_user_id
|
||||
SET collection_logs.team_id = collection_teams.id
|
||||
WHERE collection_logs.team_id IS NULL
|
||||
AND collection_teams.status = "active"
|
||||
');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
64
tests/Feature/Api/V1/Scan/ScanTeamIdTest.php
Normal file
64
tests/Feature/Api/V1/Scan/ScanTeamIdTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1\Scan;
|
||||
|
||||
use App\Models\CollectionTeam;
|
||||
use App\Models\DropOffPoint;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrCodeBatch;
|
||||
use App\Models\User;
|
||||
use App\Services\Qr\BatchGenerator;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Database\Seeders\SampleDropOffPointsSeeder;
|
||||
use Database\Seeders\SamplePsgcSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScanTeamIdTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDropOffPointsSeeder::class]);
|
||||
}
|
||||
|
||||
public function test_scan_without_trip_assigns_team_id()
|
||||
{
|
||||
$scanner = User::factory()->create(['role' => User::ROLE_SCANNER, 'status' => 'active']);
|
||||
$team = CollectionTeam::create([
|
||||
'tenant_id' => 1,
|
||||
'name' => 'Test Team',
|
||||
'scanner_id' => $scanner->id,
|
||||
'status' => CollectionTeam::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$dop = DropOffPoint::first();
|
||||
$batch = app(BatchGenerator::class)->generate(1, QrCodeBatch::PURPOSE_FREE);
|
||||
$code = $batch->codes->first();
|
||||
$code->forceFill([
|
||||
'assigned_to_household_id' => \App\Models\Household::factory()->create(['barangay_id' => $dop->barangay_id])->id,
|
||||
'status' => 'active',
|
||||
])->save();
|
||||
|
||||
Sanctum::actingAs($scanner);
|
||||
|
||||
$response = $this->postJson('/api/v1/scanner/scan', [
|
||||
'serial' => $code->serial,
|
||||
'drop_off_point_id' => $dop->id,
|
||||
'lat' => $dop->coordinates->latitude,
|
||||
'lng' => $dop->coordinates->longitude,
|
||||
'weight_kg' => 5,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('collection_logs', [
|
||||
'qr_code_id' => $code->id,
|
||||
'team_id' => $team->id,
|
||||
'trip_id' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user