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>
57 lines
1.5 KiB
PHP
57 lines
1.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Qr;
|
|
|
|
class QrSerialGenerator
|
|
{
|
|
/**
|
|
* PH-{area}-{YYMM}-{batch}-{code}-{checksum}
|
|
* Example: PH-QC042-2604-0012-000847-K9
|
|
*/
|
|
public function build(
|
|
string $areaCode,
|
|
\DateTimeInterface $batchDate,
|
|
int $batchSeq,
|
|
int $codeSeq,
|
|
): string {
|
|
$base = sprintf(
|
|
'PH-%s-%s-%04d-%06d',
|
|
strtoupper($areaCode),
|
|
$batchDate->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);
|
|
}
|
|
}
|