diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 14cd0a4f5..2bbe6420d 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -1984,7 +1984,7 @@ protected function normalizeTrackingLookup($input): ?string $input = trim($input); - return preg_match('/^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$/', $input) === 1 ? $input : null; + return preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9._\-]| (?=[A-Za-z0-9._\-])){0,99}$/', $input) === 1 ? $input : null; } /** diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index a5c9f0c77..4f23fe95d 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -184,15 +184,47 @@ public function getTypeAttribute() */ public static function generateTrackingNumber($region = 'SG', $length = 10): string { - $company = \Fleetbase\Models\Company::where('uuid', session('company'))->withoutGlobalScopes()->first(); - $companyName = $company ? strtoupper(substr($company->name, 0, 3)) : null; - $number = $companyName ?? 'FLB'; + $company = \Fleetbase\Models\Company::where('uuid', session('company'))->withoutGlobalScopes()->first(); + $number = static::trackingNumberPrefix($company ? $company->name : null); for ($i = 0; $i < $length; $i++) { $number .= mt_rand(0, 9); } - return $number . strtoupper($region); + return $number . static::trackingNumberSuffix($region); + } + + /** + * The tracking number prefix for a company: the first three letters or + * digits of its name, upper-cased, or FLB when it has none. Spaces and + * punctuation are skipped, so "Al Noor Transport" gives ALN. + */ + public static function trackingNumberPrefix(?string $companyName): string + { + $prefix = substr(static::trackingNumberCharacters($companyName), 0, 3); + + return $prefix !== '' ? $prefix : 'FLB'; + } + + /** + * The tracking number suffix for a region: its letters and digits, + * upper-cased, or SG when it has none. A region can be a province or + * district name when the pickup has no country ("New York" gives NEWYORK). + */ + public static function trackingNumberSuffix(?string $region): string + { + $suffix = static::trackingNumberCharacters($region); + + return $suffix !== '' ? $suffix : 'SG'; + } + + /** + * The ASCII letters and digits of a value, upper-cased. A tracking number + * holds nothing else, so it can always be typed, scanned and looked up. + */ + protected static function trackingNumberCharacters(?string $value): string + { + return (string) preg_replace('/[^A-Z0-9]/', '', strtoupper((string) $value)); } /** diff --git a/server/src/Support/TrackingPage/TrackingResolver.php b/server/src/Support/TrackingPage/TrackingResolver.php index 09b3091fd..927116e0f 100644 --- a/server/src/Support/TrackingPage/TrackingResolver.php +++ b/server/src/Support/TrackingPage/TrackingResolver.php @@ -22,7 +22,7 @@ */ class TrackingResolver { - public const NUMBER_PATTERN = '/^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$/'; + public const NUMBER_PATTERN = '/^[A-Za-z0-9](?:[A-Za-z0-9._\-]| (?=[A-Za-z0-9._\-])){0,99}$/'; /** * The tracking number as typed, trimmed, or null when it can't be one. diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index 9b5e62f19..ff183cbe5 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -1651,6 +1651,14 @@ function fleetopsSuppressStrNullDeprecations(): Closure expect($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe($notFound) ->and($controller->trackingLookups)->toBe(['TN-404']); + // Numbers generated for companies with a space in the first three letters of + // their name ("AL 7796486490AE") reach the query; other spacing does not. + foreach (['AL 1', "AL\t1", "AL\n1", 'AL&1'] as $tracking) { + expect($controller->lookup(new Request(['tracking' => $tracking]))->getData(true))->toBe($notFound); + } + expect($controller->lookup(new Request(['tracking' => 'AL 7796486490AE']))->getData(true))->toBe($notFound) + ->and($controller->trackingLookups)->toBe(['TN-404', 'AL 7796486490AE']); + $trackedOrder = fleetopsInternalOrderLifecycleOrder('tracked-order'); $trackedOrder->trackerForTest = new FleetOpsInternalOrderLifecycleTrackerFake($trackedOrder); $controller->trackingOrder = $trackedOrder; diff --git a/server/tests/Unit/Models/TrackingNumberTest.php b/server/tests/Unit/Models/TrackingNumberTest.php index c0084249d..0dbd70f61 100644 --- a/server/tests/Unit/Models/TrackingNumberTest.php +++ b/server/tests/Unit/Models/TrackingNumberTest.php @@ -258,3 +258,36 @@ public function getBarcodePNG(...$arguments) } } }); + +test('tracking number prefixes keep only letters and digits from the company name', function () { + expect(TrackingNumber::trackingNumberPrefix('Nordic Freight'))->toBe('NOR') + ->and(TrackingNumber::trackingNumberPrefix('Al Noor Transport'))->toBe('ALN') + ->and(TrackingNumber::trackingNumberPrefix('A&B Logistics'))->toBe('ABL') + ->and(TrackingNumber::trackingNumberPrefix("O'Brien Couriers"))->toBe('OBR') + ->and(TrackingNumber::trackingNumberPrefix('3PL Hub'))->toBe('3PL') + ->and(TrackingNumber::trackingNumberPrefix('Ab'))->toBe('AB') + ->and(TrackingNumber::trackingNumberPrefix('Café Express'))->toBe('CAF') + ->and(TrackingNumber::trackingNumberPrefix('النور'))->toBe('FLB') + ->and(TrackingNumber::trackingNumberPrefix(' '))->toBe('FLB') + ->and(TrackingNumber::trackingNumberPrefix(null))->toBe('FLB'); +}); + +test('tracking number suffixes keep only letters and digits from the region', function () { + expect(TrackingNumber::trackingNumberSuffix('sg'))->toBe('SG') + ->and(TrackingNumber::trackingNumberSuffix('New York'))->toBe('NEWYORK') + ->and(TrackingNumber::trackingNumberSuffix(' AE '))->toBe('AE') + ->and(TrackingNumber::trackingNumberSuffix('Île-de-France'))->toBe('LEDEFRANCE') + ->and(TrackingNumber::trackingNumberSuffix('القاهرة'))->toBe('SG') + ->and(TrackingNumber::trackingNumberSuffix(''))->toBe('SG') + ->and(TrackingNumber::trackingNumberSuffix(null))->toBe('SG'); +}); + +test('generated tracking numbers hold only letters and digits whatever the company name and region', function () { + session(['company' => null]); + + foreach (['New York', 'Abu Dhabi', 'ae', "Côte d'Ivoire", ''] as $region) { + expect(TrackingNumber::generateTrackingNumber($region, 10))->toMatch('/^[A-Z0-9]+$/'); + } + + expect(TrackingNumber::generateTrackingNumber('New York', 4))->toMatch('/^FLB\d{4}NEWYORK$/'); +}); diff --git a/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php b/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php index 40f2ee855..73ecd83e1 100644 --- a/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php +++ b/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php @@ -80,6 +80,26 @@ function trackingResolverSetting($db, string $key, mixed $value): void ->and((new TrackingResolver())->resolve('../etc'))->toBeNull(); }); +test('tracking numbers may hold single inner spaces, as generated prefixes can', function () { + // generateTrackingNumber() takes the first three letters of the company name, + // so a company called "Al Noor" issues numbers like "AL 7796486490AE". + $matches = fn (string $input) => preg_match(TrackingResolver::NUMBER_PATTERN, $input) === 1; + + expect(TrackingResolver::normalize('AL 7796486490AE'))->toBe('AL 7796486490AE') + ->and(TrackingResolver::normalize('ABC1234567890AE'))->toBe('ABC1234567890AE') + ->and(TrackingResolver::normalize(' AL 7796486490AE '))->toBe('AL 7796486490AE') + ->and($matches(' AL1'))->toBeFalse() + ->and($matches('AL1 '))->toBeFalse() + ->and(TrackingResolver::normalize('AL 1'))->toBeNull() + ->and(TrackingResolver::normalize("AL'1"))->toBeNull() + ->and(TrackingResolver::normalize('AL&1'))->toBeNull() + ->and(TrackingResolver::normalize("AL\t1"))->toBeNull() + ->and(TrackingResolver::normalize("AL\n1"))->toBeNull() + ->and(TrackingResolver::normalize('AL 1 2'))->toBe('AL 1 2') + ->and(TrackingResolver::normalize(str_repeat('A B', 33) . 'A'))->toBe(str_repeat('A B', 33) . 'A') + ->and(TrackingResolver::normalize(str_repeat('A B', 33) . 'AB'))->toBeNull(); +}); + test('each kind of tracking number opens its order for the right customer', function () { trackingResolverFixture(); $resolver = new TrackingResolver();