From 7db38fa2a39733091bc1bac101f9839d7e683866 Mon Sep 17 00:00:00 2001 From: Shivanath Devinarayanan Date: Fri, 23 Oct 2026 01:50:14 +0400 Subject: [PATCH 1/3] fix(tracking): accept single inner spaces in tracking number lookups Tracking numbers are generated with the first three characters of the company name as their prefix, so a company named "Al Noor" issues numbers like "AL 7796486490AE". The lookup pattern added in #352 has no space in its character class, so the public tracking page and fleet-ops/lookup now answer "not found" for those numbers. Allow a space only when it sits between two allowed characters. Leading, trailing and repeated spaces, tabs, newlines, quotes and other symbols are still refused, and the 100 character limit is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/v1/OrderController.php | 2 +- .../Support/TrackingPage/TrackingResolver.php | 2 +- .../Internal/OrderControllerContractsTest.php | 8 ++++++++ .../TrackingPage/TrackingResolverTest.php | 20 +++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) 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/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/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(); From 4ee3a825961bfe7f2af3e7d6167573009da5819d Mon Sep 17 00:00:00 2001 From: Shivanath Devinarayanan Date: Fri, 23 Oct 2026 01:50:50 +0400 Subject: [PATCH 2/3] fix(tracking): keep only letters and digits in the generated number prefix generateTrackingNumber() takes the first three characters of the company name as they are, so names such as "Al Noor" or "A&B Logistics" produce numbers containing a space or "&", and names in non-Latin scripts produce partial multibyte characters. Keep only A-Z and 0-9 from those three characters and fall back to FLB when nothing is left. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/src/Models/TrackingNumber.php | 16 +++++++++++++--- server/tests/Unit/Models/TrackingNumberTest.php | 10 ++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) 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/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'); +}); From fdaa94a8b21ef4e1e2c9e0478e323ca405b7d3a2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 11:44:32 +0800 Subject: [PATCH 3/3] fix(tracking): generate tracking numbers from letters and digits only Two parts of a generated number could carry a space or punctuation: - The prefix took the first three characters of the company name, then dropped what was not a letter or digit, so "Al Noor Transport" gave AL and "A&B Logistics" gave AB. It now skips those characters first and takes the first three letters or digits (ALN, ABL), falling back to FLB. - The suffix appended the region as given. The region falls back to the pickup's province or district when it has no country, and POST /v1/tracking-numbers accepts any string, so a pickup in "New York" issued ...NEW YORK. It now keeps only letters and digits, falling back to SG. Numbers already issued keep working through the lookup change in this PR. --- server/src/Models/TrackingNumber.php | 30 ++++++++++++++++--- .../tests/Unit/Models/TrackingNumberTest.php | 27 +++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index eadb881ea..4f23fe95d 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -191,20 +191,42 @@ public static function generateTrackingNumber($region = 'SG', $length = 10): str $number .= mt_rand(0, 9); } - return $number . strtoupper($region); + return $number . static::trackingNumberSuffix($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. + * 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 = (string) preg_replace('/[^A-Z0-9]/', '', strtoupper(substr((string) $companyName, 0, 3))); + $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)); + } + /** * Generates a unique fleetbase tracking number. * diff --git a/server/tests/Unit/Models/TrackingNumberTest.php b/server/tests/Unit/Models/TrackingNumberTest.php index 6722729c7..0dbd70f61 100644 --- a/server/tests/Unit/Models/TrackingNumberTest.php +++ b/server/tests/Unit/Models/TrackingNumberTest.php @@ -261,10 +261,33 @@ 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('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$/'); +});