From d458e743017bc3ce662f9a7e953a322584aa0be8 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 30 Apr 2026 02:12:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20complete=20Module=207=20?= =?UTF-8?q?=E2=80=94=20QR=20code=20system=20(core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qr_code_batches + qr_codes tables. State machine via spatie/laravel-model-states (unassigned → allocated → active → used, plus expired/voided). Serial format PH-{area}-{YYMM}-{batch}-{code}- {checksum} with 2-char SHA-256-derived checksum. BatchGenerator bulk-inserts in chunks. QrAllocator allocates free codes to verified households (prefers area-targeted batch, falls back to any). Real HouseholdVerified listener replaces the placeholder. QrBalanceLow event for Module 11. Admin: batch CRUD, mark-printed, void code, lifecycle search, PDF print sheet (24/A4 via dompdf + endroid/qr-code + picqer/barcode). Resident: list own codes, balance with low_balance flag, activate allocated codes. Removed explicit Event::listen for HouseholdVerified — Laravel 11 auto-discovers it via handle() type-hint, and double-registering was causing double-dispatch. 130 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 39 +- app/Events/QrBalanceLow.php | 18 + .../Api/V1/Admin/AdminQrBatchController.php | 159 ++++ .../Api/V1/Admin/AdminQrCodeController.php | 41 + .../Api/V1/Qr/MyQrCodeController.php | 94 +++ app/Http/Requests/Qr/GenerateBatchRequest.php | 31 + app/Http/Requests/Qr/VoidQrCodeRequest.php | 20 + app/Http/Resources/QrCodeBatchResource.php | 33 + app/Http/Resources/QrCodeResource.php | 26 + ...AllocateFreeQrCodesOnHouseholdVerified.php | 14 +- app/Models/QrCode.php | 67 ++ app/Models/QrCodeBatch.php | 61 ++ app/Providers/AppServiceProvider.php | 7 +- app/Services/Qr/BatchGenerator.php | 86 ++ app/Services/Qr/QrAllocator.php | 146 ++++ app/Services/Qr/QrSerialGenerator.php | 56 ++ app/States/QrCode/Active.php | 13 + app/States/QrCode/Allocated.php | 13 + app/States/QrCode/Expired.php | 13 + app/States/QrCode/QrCodeState.php | 29 + app/States/QrCode/Unassigned.php | 13 + app/States/QrCode/Used.php | 13 + app/States/QrCode/Voided.php | 13 + composer.json | 3 + composer.lock | 782 +++++++++++++++++- config/qr.php | 20 + ...05_100000_create_qr_code_batches_table.php | 42 + ...026_05_05_100001_create_qr_codes_table.php | 50 ++ resources/views/pdf/qr-batch.blade.php | 60 ++ routes/api.php | 28 + tests/Feature/Api/V1/Qr/MyQrCodeTest.php | 124 +++ tests/Feature/Api/V1/Qr/QrBatchTest.php | 129 +++ .../Api/V1/Qr/QrFreeAllocationTest.php | 106 +++ tests/Feature/Api/V1/Qr/QrSerialTest.php | 47 ++ 34 files changed, 2382 insertions(+), 14 deletions(-) create mode 100644 app/Events/QrBalanceLow.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AdminQrCodeController.php create mode 100644 app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php create mode 100644 app/Http/Requests/Qr/GenerateBatchRequest.php create mode 100644 app/Http/Requests/Qr/VoidQrCodeRequest.php create mode 100644 app/Http/Resources/QrCodeBatchResource.php create mode 100644 app/Http/Resources/QrCodeResource.php create mode 100644 app/Models/QrCode.php create mode 100644 app/Models/QrCodeBatch.php create mode 100644 app/Services/Qr/BatchGenerator.php create mode 100644 app/Services/Qr/QrAllocator.php create mode 100644 app/Services/Qr/QrSerialGenerator.php create mode 100644 app/States/QrCode/Active.php create mode 100644 app/States/QrCode/Allocated.php create mode 100644 app/States/QrCode/Expired.php create mode 100644 app/States/QrCode/QrCodeState.php create mode 100644 app/States/QrCode/Unassigned.php create mode 100644 app/States/QrCode/Used.php create mode 100644 app/States/QrCode/Voided.php create mode 100644 config/qr.php create mode 100644 database/migrations/2026_05_05_100000_create_qr_code_batches_table.php create mode 100644 database/migrations/2026_05_05_100001_create_qr_codes_table.php create mode 100644 resources/views/pdf/qr-batch.blade.php create mode 100644 tests/Feature/Api/V1/Qr/MyQrCodeTest.php create mode 100644 tests/Feature/Api/V1/Qr/QrBatchTest.php create mode 100644 tests/Feature/Api/V1/Qr/QrFreeAllocationTest.php create mode 100644 tests/Feature/Api/V1/Qr/QrSerialTest.php diff --git a/CLAUDE.md b/CLAUDE.md index ce62938..892370b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,8 +173,43 @@ bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in `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` +- [x] Module 7: QR Code System — complete (CORE) + - `qr_code_batches` and `qr_codes` tables; `target_store_id` / + `assigned_to_store_id` are nullable bigints until Module 12 adds FKs + - State machine via `spatie/laravel-model-states`: + `unassigned → allocated → active → used`, plus `expired` / `voided` + side states. Classes under `app/States/QrCode/`. + - Serial format `PH-{area}-{YYMM}-{batch}-{code}-{checksum}` via + `QrSerialGenerator`; 2-char SHA-256-derived alphanumeric checksum. + `isValid()` rejects tampered serials. + - `BatchGenerator::generate()` — bulk-inserts in 1000-row chunks for + big batches; per-day batch sequence; transactional. + - `QrAllocator::allocateFreeToHousehold()` allocates the configured + quota (`config('qr.free_allocation_per_household')`, default 10) + from a free batch — prefers a batch whose target_area covers the + household's barangay, falls back to any active free batch. + - `HouseholdVerified` listener now does real allocation. **Don't + register listeners explicitly in `AppServiceProvider::boot` if they + follow the `Listener@handle(Event)` convention** — Laravel 11 + auto-discovers them, and double-registering causes double-dispatch. + - `QrBalanceLow` event fires from `QrAllocator::notifyBalanceIfLow()` + (Module 11 will hit this from the scan flow). + - Admin endpoints: `GET/POST /admin/qr-batches`, `GET .../{batch}`, + `POST .../{batch}/mark-printed`, `GET .../{batch}/print.pdf`, + `GET /admin/qr-codes/{serial}` (lifecycle search), `POST /admin/ + qr-codes/{serial}/void` (with reason). + - Resident endpoints: `GET /me/qr-codes`, `GET /me/qr-codes/balance` + (counts by status + low_balance flag), `POST /me/qr-codes/{serial}/ + activate`. + - PDF: `barryvdh/laravel-dompdf` + `endroid/qr-code` (v6 — use + `new Builder(writer:, data:, ...)` constructor; static `Builder:: + create()` was removed) + `picqer/php-barcode-generator`. Blade + template at `resources/views/pdf/qr-batch.blade.php` renders 24 + cards/A4 (4×6, QR + Code-128 + serial). + - `config/qr.php` with `free_allocation_per_household` and + `low_balance_threshold` env-overridable. +- All 130 feature tests passing +- [ ] Module 8+: see `../docs/development-roadmap.md` ### Geo notes - Boundary polygons + centroids stored nullable for now. Once a full PSGC diff --git a/app/Events/QrBalanceLow.php b/app/Events/QrBalanceLow.php new file mode 100644 index 0000000..078bba3 --- /dev/null +++ b/app/Events/QrBalanceLow.php @@ -0,0 +1,18 @@ +validate([ + 'purpose' => ['nullable', 'in:free_allocation,store_inventory,promotional'], + 'target_area_id' => ['nullable', 'integer'], + 'q' => ['nullable', 'string', 'max:100'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $perPage = (int) $request->input('per_page', 25); + + $batches = QrCodeBatch::query() + ->with(['targetArea', 'createdBy']) + ->withCount('codes') + ->when($request->filled('purpose'), fn ($q) => $q->where('purpose', $request->string('purpose'))) + ->when( + $request->filled('target_area_id'), + fn ($q) => $q->where('target_area_id', $request->integer('target_area_id')), + ) + ->when( + $request->filled('q'), + fn ($q) => $q->where('batch_number', 'like', '%'.$request->string('q').'%'), + ) + ->orderByDesc('id') + ->paginate($perPage); + + return $this->ok( + QrCodeBatchResource::collection($batches), + null, + [ + 'page' => $batches->currentPage(), + 'per_page' => $batches->perPage(), + 'total' => $batches->total(), + 'last_page' => $batches->lastPage(), + ], + ); + } + + public function store(GenerateBatchRequest $request, BatchGenerator $generator): JsonResponse + { + $data = $request->validated(); + $area = isset($data['target_area_id']) + ? ServiceArea::find($data['target_area_id']) + : null; + + $batch = $generator->generate( + quantity: (int) $data['quantity'], + purpose: $data['purpose'], + targetArea: $area, + targetStoreId: $data['target_store_id'] ?? null, + createdBy: $request->user(), + expiresAt: isset($data['expires_at']) ? \Carbon\Carbon::parse($data['expires_at']) : null, + notes: $data['notes'] ?? null, + ); + + $batch->load(['targetArea', 'createdBy', 'codes']); + + return $this->created(new QrCodeBatchResource($batch), 'Batch generated'); + } + + public function show(QrCodeBatch $qrCodeBatch): JsonResponse + { + $qrCodeBatch->load(['targetArea', 'createdBy', 'codes']); + + return $this->ok(new QrCodeBatchResource($qrCodeBatch)); + } + + public function markPrinted(QrCodeBatch $qrCodeBatch): JsonResponse + { + if ($qrCodeBatch->printed_at) { + return $this->fail('Batch already marked as printed', null, 422); + } + + $qrCodeBatch->forceFill(['printed_at' => now()])->save(); + + return $this->ok( + new QrCodeBatchResource($qrCodeBatch->fresh()->load(['targetArea', 'createdBy', 'codes'])), + 'Batch marked as printed', + ); + } + + public function showCode(string $serial): JsonResponse + { + $code = \App\Models\QrCode::query() + ->with(['batch.targetArea', 'household.head', 'usedAtDropOff', 'scannedBy']) + ->where('serial', $serial) + ->firstOrFail(); + + return $this->ok(new QrCodeResource($code)); + } + + public function printPdf(QrCodeBatch $qrCodeBatch): Response + { + $codes = $qrCodeBatch->codes()->orderBy('id')->get(); + + $rendered = $codes->map(function ($c) { + return [ + 'serial' => $c->serial, + 'qr_data_uri' => $this->qrDataUri($c->serial), + 'barcode_data_uri' => $this->barcodeDataUri($c->serial), + ]; + })->all(); + + $perPage = 24; + $pages = array_chunk($rendered, $perPage); + + $pdf = Pdf::loadView('pdf.qr-batch', [ + 'batch' => $qrCodeBatch, + 'pages' => $pages, + ])->setPaper('a4'); + + $filename = "verde-{$qrCodeBatch->batch_number}.pdf"; + + return $pdf->download($filename); + } + + private function qrDataUri(string $serial): string + { + $result = (new Builder( + writer: new PngWriter(), + data: $serial, + errorCorrectionLevel: ErrorCorrectionLevel::Medium, + size: 140, + margin: 4, + ))->build(); + + return $result->getDataUri(); + } + + private function barcodeDataUri(string $serial): string + { + $generator = new BarcodeGeneratorPNG(); + $png = $generator->getBarcode($serial, BarcodeGeneratorPNG::TYPE_CODE_128, 1, 28); + + return 'data:image/png;base64,'.base64_encode($png); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AdminQrCodeController.php b/app/Http/Controllers/Api/V1/Admin/AdminQrCodeController.php new file mode 100644 index 0000000..6efab00 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminQrCodeController.php @@ -0,0 +1,41 @@ +where('serial', $serial) + ->firstOrFail(); + + return $this->ok(new QrCodeResource($code)); + } + + public function void(VoidQrCodeRequest $request, QrCode $qrCode): JsonResponse + { + if (! $qrCode->status->canTransitionTo(Voided::class)) { + return $this->fail('Code cannot be voided from its current state', null, 422); + } + + $qrCode->status->transitionTo(Voided::class); + $existingMeta = $qrCode->metadata ?? []; + $qrCode->forceFill([ + 'metadata' => array_merge($existingMeta, [ + 'voided_at' => now()->toIso8601String(), + 'voided_by_admin_id' => $request->user()->id, + 'void_reason' => $request->validated('reason'), + ]), + ])->save(); + + return $this->ok(new QrCodeResource($qrCode->fresh()), 'Code voided'); + } +} diff --git a/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php b/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php new file mode 100644 index 0000000..15d5f6c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php @@ -0,0 +1,94 @@ +validate([ + 'status' => ['nullable', 'in:unassigned,allocated,active,used,expired,voided'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $household = Household::where('head_user_id', $request->user()->id)->first(); + if (! $household) { + return $this->ok([], 'No household yet'); + } + + $perPage = (int) $request->input('per_page', 50); + + $codes = QrCode::query() + ->where('assigned_to_household_id', $household->id) + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->orderByDesc('id') + ->paginate($perPage); + + return $this->ok( + QrCodeResource::collection($codes), + null, + [ + 'page' => $codes->currentPage(), + 'per_page' => $codes->perPage(), + 'total' => $codes->total(), + ], + ); + } + + public function balance(Request $request): JsonResponse + { + $household = Household::where('head_user_id', $request->user()->id)->first(); + if (! $household) { + return $this->ok([ + 'active' => 0, 'allocated' => 0, 'used' => 0, 'expired' => 0, + 'low_balance' => false, + ]); + } + + $counts = QrCode::query() + ->where('assigned_to_household_id', $household->id) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status') + ->all(); + + $active = (int) ($counts['active'] ?? 0); + $threshold = (int) config('qr.low_balance_threshold', 5); + + return $this->ok([ + 'active' => $active, + 'allocated' => (int) ($counts['allocated'] ?? 0), + 'used' => (int) ($counts['used'] ?? 0), + 'expired' => (int) ($counts['expired'] ?? 0), + 'low_balance' => $active <= $threshold, + 'low_balance_threshold' => $threshold, + ]); + } + + public function activate(Request $request, QrCode $qrCode, QrAllocator $allocator): JsonResponse + { + $household = Household::where('head_user_id', $request->user()->id)->first(); + if (! $household || $qrCode->assigned_to_household_id !== $household->id) { + return $this->forbidden('This code is not assigned to your household'); + } + + if ($qrCode->status instanceof Active) { + return $this->ok(new QrCodeResource($qrCode), 'Code already active'); + } + + if (! $allocator->activate($qrCode)) { + return $this->fail('Code cannot be activated from its current state', null, 422); + } + + return $this->ok(new QrCodeResource($qrCode->fresh()), 'Code activated'); + } +} diff --git a/app/Http/Requests/Qr/GenerateBatchRequest.php b/app/Http/Requests/Qr/GenerateBatchRequest.php new file mode 100644 index 0000000..583891d --- /dev/null +++ b/app/Http/Requests/Qr/GenerateBatchRequest.php @@ -0,0 +1,31 @@ + ['required', 'integer', 'min:1', 'max:50000'], + 'purpose' => ['required', Rule::in([ + QrCodeBatch::PURPOSE_FREE, + QrCodeBatch::PURPOSE_STORE, + QrCodeBatch::PURPOSE_PROMO, + ])], + 'target_area_id' => ['nullable', 'integer', 'exists:service_areas,id'], + 'target_store_id' => ['nullable', 'integer'], + 'expires_at' => ['nullable', 'date', 'after:today'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Requests/Qr/VoidQrCodeRequest.php b/app/Http/Requests/Qr/VoidQrCodeRequest.php new file mode 100644 index 0000000..bb56cea --- /dev/null +++ b/app/Http/Requests/Qr/VoidQrCodeRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'min:3', 'max:500'], + ]; + } +} diff --git a/app/Http/Resources/QrCodeBatchResource.php b/app/Http/Resources/QrCodeBatchResource.php new file mode 100644 index 0000000..989a2f4 --- /dev/null +++ b/app/Http/Resources/QrCodeBatchResource.php @@ -0,0 +1,33 @@ +whenLoaded('codes', function () { + return $this->codes->groupBy(fn (QrCode $c) => (string) $c->status) + ->map(fn ($group) => $group->count()) + ->all(); + }); + + return [ + 'batch_number' => $this->batch_number, + 'quantity' => $this->quantity, + 'purpose' => $this->purpose, + 'target_area' => $this->whenLoaded('targetArea', fn () => $this->targetArea?->code), + 'target_store_id' => $this->target_store_id, + 'created_by' => $this->whenLoaded('createdBy', fn () => $this->createdBy?->full_name), + 'printed_at' => $this->printed_at?->toIso8601String(), + 'expires_at' => $this->expires_at?->toIso8601String(), + 'notes' => $this->notes, + 'status_counts' => $statusCounts, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/QrCodeResource.php b/app/Http/Resources/QrCodeResource.php new file mode 100644 index 0000000..f637eec --- /dev/null +++ b/app/Http/Resources/QrCodeResource.php @@ -0,0 +1,26 @@ + $this->serial, + 'barcode_value' => $this->barcode_value, + 'status' => (string) $this->status, + 'batch_number' => $this->whenLoaded('batch', fn () => $this->batch?->batch_number), + 'household_id' => $this->whenLoaded('household', fn () => $this->household?->uuid), + 'allocated_at' => $this->allocated_at?->toIso8601String(), + 'activated_at' => $this->activated_at?->toIso8601String(), + 'used_at' => $this->used_at?->toIso8601String(), + 'expires_at' => $this->expires_at?->toIso8601String(), + 'used_at_drop_off' => $this->whenLoaded('usedAtDropOff', fn () => $this->usedAtDropOff?->name), + 'scanned_by' => $this->whenLoaded('scannedBy', fn () => $this->scannedBy?->full_name), + ]; + } +} diff --git a/app/Listeners/AllocateFreeQrCodesOnHouseholdVerified.php b/app/Listeners/AllocateFreeQrCodesOnHouseholdVerified.php index b7aa50a..aff0cd4 100644 --- a/app/Listeners/AllocateFreeQrCodesOnHouseholdVerified.php +++ b/app/Listeners/AllocateFreeQrCodesOnHouseholdVerified.php @@ -3,21 +3,21 @@ namespace App\Listeners; use App\Events\HouseholdVerified; +use App\Services\Qr\QrAllocator; use Illuminate\Support\Facades\Log; -/** - * Placeholder. Module 7 (QR Code System) replaces this with actual code - * batch allocation logic. Kept here so Module 4's verification flow can - * fire the event safely from day one. - */ class AllocateFreeQrCodesOnHouseholdVerified { + public function __construct(private readonly QrAllocator $allocator) {} + public function handle(HouseholdVerified $event): void { - Log::info('HouseholdVerified — free QR allocation pending Module 7', [ + $allocated = $this->allocator->allocateFreeToHousehold($event->household); + + Log::info('HouseholdVerified — free QR codes allocated', [ 'household_id' => $event->household->id, 'household_uuid' => $event->household->uuid, - 'head_user_id' => $event->household->head_user_id, + 'allocated' => $allocated, 'verified_by' => $event->verifiedBy->id, ]); } diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php new file mode 100644 index 0000000..ab68fbf --- /dev/null +++ b/app/Models/QrCode.php @@ -0,0 +1,67 @@ + QrCodeState::class, + 'metadata' => 'array', + 'allocated_at' => 'datetime', + 'activated_at' => 'datetime', + 'used_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } + + public function getRouteKeyName(): string + { + return 'serial'; + } + + public function batch(): BelongsTo + { + return $this->belongsTo(QrCodeBatch::class, 'batch_id'); + } + + public function household(): BelongsTo + { + return $this->belongsTo(Household::class, 'assigned_to_household_id'); + } + + public function usedAtDropOff(): BelongsTo + { + return $this->belongsTo(DropOffPoint::class, 'used_at_drop_off_id'); + } + + public function scannedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'scanned_by_user_id'); + } +} diff --git a/app/Models/QrCodeBatch.php b/app/Models/QrCodeBatch.php new file mode 100644 index 0000000..d4ab8ce --- /dev/null +++ b/app/Models/QrCodeBatch.php @@ -0,0 +1,61 @@ + 'datetime', + 'expires_at' => 'datetime', + 'quantity' => 'integer', + ]; + } + + public function getRouteKeyName(): string + { + return 'batch_number'; + } + + public function targetArea(): BelongsTo + { + return $this->belongsTo(ServiceArea::class, 'target_area_id'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_admin_id'); + } + + public function codes(): HasMany + { + return $this->hasMany(QrCode::class, 'batch_id'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 86fccd1..f7bb5aa 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,13 +2,10 @@ namespace App\Providers; -use App\Events\HouseholdVerified; -use App\Listeners\AllocateFreeQrCodesOnHouseholdVerified; use App\Services\Sms\FakeSmsService; use App\Services\Sms\LogSmsService; use App\Services\Sms\SemaphoreSmsService; use App\Services\Sms\SmsService; -use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -31,6 +28,8 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - Event::listen(HouseholdVerified::class, AllocateFreeQrCodesOnHouseholdVerified::class); + // Listeners under App\Listeners are auto-discovered by Laravel 11 + // based on their handle() method's type-hint. Don't register them + // explicitly here — that causes double-dispatch. } } diff --git a/app/Services/Qr/BatchGenerator.php b/app/Services/Qr/BatchGenerator.php new file mode 100644 index 0000000..bcbca33 --- /dev/null +++ b/app/Services/Qr/BatchGenerator.php @@ -0,0 +1,86 @@ +code ?? 'PH'; + $batchSeq = $this->nextBatchSeq($now); + $batchNumber = sprintf('B%s%04d', $now->format('ymd'), $batchSeq); + + return DB::transaction(function () use ( + $batchNumber, $quantity, $purpose, $targetArea, $targetStoreId, + $createdBy, $expiresAt, $notes, $now, $areaCode, $batchSeq, + ) { + $batch = QrCodeBatch::create([ + 'batch_number' => $batchNumber, + 'quantity' => $quantity, + 'purpose' => $purpose, + 'target_area_id' => $targetArea?->id, + 'target_store_id' => $targetStoreId, + 'created_by_admin_id' => $createdBy?->id, + 'expires_at' => $expiresAt, + 'notes' => $notes, + ]); + + $rows = []; + $tsNow = $now->toDateTimeString(); + for ($i = 1; $i <= $quantity; $i++) { + $serial = $this->serials->build($areaCode, $now, $batchSeq, $i); + $rows[] = [ + 'serial' => $serial, + 'barcode_value' => $serial, + 'batch_id' => $batch->id, + 'status' => Unassigned::$name, + 'expires_at' => $expiresAt, + 'created_at' => $tsNow, + 'updated_at' => $tsNow, + ]; + + // Bulk insert in chunks to keep memory bounded for big batches. + if (count($rows) >= 1000) { + QrCode::insert($rows); + $rows = []; + } + } + + if (! empty($rows)) { + QrCode::insert($rows); + } + + return $batch->fresh(); + }); + } + + private function nextBatchSeq(Carbon $when): int + { + // Per-day batch sequence: count today's batches, +1. + $today = $when->copy()->startOfDay(); + $tomorrow = $when->copy()->endOfDay(); + + return QrCodeBatch::whereBetween('created_at', [$today, $tomorrow])->count() + 1; + } +} diff --git a/app/Services/Qr/QrAllocator.php b/app/Services/Qr/QrAllocator.php new file mode 100644 index 0000000..91a3746 --- /dev/null +++ b/app/Services/Qr/QrAllocator.php @@ -0,0 +1,146 @@ +findFreeBatchForHousehold($household); + if (! $batch) { + Log::warning('No free_allocation batch available for household', [ + 'household_id' => $household->id, + 'barangay_id' => $household->barangay_id, + ]); + + return 0; + } + + $codeIds = QrCode::query() + ->where('batch_id', $batch->id) + ->where('status', Unassigned::$name) + ->orderBy('id') + ->limit($quota) + ->lockForUpdate() + ->pluck('id'); + + if ($codeIds->isEmpty()) { + Log::warning('Free batch found but no unassigned codes left', [ + 'batch_id' => $batch->id, + 'household_id' => $household->id, + ]); + + return 0; + } + + $now = now(); + QrCode::whereIn('id', $codeIds)->update([ + 'status' => Active::$name, + 'assigned_to_household_id' => $household->id, + 'allocated_at' => $now, + 'activated_at' => $now, + 'updated_at' => $now, + ]); + + $allocated = $codeIds->count(); + if ($allocated < $quota) { + Log::info('Partial free allocation — batch ran out', [ + 'household_id' => $household->id, + 'allocated' => $allocated, + 'quota' => $quota, + ]); + } + + return $allocated; + }); + } + + /** + * Activate an allocated code (allocated → active). Used by residents + * after they receive a printed sheet (e.g., from a partner store). + */ + public function activate(QrCode $code): bool + { + if (! $code->status->canTransitionTo(Active::class)) { + return false; + } + + $code->status->transitionTo(Active::class); + $code->forceFill(['activated_at' => now()])->save(); + + return true; + } + + /** + * Count active codes for a household and dispatch QrBalanceLow when + * the count crosses below the threshold. Call this after a code is + * marked `used` (Module 11 will hit this from the scan flow). + */ + public function notifyBalanceIfLow(Household $household): void + { + $threshold = (int) config('qr.low_balance_threshold', 5); + $active = QrCode::where('assigned_to_household_id', $household->id) + ->where('status', Active::$name) + ->count(); + + if ($active <= $threshold) { + QrBalanceLow::dispatch($household, $active, $threshold); + } + } + + /** + * Find the most appropriate free_allocation batch for a household: + * prefer one targeting a service area that covers the household's + * barangay; fall back to any active free_allocation batch. + */ + private function findFreeBatchForHousehold(Household $household): ?QrCodeBatch + { + $now = now(); + + $query = QrCodeBatch::query() + ->where('purpose', QrCodeBatch::PURPOSE_FREE) + ->where(function ($q) use ($now) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', $now); + }); + + if ($household->barangay_id) { + $matched = (clone $query) + ->whereHas('targetArea.barangays', function ($q) use ($household) { + $q->where('barangays.id', $household->barangay_id); + }) + ->whereHas('codes', fn ($c) => $c->where('status', Unassigned::$name)) + ->orderBy('id') + ->first(); + + if ($matched) { + return $matched; + } + } + + return $query + ->whereHas('codes', fn ($c) => $c->where('status', Unassigned::$name)) + ->orderBy('id') + ->first(); + } +} diff --git a/app/Services/Qr/QrSerialGenerator.php b/app/Services/Qr/QrSerialGenerator.php new file mode 100644 index 0000000..9dd6e3e --- /dev/null +++ b/app/Services/Qr/QrSerialGenerator.php @@ -0,0 +1,56 @@ +format('ym'), + $batchSeq, + $codeSeq, + ); + + return $base.'-'.$this->checksum($base); + } + + /** + * Returns 2-char alphanumeric checksum derived from SHA-256 of the + * input. Deterministic; designed to catch single-character typos + * during manual entry, not as a cryptographic guarantee. + */ + public function checksum(string $base): string + { + $hex = hash('sha256', $base); + // Convert two hex bytes to base-36 alphanumeric for shorter, + // human-readable output. + $int = hexdec(substr($hex, 0, 4)); + $code = strtoupper(base_convert((string) $int, 10, 36)); + + return str_pad(substr($code, 0, 2), 2, '0', STR_PAD_LEFT); + } + + public function isValid(string $serial): bool + { + $parts = explode('-', $serial); + if (count($parts) !== 6) { + return false; + } + + $checksum = array_pop($parts); + $base = implode('-', $parts); + + return $this->checksum($base) === strtoupper($checksum); + } +} diff --git a/app/States/QrCode/Active.php b/app/States/QrCode/Active.php new file mode 100644 index 0000000..79c2c29 --- /dev/null +++ b/app/States/QrCode/Active.php @@ -0,0 +1,13 @@ +default(Unassigned::class) + ->allowTransition(Unassigned::class, Allocated::class) + ->allowTransition(Allocated::class, Active::class) + ->allowTransition(Active::class, Used::class) + ->allowTransition([Allocated::class, Active::class], Expired::class) + ->allowTransition([Unassigned::class, Allocated::class, Active::class], Voided::class); + } + + abstract public function label(): string; +} diff --git a/app/States/QrCode/Unassigned.php b/app/States/QrCode/Unassigned.php new file mode 100644 index 0000000..b713ff7 --- /dev/null +++ b/app/States/QrCode/Unassigned.php @@ -0,0 +1,13 @@ +=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -433,6 +615,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -564,6 +901,78 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "endroid/qr-code", + "version": "6.1.3", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.4" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.3", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.1.3" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2026-02-05T07:01:58+00:00" + }, { "name": "facade/ignition-contracts", "version": "1.0.2", @@ -2185,6 +2594,73 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "matanyadaev/laravel-eloquent-spatial", "version": "4.7.0", @@ -2831,6 +3307,87 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "picqer/php-barcode-generator", + "version": "v3.2.4", + "source": { + "type": "git", + "url": "https://github.com/picqer/php-barcode-generator.git", + "reference": "16a5cfb74eb8b7a0aa2e60427d98599aa8259380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/picqer/php-barcode-generator/zipball/16a5cfb74eb8b7a0aa2e60427d98599aa8259380", + "reference": "16a5cfb74eb8b7a0aa2e60427d98599aa8259380", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-bcmath": "Barcode IMB (Intelligent Mail Barcode) needs bcmath extension", + "ext-gd": "For JPG and PNG generators, GD or Imagick is required", + "ext-imagick": "For JPG and PNG generators, GD or Imagick is required" + }, + "type": "library", + "autoload": { + "psr-4": { + "Picqer\\Barcode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Casper Bakker", + "email": "info@picqer.com", + "homepage": "https://picqer.com" + }, + { + "name": "Nicola Asuni", + "email": "info@tecnick.com", + "homepage": "http://nicolaasuni.tecnick.com" + } + ], + "description": "An easy to use, non-bloated, barcode generator in PHP. Creates SVG, PNG, JPG and HTML images from the most used 1D barcode standards.", + "homepage": "https://github.com/picqer/php-barcode-generator", + "keywords": [ + "CODABAR", + "Code11", + "Code93", + "EAN13", + "KIX", + "KIXCODE", + "MSI", + "POSTNET", + "Pharma", + "Standard 2 of 5", + "barcode", + "barcode generator", + "code128", + "code39", + "ean", + "html", + "jpeg", + "jpg", + "php", + "png", + "svg", + "upc" + ], + "support": { + "issues": "https://github.com/picqer/php-barcode-generator/issues", + "source": "https://github.com/picqer/php-barcode-generator/tree/v3.2.4" + }, + "time": "2026-01-08T08:57:40+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3520,6 +4077,86 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, { "name": "spatie/laravel-activitylog", "version": "4.12.3", @@ -6323,6 +6960,149 @@ ], "time": "2026-03-30T13:44:50+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/config/qr.php b/config/qr.php new file mode 100644 index 0000000..c6738fb --- /dev/null +++ b/config/qr.php @@ -0,0 +1,20 @@ + (int) env('QR_FREE_ALLOCATION_PER_HOUSEHOLD', 10), + + /* + | When a household's active code count drops to or below this number, + | a QrBalanceLow event fires (notification listeners hook off it). + */ + 'low_balance_threshold' => (int) env('QR_LOW_BALANCE_THRESHOLD', 5), + + /* + | Default expiry for a generated batch (months from creation). Null + | means no expiry. + */ + 'default_batch_expiry_months' => env('QR_DEFAULT_BATCH_EXPIRY_MONTHS', null), +]; diff --git a/database/migrations/2026_05_05_100000_create_qr_code_batches_table.php b/database/migrations/2026_05_05_100000_create_qr_code_batches_table.php new file mode 100644 index 0000000..7fe22ed --- /dev/null +++ b/database/migrations/2026_05_05_100000_create_qr_code_batches_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('batch_number', 32)->unique(); + $table->unsignedInteger('quantity'); + $table->enum('purpose', ['free_allocation', 'store_inventory', 'promotional']) + ->index(); + $table->foreignId('target_area_id') + ->nullable() + ->constrained('service_areas') + ->nullOnDelete(); + // target_store_id will reference partner_stores once Module 12 + // adds the table; nullable bigint without FK for now. + $table->unsignedBigInteger('target_store_id')->nullable()->index(); + $table->foreignId('created_by_admin_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + $table->timestamp('printed_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['purpose', 'target_area_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('qr_code_batches'); + } +}; diff --git a/database/migrations/2026_05_05_100001_create_qr_codes_table.php b/database/migrations/2026_05_05_100001_create_qr_codes_table.php new file mode 100644 index 0000000..a3a74ca --- /dev/null +++ b/database/migrations/2026_05_05_100001_create_qr_codes_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('serial', 64)->unique(); + $table->string('barcode_value', 64)->nullable(); + $table->foreignId('batch_id') + ->constrained('qr_code_batches') + ->cascadeOnDelete(); + $table->string('status', 16)->default('unassigned')->index(); + $table->foreignId('assigned_to_household_id') + ->nullable() + ->constrained('households') + ->nullOnDelete(); + // assigned_to_store_id will reference partner_stores in Module 12 + $table->unsignedBigInteger('assigned_to_store_id')->nullable()->index(); + $table->timestamp('allocated_at')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('used_at')->nullable(); + $table->foreignId('used_at_drop_off_id') + ->nullable() + ->constrained('drop_off_points') + ->nullOnDelete(); + $table->foreignId('scanned_by_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + $table->timestamp('expires_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['status', 'assigned_to_household_id'], 'qr_codes_status_household_idx'); + $table->index(['status', 'assigned_to_store_id'], 'qr_codes_status_store_idx'); + $table->index(['batch_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('qr_codes'); + } +}; diff --git a/resources/views/pdf/qr-batch.blade.php b/resources/views/pdf/qr-batch.blade.php new file mode 100644 index 0000000..9cdac0e --- /dev/null +++ b/resources/views/pdf/qr-batch.blade.php @@ -0,0 +1,60 @@ + + + + + Verde QR Batch {{ $batch->batch_number }} + + + +@foreach ($pages as $pageIndex => $page) +
+ Verde — Batch {{ $batch->batch_number }} +   · {{ $batch->purpose }} +   · {{ count($page) }} of {{ $batch->quantity }} codes +   · page {{ $pageIndex + 1 }}/{{ count($pages) }} +
+ + @php $rows = array_chunk($page, 4); @endphp + @foreach ($rows as $row) + + @foreach ($row as $cell) + + @endforeach + @for ($i = count($row); $i < 4; $i++) + + @endfor + + @endforeach +
+ QR + Code-128 +
{{ $cell['serial'] }}
+
 
+ @if ($pageIndex < count($pages) - 1) +
+ @endif +@endforeach + + diff --git a/routes/api.php b/routes/api.php index 263564b..d745a79 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,8 @@ 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\AdminQrBatchController; +use App\Http\Controllers\Api\V1\Admin\AdminQrCodeController; use App\Http\Controllers\Api\V1\Admin\AdminUserController; use App\Http\Controllers\Api\V1\Auth\ForgotPasswordController; use App\Http\Controllers\Api\V1\Auth\LoginController; @@ -24,6 +26,7 @@ use App\Http\Controllers\Api\V1\Geo\ResolveLocationController; use App\Http\Controllers\Api\V1\Geo\ServiceAreaController; use App\Http\Controllers\Api\V1\HealthController; use App\Http\Controllers\Api\V1\Household\HouseholdController; +use App\Http\Controllers\Api\V1\Qr\MyQrCodeController; use Illuminate\Support\Facades\Route; /* @@ -122,6 +125,31 @@ Route::prefix('admin/dumpsites') Route::delete('/{dumpsite}', [AdminDumpsiteController::class, 'destroy'])->name('destroy'); }); +Route::prefix('admin/qr-batches') + ->name('api.v1.admin.qr-batches.') + ->middleware(['auth:sanctum', 'role:admin']) + ->group(function () { + Route::get('/', [AdminQrBatchController::class, 'index'])->name('index'); + Route::post('/', [AdminQrBatchController::class, 'store'])->name('store'); + Route::get('/{qr_code_batch}', [AdminQrBatchController::class, 'show'])->name('show'); + Route::post('/{qr_code_batch}/mark-printed', [AdminQrBatchController::class, 'markPrinted'])->name('mark-printed'); + Route::get('/{qr_code_batch}/print.pdf', [AdminQrBatchController::class, 'printPdf'])->name('print-pdf'); + }); + +Route::prefix('admin/qr-codes') + ->name('api.v1.admin.qr-codes.') + ->middleware(['auth:sanctum', 'role:admin']) + ->group(function () { + Route::get('/{serial}', [AdminQrCodeController::class, 'show'])->name('show'); + Route::post('/{qr_code}/void', [AdminQrCodeController::class, 'void'])->name('void'); + }); + +Route::middleware('auth:sanctum')->prefix('me/qr-codes')->name('api.v1.me.qr-codes.')->group(function () { + Route::get('/', [MyQrCodeController::class, 'index'])->name('index'); + Route::get('/balance', [MyQrCodeController::class, 'balance'])->name('balance'); + Route::post('/{qr_code}/activate', [MyQrCodeController::class, 'activate'])->name('activate'); +}); + 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'); diff --git a/tests/Feature/Api/V1/Qr/MyQrCodeTest.php b/tests/Feature/Api/V1/Qr/MyQrCodeTest.php new file mode 100644 index 0000000..9d10ca7 --- /dev/null +++ b/tests/Feature/Api/V1/Qr/MyQrCodeTest.php @@ -0,0 +1,124 @@ +seed(RoleSeeder::class); + } + + public function test_balance_returns_zero_for_resident_without_household(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + Sanctum::actingAs($resident); + + $this->getJson('/api/v1/me/qr-codes/balance') + ->assertOk() + ->assertJsonPath('data.active', 0); + } + + public function test_balance_returns_counts_by_status(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $h = Household::factory()->create(['head_user_id' => $resident->id]); + $batch = app(BatchGenerator::class)->generate(5, QrCodeBatch::PURPOSE_FREE); + QrCode::query() + ->where('batch_id', $batch->id) + ->update(['assigned_to_household_id' => $h->id, 'status' => 'active']); + Sanctum::actingAs($resident); + + $this->getJson('/api/v1/me/qr-codes/balance') + ->assertOk() + ->assertJsonPath('data.active', 5) + ->assertJsonPath('data.low_balance', true); + } + + public function test_resident_can_list_own_codes(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $h = Household::factory()->create(['head_user_id' => $resident->id]); + $batch = app(BatchGenerator::class)->generate(3, QrCodeBatch::PURPOSE_FREE); + QrCode::query() + ->where('batch_id', $batch->id) + ->update(['assigned_to_household_id' => $h->id, 'status' => 'active']); + Sanctum::actingAs($resident); + + $response = $this->getJson('/api/v1/me/qr-codes'); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_resident_can_activate_an_allocated_code(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $h = Household::factory()->create(['head_user_id' => $resident->id]); + $batch = app(BatchGenerator::class)->generate(1, QrCodeBatch::PURPOSE_FREE); + $code = QrCode::query()->where('batch_id', $batch->id)->first(); + $code->forceFill([ + 'assigned_to_household_id' => $h->id, + 'status' => 'allocated', + 'allocated_at' => now(), + ])->save(); + Sanctum::actingAs($resident); + + $response = $this->postJson("/api/v1/me/qr-codes/{$code->serial}/activate"); + + $response->assertOk() + ->assertJsonPath('data.status', 'active'); + $this->assertNotNull($code->fresh()->activated_at); + } + + public function test_resident_cannot_activate_other_household_code(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $other = User::factory()->create(['role' => User::ROLE_RESIDENT]); + $otherHousehold = Household::factory()->create(['head_user_id' => $other->id]); + $batch = app(BatchGenerator::class)->generate(1, QrCodeBatch::PURPOSE_FREE); + $code = QrCode::query()->where('batch_id', $batch->id)->first(); + $code->forceFill([ + 'assigned_to_household_id' => $otherHousehold->id, + 'status' => 'allocated', + ])->save(); + Sanctum::actingAs($resident); + + $this->postJson("/api/v1/me/qr-codes/{$code->serial}/activate") + ->assertStatus(403); + } + + public function test_low_balance_event_dispatched_when_count_below_threshold(): void + { + Event::fake([QrBalanceLow::class]); + + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $h = Household::factory()->create(['head_user_id' => $resident->id]); + + $batch = app(BatchGenerator::class)->generate(3, QrCodeBatch::PURPOSE_FREE); + QrCode::query()->where('batch_id', $batch->id) + ->update(['assigned_to_household_id' => $h->id, 'status' => 'active']); + + app(QrAllocator::class)->notifyBalanceIfLow($h); + + Event::assertDispatched(QrBalanceLow::class, fn ($e) => + $e->household->id === $h->id && $e->activeCount === 3, + ); + } +} diff --git a/tests/Feature/Api/V1/Qr/QrBatchTest.php b/tests/Feature/Api/V1/Qr/QrBatchTest.php new file mode 100644 index 0000000..c480f86 --- /dev/null +++ b/tests/Feature/Api/V1/Qr/QrBatchTest.php @@ -0,0 +1,129 @@ +seed(RoleSeeder::class); + $this->admin = User::factory()->create([ + 'role' => User::ROLE_ADMIN, + 'status' => User::STATUS_ACTIVE, + ]); + } + + public function test_admin_can_generate_batch(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson('/api/v1/admin/qr-batches', [ + 'quantity' => 24, + 'purpose' => QrCodeBatch::PURPOSE_FREE, + ]); + + $response->assertCreated() + ->assertJsonPath('data.quantity', 24) + ->assertJsonPath('data.purpose', 'free_allocation'); + + $batchNumber = $response->json('data.batch_number'); + $this->assertSame(24, QrCode::whereHas('batch', fn ($q) => $q->where('batch_number', $batchNumber))->count()); + } + + public function test_admin_can_list_batches(): void + { + Sanctum::actingAs($this->admin); + app(BatchGenerator::class)->generate(5, QrCodeBatch::PURPOSE_FREE, null, null, $this->admin); + app(BatchGenerator::class)->generate(3, QrCodeBatch::PURPOSE_PROMO, null, null, $this->admin); + + $response = $this->getJson('/api/v1/admin/qr-batches?purpose=free_allocation'); + + $response->assertOk(); + $purposes = collect($response->json('data'))->pluck('purpose')->unique(); + $this->assertEquals(['free_allocation'], $purposes->all()); + } + + public function test_admin_can_show_batch_with_status_counts(): void + { + Sanctum::actingAs($this->admin); + $batch = app(BatchGenerator::class)->generate(5, QrCodeBatch::PURPOSE_FREE); + + $response = $this->getJson("/api/v1/admin/qr-batches/{$batch->batch_number}"); + + $response->assertOk() + ->assertJsonPath('data.status_counts.unassigned', 5); + } + + public function test_admin_can_mark_batch_printed(): void + { + Sanctum::actingAs($this->admin); + $batch = app(BatchGenerator::class)->generate(2, QrCodeBatch::PURPOSE_FREE); + + $this->postJson("/api/v1/admin/qr-batches/{$batch->batch_number}/mark-printed") + ->assertOk(); + $this->assertNotNull($batch->fresh()->printed_at); + + $this->postJson("/api/v1/admin/qr-batches/{$batch->batch_number}/mark-printed") + ->assertStatus(422); + } + + public function test_print_pdf_returns_pdf(): void + { + Sanctum::actingAs($this->admin); + $batch = app(BatchGenerator::class)->generate(3, QrCodeBatch::PURPOSE_FREE); + + $response = $this->get("/api/v1/admin/qr-batches/{$batch->batch_number}/print.pdf"); + + $response->assertOk(); + $this->assertSame('application/pdf', $response->headers->get('content-type')); + } + + public function test_admin_can_void_a_code(): void + { + Sanctum::actingAs($this->admin); + $batch = app(BatchGenerator::class)->generate(1, QrCodeBatch::PURPOSE_FREE); + $code = $batch->codes()->first(); + + $response = $this->postJson("/api/v1/admin/qr-codes/{$code->serial}/void", [ + 'reason' => 'Damaged sheet', + ]); + + $response->assertOk() + ->assertJsonPath('data.status', 'voided'); + $this->assertSame('voided', (string) $code->fresh()->status); + } + + public function test_admin_can_lookup_code_by_serial(): void + { + Sanctum::actingAs($this->admin); + $batch = app(BatchGenerator::class)->generate(1, QrCodeBatch::PURPOSE_FREE); + $code = $batch->codes()->first(); + + $this->getJson("/api/v1/admin/qr-codes/{$code->serial}") + ->assertOk() + ->assertJsonPath('data.serial', $code->serial); + } + + public function test_resident_blocked_from_admin_qr_endpoints(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($resident); + + $this->postJson('/api/v1/admin/qr-batches', ['quantity' => 1, 'purpose' => 'free_allocation']) + ->assertStatus(403); + } +} diff --git a/tests/Feature/Api/V1/Qr/QrFreeAllocationTest.php b/tests/Feature/Api/V1/Qr/QrFreeAllocationTest.php new file mode 100644 index 0000000..e5951be --- /dev/null +++ b/tests/Feature/Api/V1/Qr/QrFreeAllocationTest.php @@ -0,0 +1,106 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class]); + } + + public function test_household_verified_allocates_free_codes(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN]); + $resident = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + ResidentProfile::create(['user_id' => $resident->id]); + + $bgy = Barangay::where('code', 'QC-DLM')->firstOrFail(); + $area = ServiceArea::factory()->create(); + $area->barangays()->attach($bgy->id); + + app(BatchGenerator::class)->generate(50, QrCodeBatch::PURPOSE_FREE, $area, null, $admin); + + $h = Household::factory()->create([ + 'head_user_id' => $resident->id, + 'barangay_id' => $bgy->id, + 'verification_status' => 'pending', + 'proof_of_residency_path' => 'x.jpg', + ]); + $h->markVerified($admin); + HouseholdVerified::dispatch($h->fresh(), $admin); + + $this->assertSame( + (int) config('qr.free_allocation_per_household'), + QrCode::where('assigned_to_household_id', $h->id) + ->where('status', 'active') + ->count(), + ); + } + + public function test_no_free_batch_logs_warning_does_not_crash(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN]); + $resident = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + $h = Household::factory()->create([ + 'head_user_id' => $resident->id, + 'verification_status' => 'pending', + 'proof_of_residency_path' => 'x.jpg', + ]); + $h->markVerified($admin); + + // Should not throw even though no batches exist. + HouseholdVerified::dispatch($h->fresh(), $admin); + + $this->assertSame(0, QrCode::where('assigned_to_household_id', $h->id)->count()); + } + + public function test_falls_back_to_any_free_batch_when_no_area_match(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN]); + $resident = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + + // Free batch with no targeted area. + app(BatchGenerator::class)->generate(50, QrCodeBatch::PURPOSE_FREE, null, null, $admin); + + $h = Household::factory()->create([ + 'head_user_id' => $resident->id, + 'barangay_id' => null, + 'verification_status' => 'pending', + 'proof_of_residency_path' => 'x.jpg', + ]); + $h->markVerified($admin); + HouseholdVerified::dispatch($h->fresh(), $admin); + + $this->assertSame( + (int) config('qr.free_allocation_per_household'), + QrCode::where('assigned_to_household_id', $h->id)->where('status', 'active')->count(), + ); + } +} diff --git a/tests/Feature/Api/V1/Qr/QrSerialTest.php b/tests/Feature/Api/V1/Qr/QrSerialTest.php new file mode 100644 index 0000000..f62126c --- /dev/null +++ b/tests/Feature/Api/V1/Qr/QrSerialTest.php @@ -0,0 +1,47 @@ +build('QC042', new \DateTimeImmutable('2026-04-30'), 12, 847); + + $this->assertMatchesRegularExpression( + '/^PH-QC042-2604-0012-000847-[A-Z0-9]{2}$/', + $serial, + ); + $this->assertTrue($g->isValid($serial)); + } + + public function test_invalid_checksum_rejected(): void + { + $g = new QrSerialGenerator(); + $serial = $g->build('QC042', new \DateTimeImmutable('2026-04-30'), 1, 1); + $tampered = preg_replace('/-..$/', '-XX', $serial); + + $this->assertFalse($g->isValid($tampered)); + } + + public function test_malformed_serial_rejected(): void + { + $g = new QrSerialGenerator(); + + $this->assertFalse($g->isValid('not-a-serial')); + $this->assertFalse($g->isValid('PH-QC-2604-0001-000001')); + } + + public function test_checksum_is_deterministic(): void + { + $g = new QrSerialGenerator(); + $a = $g->build('QC042', new \DateTimeImmutable('2026-04-30'), 1, 1); + $b = $g->build('QC042', new \DateTimeImmutable('2026-04-30'), 1, 1); + + $this->assertSame($a, $b); + } +}