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..eadb881ea 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -184,9 +184,8 @@ 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); @@ -195,6 +194,17 @@ public static function generateTrackingNumber($region = 'SG', $length = 10): str return $number . strtoupper($region); } + /** + * The tracking number prefix for a company: the letters and digits among the + * first three characters of its name, upper-cased, or FLB when none are left. + */ + public static function trackingNumberPrefix(?string $companyName): string + { + $prefix = (string) preg_replace('/[^A-Z0-9]/', '', strtoupper(substr((string) $companyName, 0, 3))); + + return $prefix !== '' ? $prefix : 'FLB'; + } + /** * Generates a unique fleetbase tracking number. * 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..6722729c7 100644 --- a/server/tests/Unit/Models/TrackingNumberTest.php +++ b/server/tests/Unit/Models/TrackingNumberTest.php @@ -258,3 +258,13 @@ 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('AL') + ->and(TrackingNumber::trackingNumberPrefix('A&B Logistics'))->toBe('AB') + ->and(TrackingNumber::trackingNumberPrefix('3PL Hub'))->toBe('3PL') + ->and(TrackingNumber::trackingNumberPrefix('النور'))->toBe('FLB') + ->and(TrackingNumber::trackingNumberPrefix(' '))->toBe('FLB') + ->and(TrackingNumber::trackingNumberPrefix(null))->toBe('FLB'); +}); 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();