Skip to content

Multi-Carrier Tracking ​

Flow ID: AD-33 Module(s): job, eshop, Transporters Complexity: High Last Updated: 2026-09-29

Business Overview ​

Ecommercen supports automated delivery tracking across 15 carrier integrations. A scheduled job polls each carrier's API for shipment status updates, writing normalized tracking data back to the shop_order table. The system also provides on-demand track-and-trace lookups from the admin order detail view and generates external tracking URLs for customer-facing emails and order pages.

The tracking subsystem is decoupled from voucher generation -- an order only enters the tracking pipeline once it has a gtcode (voucher/tracking number) assigned by the voucher generation flow (AD-34).

API Reference ​

REST Endpoints ​

A dedicated customer tracking endpoint exists (#835), and tracking data is also exposed as part of the order resource:

MethodPathAction
GET/rest/order/order/{id}/tracking (locale variant /{lang}/rest/order/order/{id}/tracking)Order::tracking() — auth customer; returns 404 unless the order belongs to the caller
GET/rest/order/order/{id}Order resource includes gtcode, gtstatus, gtdatetime, gtflag (raw DB column names)

The tracking route is declared at application/config/rest_routes.php:1417 (locale variant :1422), handled by src/Rest/Order/Controllers/Order.php:323, with the ownership check at Order.php:327 and the customer policy at application/config/rest_policies.php:932. Response fields (Order.php:332-345): orderId, orderSerial, status, trackingCode, trackingUrl, trackingStatus, trackingLastUpdate, trackingFlag, trackingCount, transportId, shippedAt, createdAt. Note the generic order resource uses the DB column names (gtcode, gtstatus, ...), while this endpoint maps them to the tracking* names.

Admin Endpoints ​

RouteController MethodDescription
CLI: php cli.php job/AdvGetOrdersTransferStatusAdvGetOrdersTransferStatus::executeCommand()Scheduled bulk tracking poll (legacy, active)
CLI: php cli.php job/Advisable\Domains\Transporter\Jobs\GetOrdersTransferStatusGetOrdersTransferStatus::executeCommand()Modern port — same poll, FQCN-invoked (toggle-activate)
Admin order detailAdv_orders_admin (inline)On-demand single-order track-and-trace

Code Flow ​

Automated Tracking Poll (Cron) ​

AdvGetOrdersTransferStatus::executeCommand()
  |
  +--> Query shop_order WHERE gtcode != '' AND status NOT IN (PENDING, CANCELED, RETURN)
  |    AND gtflag = 0 AND gtcount < {cap}     (cap = TRANSPORTERS/MAX_POLL_COUNT, default 10)
  |
  +--> getOrdersByTransferProviderId() -- group orders by transport_id
  |
  +--> For each provider:
       |
       +--> Load provider master record + settings from transporters_model
       +--> Switch on provider->class_name:
            |
            +--> Instantiate carrier SDK (e.g., new Acs(new AcsConfig($settings)))
            +--> Call trackAndTrace() / tracking() / getTracking() per order
            +--> Normalize response to: gtstatus, gtdatetime, gtflag
            +--> UPDATE shop_order SET gtstatus, gtdatetime, gtflag, gtcount+1

On-Demand Track & Trace ​

AdvTrackAndTrace::track($provider, $providerSettings, $voucher)
  |
  +--> Switch on provider->class_name
  +--> Instantiate carrier SDK
  +--> Call trackAndTrace() / tracking() / getTracking()
  +--> Return raw carrier response object

External Tracking URLs ​

getLinkForTransferProvider($provider, $gtCode)    -- full tracking URL for customer
getMinifiedLinkForTransferProvider($provider)     -- shortened base URL

Each carrier SDK has a static externalTrackUrl($gtCode) method in its Helper class (e.g., AcsHelper::externalTrackUrl()). The class_name → helper map is Advisable\Domains\Transporter\TrackingUrl\Resolver::HELPERS (17 entries, src/Domains/Transporter/TrackingUrl/Resolver.php:50-68); both legacy helpers delegate to Resolver::helperFor() (ecommercen/helpers/transporters_helper.php:94-97,116-119, Resolver.php:81-88), keeping only a FIS slug fallback.

trackingUrl in the REST response (#835). GET /rest/order/order/{id}/tracking computes trackingUrl via Resolver::resolve($transportId, $voucher, $orderStatus) (src/Rest/Order/Controllers/Order.php:337; Resolver.php:111-137). It is null unless all of the following hold:

  • the voucher matches VOUCHER_PATTERN — letters, digits and hyphens, 1-64 characters (Resolver.php:44,114);
  • the order status is in TRACKABLE_ORDER_STATUSES = SENT, PAID_SENT, INVOICED (Resolver.php:38,118-120);
  • the transporter exists (active or not — historical orders ride deactivated carriers) and its helper embeds the voucher in the URL (embedsVoucher(), Resolver.php:96-102, :127-135). Landing-page-only carriers, whose helper returns a fixed URL, yield null.

The URL is built with rawurlencode($voucher) passed to the helper's externalTrackUrl() (Resolver.php:137).

Domain Layer ​

Key Files ​

FileResponsibility
ecommercen/job/libraries/AdvGetOrdersTransferStatus.phpScheduled job (legacy, active) -- polls all carriers
src/Domains/Transporter/Jobs/GetOrdersTransferStatus.phpModern port -- same 15-carrier logic, DI-injected TransporterSettingsLoader; FQCN-registered; toggle-activated when ready
ecommercen/libraries/vouchers/AdvTrackAndTrace.phpOn-demand single-voucher tracking dispatcher
ecommercen/helpers/transporters_helper.phpExternal tracking URL generators (delegating to Resolver::helperFor(), :94-97,116-119), provider grouping
src/Domains/Transporter/TrackingUrl/Resolver.phpOwns the class_name → helper map (HELPERS, :50-68) and resolves the REST trackingUrl (resolve(), :111-137)
src/Transporters/{Carrier}/{Carrier}.phpCarrier SDK client (API calls)
src/Transporters/{Carrier}/{Carrier}Config.phpCarrier-specific configuration DTO
src/Transporters/{Carrier}/{Carrier}Helper.phpStatic helpers (external URLs, utilities)

Supported Carriers (15) ​

class_nameCarrierSDK ClassTrack Method
GTGeniki v1GenikitrackAndTrace()
GTV2Geniki v2GenikiV2trackAndTrace()
ACSACS (REST)AcstrackAndTrace()
ACSSoapACS (SOAP)AcsSoaptrackAndTrace()
ELTAELTA CourierEltatrackAndTrace()
SPEEDEXSpeedexSpeedexgetLastCheckpoint()
CENTERCenterCentertracking()
EASYMAILEasyMailEasyMailtrackAndTrace()
BOXNOWBoxNowBoxNowtrackAndTrace()
CYPRUSPOSTCyprus PostCyprusPosttrackAndTrace()
DHLDHL ExpressDhlgetTracking()
TAXYDEMATaxydema v1TaxydematrackAndTrace()
TAXYDEMAV2Taxydema v2TaxydemaV2tracking()
SKROUTZSkroutz Last MileSkroutztrackAndTrace()
ASAPASAP CourierAsapgetFulfillmentEventData() (disabled -- needs testing)

Architecture ​

ecommercen/job/libraries/AdvGetOrdersTransferStatus.php  -- legacy cron entry (active)
src/Domains/Transporter/Jobs/GetOrdersTransferStatus.php -- modern port (toggle-activate via jobs.php)
ecommercen/libraries/vouchers/AdvTrackAndTrace.php        -- admin on-demand
ecommercen/helpers/transporters_helper.php                -- URL helpers
src/Transporters/
  ├── {Carrier}/
  │   ├── {Carrier}.php           -- API client
  │   ├── {Carrier}Config.php     -- Config DTO (from transporters_settings)
  │   └── {Carrier}Helper.php     -- Static helpers, externalTrackUrl()
  ├── BaseTransporterConfig.php   -- Shared config base
  └── TransporterConfigInterface.php

All carrier SDKs live under the Advisable\Transporters namespace. Each carrier follows the pattern: API client class + Config DTO + Helper class. Configs are populated from the transporters_settings key-value table, loaded via transporters_model::settings($providerId).

Data Model ​

shop_order (tracking columns) ​

ColumnTypeDescription
transport_idint(11)FK to transporters master table
gtcodevarchar(30)Carrier tracking/voucher number
gtjobcodevarchar(255)Carrier job ID (for cancellation)
gtstatusvarchar(255)Latest tracking status (carrier-specific text)
gtflagtinyint(1)1 = delivered (terminal state), 0 = in transit
gtdatetimedatetimeTimestamp of last status update
gtcountint(3)Number of poll attempts (cap = TRANSPORTERS/MAX_POLL_COUNT registry key, default 10)

Key index: gtcode_gtflag_gtcount -- optimizes the cron job's SELECT query.

Tracking State Machine ​

gtcode assigned (voucher created)
  └── gtflag=0, gtcount=0
       └── Cron polls carrier API
            ├── Status update → gtstatus='{carrier status}', gtcount++
            ├── Delivered → gtstatus='ΠΑΡΑΔΟΜΕΝΟ', gtflag=1 (stops polling)
            └── gtcount reaches the cap (default 10) → stops polling (abandoned)

Configuration ​

  • Cron schedule: Configured as a scheduled job via AdvGetOrdersTransferStatus (25 4 * * * — daily at 04:25, per application/config/jobs.php). The modern port ships with a commented-out toggle entry alongside the legacy one; operators flip the leading // to switch from the legacy job to the modern Transporter-domain job (the same convention as PollPayByBankStatus, SY-16).
  • Provider settings: Stored in transporters_settings table, managed via Adv_transporters_admin (AD-06)
  • Poll limit: gtcount < TRANSPORTERS/MAX_POLL_COUNT — a DB registry key (default 10), configurable per deployment without code changes (Advisable-com/ecommercen#398). Read by both the legacy AdvGetOrdersTransferStatus and the modern GetOrdersTransferStatus jobs (cast to int, with a < 1 → 10 fallback at each site), and by the admin order-list gtstatus filter in Adv_order_model::ordersListAdminWhere() (which uses it both as gtcount < N for "in transit" and gtcount = N for "poll exhausted"). Seeded by migration 20260629122231_add_transporters_max_poll_count_registry_key.php.
  • Rate limiting: 250ms delay (usleep(250000)) between provider batches

Client Extension Points ​

  • Custom carrier: Add a new class under src/Transporters/, implement trackAndTrace(), and add a case to each voucher library switch statement. Also register the carrier's Helper class in TrackingUrl\Resolver::HELPERS (src/Domains/Transporter/TrackingUrl/Resolver.php:50-68) so tracking URLs resolve
  • Client-repo override: The legacy AdvGetOrdersTransferStatus job can be overridden via application/modules/job/libraries/GetOrdersTransferStatus.php (an empty subclass by default). The modern port is overridden via a DI alias in custom/Domains/container.php (the PollPayByBankStatus pattern, SY-16).

Business Rules ​

  1. Only active orders are tracked: Orders with status PENDING, CANCELED, or RETURN are excluded from polling
  2. Terminal state is delivery: When gtflag is set to 1 (delivered), the order exits the tracking pipeline permanently
  3. Maximum attempts (default 10, configurable): After TRANSPORTERS/MAX_POLL_COUNT unsuccessful polls (default 10), the system stops checking -- manual intervention required
  4. Normalized delivery status: Most carriers normalize delivery status to the Greek string ΠΑΡΑΔΟΜΕΝΟ. Exceptions: GenikiV2 writes the carrier's raw English status (e.g. 'DELIVERED') while still setting gtflag = 1
  5. Speedex uses final status codes: Unlike other carriers that check string status, Speedex uses numeric finalVoucherStatusCodes (success + failure codes)
  6. ASAP tracking is disabled: The getAsapTransporterStatus() method contains an early return -- needs real data testing before activation

Known Issues & Security Gaps ​

Fixed in #275 ​

Three pre-existing defensive-coding gaps in the tracking poller, fixed in both the legacy job and the modern port under Advisable-com/ecommercen#275 (the #274 port carried them over verbatim; #275 fixed both copies together).

  1. Null $provider triggers a recurring warning. switch ($provider->class_name) (ecommercen/job/libraries/AdvGetOrdersTransferStatus.php, port src/Domains/Transporter/Jobs/GetOrdersTransferStatus.php) assumed getMasterRecord() returns a row. An orphaned transporters_settings row (settings present, master record deleted) passed the if (!$providerSettings) continue; guard but yielded $provider === null, raising an Attempt to read property "class_name" on null warning under PHP 8.1 (non-fatal — switch(null) then matches no case and the provider is skipped, so the run continues, but the warning recurs every poll for every orphaned row). Fix: the guard is now if (!$provider || !$providerSettings) continue;. Regression-guarded by tests/Integration/Domains/Transporter/Jobs/GetOrdersTransferStatusTest.php.

  2. array_pop() on single-checkpoint responses. array_pop($track->Checkpoints->Checkpoint) (Center, EasyMail, CyprusPost, TaxydemaV2) requires an array passed by reference. When a carrier collapsed a single checkpoint to a scalar/object, the truthy guard still passed but array_pop raised a TypeError/warning and returned null, then $lastCheckpoint->Status dereferenced null. Fix: normalize first — $checkpoints = $track->Checkpoints->Checkpoint; $lastCheckpoint = is_array($checkpoints) ? end($checkpoints) : $checkpoints;.

  3. Undefined-array-key warning on unmapped status. $statusLabels[$track->Status] / $statusLabels[$track->status_description] (BoxNow, Skroutz) — an unmapped carrier status raised an "Undefined array key" warning and evaluated to null. Fix: $statusLabels[$key] ?? null — the null-coalescing operator suppresses the warning while persisting the same NULL value as before, so nothing downstream changes.

Stale class docblock ​

  1. GetOrdersTransferStatus class docblock says "14 carrier gateways" but there are 15 (src/Domains/Transporter/Jobs/GetOrdersTransferStatus.php:47). The switch block handles GT, GTV2, ACS, ACSSoap, ELTA, SPEEDEX, CENTER, EASYMAIL, BOXNOW, CYPRUSPOST, DHL, TAXYDEMA, SKROUTZ, ASAP, and TAXYDEMAV2 — 15 cases. The "14" comment is a stale copy-paste error from before TAXYDEMAV2 was added.

Fixed in this doc-ba-resync pass ​

  1. Speedex 'succcess' typo caused delivered orders to never set gtflag = 1. getSpeedexTransporterStatus() accessed $speedexConfig->finalVoucherStatusCodes['succcess'] (triple 'c') but SpeedexConfig::finalVoucherStatusCodes defines the key as 'success'. The typo made the success-code sub-array null, so array_merge(null, $failureCodes) produced only failure codes — in_array($lastCheckpoint->StatusCode, $finalVoucherStatusCodes) could never match a successful-delivery code, meaning gtflag was never set to 1 for delivered Speedex orders, wasting 10 poll cycles per order. Fixed in both ecommercen/job/libraries/AdvGetOrdersTransferStatus.php:301 and src/Domains/Transporter/Jobs/GetOrdersTransferStatus.php:322. Tracked as Advisable-com/ecommercen#277.

Fixed in #276 ​

  1. Loyalty-points job skipped delivered orders when gtstatus != 'ΠΑΡΑΔΟΜΕΝΟ'. AdvAddPointsToCustomerDeliver previously matched delivered orders by WHERE gtstatus = 'ΠΑΡΑΔΟΜΕΝΟ' without also checking gtflag = 1. GenikiV2 deliveries returned the English 'DELIVERED' status (gtflag = 1, gtstatus = 'DELIVERED'), so the loyalty job's string filter skipped them. Fixed on branch fix/loyalty-gtflag-276: the job now matches on WHERE gtflag = 1, correctly including GenikiV2 English-status delivered orders. Tracked in Advisable-com/ecommercen#276.

Open ​

No open issues at the time of writing.

Tests ​

SuiteFile / MethodWhat it covers
Integrationtests/Integration/Domains/Transporter/Jobs/GetOrdersTransferStatusTest.php::test_orphaned_provider_settings_do_not_abort_the_pollRegression-guards the null-provider fix (#275 item 1): seeds an orphaned transporters_settings row with no matching transporters master record, installs a strict E_WARNING→exception handler, and asserts executeCommand() completes without warning and leaves the order's gtcount unchanged.
Integration…::test_speedex_final_voucher_status_codes_merge_does_not_warnRegression-guards the Speedex typo fix (#277): invokes getSpeedexTransporterStatus() via reflection with empty $orders so the array_merge($success, $failure) line runs without making a SOAP call; a strict E_WARNING handler fails the test if anyone re-introduces 'succcess'.

The single-checkpoint normalization (#275 item 2) and the unmapped-status null-coalescing (#275 item 3) are verified by php -l and code inspection — they live inside carrier methods that instantiate live-API gateway classes, making them unsuitable for unit tests without a refactor.

Wiki Guide: DHL Integration Guide -- specific DHL Express setup and configuration