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:
Developer
2026-07-06 15:59:35 +08:00
parent 78c246c72f
commit aa8fdaa5aa
8 changed files with 166 additions and 20 deletions

View File

@@ -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();
});

View File

@@ -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');
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View File

@@ -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');
});
}
};

View File

@@ -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
{
//
}
};

View 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,
]);
}
}