Skip to content

Multi-Carrier Tracking

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

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

No dedicated REST endpoint. Tracking data is exposed as part of the order resource:

MethodPathAction
GET/rest/order/order/{id}Order resource includes gtcode, gtstatus, gtdatetime, gtflag

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 < 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()).

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, provider grouping
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 (max 10 before giving up)

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 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: Hardcoded at gtcount < 10 (10 attempts before abandoning)
  • 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
  • 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 10 attempts: After 10 unsuccessful polls, 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