Files
Verde-Web/app/Services/Qr/QrSerialGenerator.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

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