Files
Verde-Web/app/Services/Qr/BatchGenerator.php
admin d458e74301 feat(backend): complete Module 7 — QR code system (core)
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) <noreply@anthropic.com>
2026-04-30 02:12:41 +08:00

87 lines
2.8 KiB
PHP

<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Models\QrCodeBatch;
use App\Models\ServiceArea;
use App\Models\User;
use App\States\QrCode\Unassigned;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class BatchGenerator
{
public function __construct(private readonly QrSerialGenerator $serials) {}
/**
* Generate a new batch with $quantity codes in the unassigned state.
*/
public function generate(
int $quantity,
string $purpose,
?ServiceArea $targetArea = null,
?int $targetStoreId = null,
?User $createdBy = null,
?\DateTimeInterface $expiresAt = null,
?string $notes = null,
): QrCodeBatch {
$now = Carbon::now();
$areaCode = $targetArea?->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;
}
}