Compare commits

...
4 Commits
17 changed files with 178 additions and 48 deletions
+38
View File
@@ -4,6 +4,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.30.0] - 2026-09-30
### Added
- Manual vouchers (an integrated carrier's voucher typed in by hand) can be
edited and tracked by hand from the order page: "Edit number" (the number
stays required) and "Add tracking update" now show for them, as they do
for manual carriers. An In Transit / Collected update dispatches the
order and sends the "on its way" email; Delivered marks it delivered and
stops tracking polls for it. The carrier's own checkpoints keep coming in
alongside.
- A shipping method's "Tracking URL" can now be set for every driver, not
only `manual`. For an integrated carrier it's copied onto vouchers added
with "Add manual voucher", so they get the "Carrier tracking" link in the
admin and a "Track your parcel" link in the dispatched email.
API-created shipments still send customers to the order page. Vouchers
added before this have no link.
- The customer's delivery instructions are sent to the carrier: ELTA's
remarks (ΠΑΡΑΤΗΡΗΣΕΙΣ, one line, 90 characters) and ACS's
`Delivery_Notes`. ELTA labels print them in the ΠΑΡΑΤΗΡΗΣΕΙΣ box (up to 3
lines on A4, 2 on A6 and the payment stub).
### Changed
- Tracking polls run one queued job per shipment: `PollShipmentTrackingJob`
now only finds the shipments to poll and dispatches a
`RefreshShipmentTrackingJob` for each as a batch (`allowFailures`). A
slow or failing carrier call only affects its own job, with its own 45s
timeout; a failed one is retried by the next poll, five minutes later.
Needs the `job_batches` table (Laravel's default jobs migration).
- `PollShipmentTrackingJob` is unique, so a scheduled run is dropped while
the previous one is still pending. `RefreshShipmentTrackingJob` is unique
per shipment, so the same shipment is never refreshed twice at once,
which could record a checkpoint twice and fire its emails twice.
### Fixed
- "Add manual voucher" and a manual carrier's "Create Shipment" failed
with an SQL error ("missing FROM-clause entry for table lunar_orders")
whenever a voucher number was entered: their unique check on the
shipments table also excluded the current order's id.
## [0.29.3] - 2026-09-30 ## [0.29.3] - 2026-09-30
### Fixed ### Fixed
- Queued shipping jobs did nothing: `PollShipmentTrackingJob` and - Queued shipping jobs did nothing: `PollShipmentTrackingJob` and
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.29.3", "version": "0.30.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.29.3", "version": "0.30.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.", "description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
@@ -206,6 +206,10 @@
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}} {{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;"> <div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div> <div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
{{-- sxolia_1/2 (textbox49/50) --}}
@foreach (array_slice($sxolia, 0, 2) as $i => $line)
<div style="position:absolute;top:{{ 0.24 + $i * 0.26 }}cm;left:0.10cm;width:5.62cm;font-size:6pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div> </div>
@if ($multiPiece) @if ($multiPiece)
@@ -139,6 +139,10 @@
<div class="mc-box" style="top:4.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;"> <div class="mc-box" style="top:4.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div> <div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
{{-- sxolia_1..3 (textbox92/95/94) --}}
@foreach ($sxolia as $i => $line)
<div style="position:absolute;top:{{ 0.30 + $i * 0.31 }}cm;left:0.10cm;width:4.18cm;font-size:7pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div> </div>
{{-- Right-hand signature / print-timestamp column, differs by copy type --}} {{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@@ -73,6 +73,11 @@
<div class="mc-box" style="top:2.45cm;left:6.06cm;width:4.71cm;height:1.17cm;border-bottom:none;"> <div class="mc-box" style="top:2.45cm;left:6.06cm;width:4.71cm;height:1.17cm;border-bottom:none;">
<div style="position:absolute;top:0.02cm;left:0.16cm;font-size:6pt;">Συν.Χρέωσης (€):</div> <div style="position:absolute;top:0.02cm;left:0.16cm;font-size:6pt;">Συν.Χρέωσης (€):</div>
<div style="position:absolute;top:0.30cm;left:0.16cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div> <div style="position:absolute;top:0.30cm;left:0.16cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
{{-- sxolia_1..3 (textbox91/132/133) sit under this heading in the RDLC;
this stub's boxes are shorter than the RDLC's, so only 2 lines fit. --}}
@foreach (array_slice($sxolia, 0, 2) as $i => $line)
<div style="position:absolute;top:{{ 0.50 + $i * 0.16 }}cm;left:0.16cm;width:4.47cm;font-size:5pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div> </div>
<div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;"> <div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;">
@@ -57,7 +57,7 @@ class OrderDispatchedNotification extends BaseNotification
'reference' => $reference, 'reference' => $reference,
// Active, numbered, non-return shipments: the voucher numbers // Active, numbered, non-return shipments: the voucher numbers
// to show, plus the courier's link for manual carriers // to show, plus the courier's link for manual carriers
// (Shipment::trackingUrl()). Integrated carriers' history is // and typed-in vouchers (Shipment::trackingUrl()). API-created shipments' history is
// on the customer's order page. // on the customer's order page.
'shipments' => $order->shipments()->get() 'shipments' => $order->shipments()->get()
->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference)) ->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference))
@@ -118,6 +118,12 @@ class OrderFulfillmentService
'meta' => [ 'meta' => [
'services' => array_map(fn (ExtraService $service) => $service->value, $services), 'services' => array_map(fn (ExtraService $service) => $service->value, $services),
'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null, 'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null,
// Copied like ManualFulfillmentService does, so the link
// keeps working if the method is edited later.
'tracking_url' => ShippingMethod::where('driver', $carrier)->get()
->map(fn (ShippingMethod $method) => $method->data['tracking_url'] ?? null)
->filter()
->first(),
], ],
]); ]);
@@ -72,6 +72,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtr
'Reference_Key1' => (string) $order->reference, 'Reference_Key1' => (string) $order->reference,
]; ];
// The customer's delivery instructions, for the courier.
if ($instructions = trim(preg_replace('/\s+/u', ' ', (string) $address->delivery_instructions))) {
$params['Delivery_Notes'] = $instructions;
}
$codAmount = $request->paymentMode === 'cod' $codAmount = $request->paymentMode === 'cod'
? (float) ($request->amountToCollect ?? $order->total->decimal) ? (float) ($request->amountToCollect ?? $order->total->decimal)
: null; : null;
@@ -63,6 +63,11 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
$weight = $request->weight ?? 0.5; $weight = $request->weight ?? 0.5;
$surcharges = $this->surchargeCodes($request); $surcharges = $this->surchargeCodes($request);
// The customer's delivery instructions, as ELTA's remarks
// (ΠΑΡΑΤΗΡΗΣΕΙΣ): one line, at most 90 characters — the ELTA
// client's own field limit.
$remarks = mb_substr(trim(preg_replace('/\s+/u', ' ', (string) $address->delivery_instructions)), 0, 90);
$params = [ $params = [
'pel_apost_code' => config('elta.apost_code'), 'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"), 'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"),
@@ -78,7 +83,7 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
'pel_y' => '', 'pel_y' => '',
'pel_z' => '', 'pel_z' => '',
'pel_temaxia' => (string) $request->packageCount, 'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '', 'pel_paral_sxolia' => $remarks,
'pel_sur_1' => $surcharges[0] ?? '', 'pel_sur_1' => $surcharges[0] ?? '',
'pel_sur_2' => $surcharges[1] ?? '', 'pel_sur_2' => $surcharges[1] ?? '',
'pel_sur_3' => $surcharges[2] ?? '', 'pel_sur_3' => $surcharges[2] ?? '',
@@ -129,6 +134,7 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
'meta' => [ 'meta' => [
'carrier_id' => $this->findPendingVoucherId((string) $order->reference), 'carrier_id' => $this->findPendingVoucherId((string) $order->reference),
'reference' => (string) $order->reference, 'reference' => (string) $order->reference,
'delivery_instructions' => $remarks ?: null,
'weight' => $weight, 'weight' => $weight,
'package_count' => $pieces, 'package_count' => $pieces,
'cod_amount' => $codAmount, 'cod_amount' => $codAmount,
@@ -240,6 +240,11 @@ class EltaLabelRenderer
// older vouchers were sent the order id). // older vouchers were sent the order id).
'order_reference' => $isChild ? null : (string) $order->reference, 'order_reference' => $isChild ? null : (string) $order->reference,
// ΠΑΡΑΤΗΡΗΣΕΙΣ: the remarks we sent ELTA (the customer's delivery
// instructions), in the client's 27-character lines — up to 3
// on A4, the first 2 on A6. Extra parcels print them too.
'sxolia' => array_slice(mb_str_split((string) ($meta['delivery_instructions'] ?? ''), 27), 0, 3),
// sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked // sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked
// extra services, in ELTA's own wording and slots. // extra services, in ELTA's own wording and slots.
'sur_1' => $surcharges[1], 'sur_1' => $surcharges[1],
@@ -124,12 +124,13 @@ class OrderShipmentsExtension extends ViewPageExtension
->schema(fn (Shipment $record) => [ ->schema(fn (Shipment $record) => [
TextInput::make('tracking_reference') TextInput::make('tracking_reference')
->label('Voucher / tracking number') ->label('Voucher / tracking number')
->required($record->source === Shipment::SOURCE_MANUAL_VOUCHER)
->unique(Shipment::class, 'tracking_reference', ignorable: $record), ->unique(Shipment::class, 'tracking_reference', ignorable: $record),
]) ])
->action(fn (Shipment $record, array $data) => $record->update([ ->action(fn (Shipment $record, array $data) => $record->update([
'tracking_reference' => filled($data['tracking_reference']) ? trim($data['tracking_reference']) : null, 'tracking_reference' => filled($data['tracking_reference']) ? trim($data['tracking_reference']) : null,
])) ]))
->visible(fn (Shipment $record) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()), ->visible(fn (Shipment $record) => in_array($record->source, [Shipment::SOURCE_MANUAL, Shipment::SOURCE_MANUAL_VOUCHER], true) && ! $record->isCancelled()),
Action::make('add_tracking_update') Action::make('add_tracking_update')
->label('Add tracking update') ->label('Add tracking update')
->icon('heroicon-o-plus-circle') ->icon('heroicon-o-plus-circle')
@@ -152,7 +153,7 @@ class OrderShipmentsExtension extends ViewPageExtension
->helperText('Shown to the customer on their order page.'), ->helperText('Shown to the customer on their order page.'),
]) ])
->action(fn (Shipment $record, array $data) => $this->addTrackingUpdate($record, $data)) ->action(fn (Shipment $record, array $data) => $this->addTrackingUpdate($record, $data))
->visible(fn (Shipment $record) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()), ->visible(fn (Shipment $record) => in_array($record->source, [Shipment::SOURCE_MANUAL, Shipment::SOURCE_MANUAL_VOUCHER], true) && ! $record->isCancelled()),
Action::make('cancel_shipment') Action::make('cancel_shipment')
->label('Cancel') ->label('Cancel')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
@@ -130,7 +130,7 @@ class OrderViewExtension extends ViewPageExtension
return [ return [
TextInput::make('tracking_reference') TextInput::make('tracking_reference')
->label('Voucher / tracking number') ->label('Voucher / tracking number')
->unique(Shipment::class, 'tracking_reference') ->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->helperText('Optional — add it later from the shipment if the courier hasn\'t given one yet.'), ->helperText('Optional — add it later from the shipment if the courier hasn\'t given one yet.'),
TextInput::make('package_count') TextInput::make('package_count')
->label('Number of packages') ->label('Number of packages')
@@ -305,7 +305,7 @@ class OrderViewExtension extends ViewPageExtension
TextInput::make('voucher_number') TextInput::make('voucher_number')
->label('Voucher number') ->label('Voucher number')
->required() ->required()
->unique(Shipment::class, 'tracking_reference') ->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->live(onBlur: true) ->live(onBlur: true)
->afterStateUpdated(fn (Set $set) => $set('lookup', null)) ->afterStateUpdated(fn (Set $set) => $set('lookup', null))
->suffixAction( ->suffixAction(
@@ -145,9 +145,8 @@ class ShippingMethodResourceExtension extends ResourceExtension
->required($isManual), ->required($isManual),
TextInput::make('tracking_url') TextInput::make('tracking_url')
->label('Tracking URL') ->label('Tracking URL')
->helperText('Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}.') ->helperText('Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}. For an integrated carrier it\'s only used on vouchers typed in by hand.')
->rule('starts_with:http://,https://') ->rule('starts_with:http://,https://'),
->visible($isManual),
Toggle::make('collects_cash') Toggle::make('collects_cash')
->label('Courier collects cash on delivery') ->label('Courier collects cash on delivery')
->helperText('Offers cash on delivery at checkout for this shipping method.') ->helperText('Offers cash on delivery at checkout for this shipping method.')
+27 -33
View File
@@ -3,40 +3,43 @@
namespace Modules\Core\Shipping\Jobs; namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Bus;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Services\ShipmentTrackingRecorder;
use Throwable;
/** /**
* Carrier-agnostic: polls every Shipment not yet in a terminal state, * Carrier-agnostic: finds every Shipment not yet in a terminal state on a
* skipping carriers whose fulfillment service doesn't implement * carrier whose fulfillment service implements SupportsTracking, and
* SupportsTracking. New checkpoints are recorded in shipment_info and * dispatches one RefreshShipmentTrackingJob per shipment as a batch. The
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint. * carrier calls happen in those jobs — each with its own timeout, and a
* failure in one (a carrier 500, a malformed parcel response) doesn't
* touch the others (allowFailures).
* *
* Each shipment's trackShipment() call is individually try/caught in * Unique: a scheduled run is dropped while the previous one is still
* pollCarrierShipments() — one shipment's tracking lookup failing (a * queued or building its batch. The schedule's withoutOverlapping() alone
* carrier 500, a malformed parcel response) must not stop the rest of that * can't do this — for a queued job it only covers the dispatch itself.
* carrier's shipments in the same batch from being polled. The failure is
* reported and the loop continues.
*/ */
class PollShipmentTrackingJob implements ShouldQueue class PollShipmentTrackingJob implements ShouldBeUnique, ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;
use Queueable; use Queueable;
use SerializesModels;
public int $tries = 3; /** Only queries and dispatches — no carrier calls. */
public int $timeout = 60;
public int $backoff = 60; public bool $failOnTimeout = true;
public int $tries = 1;
public int $uniqueFor = 300;
public function handle(): void public function handle(): void
{ {
@@ -48,7 +51,7 @@ class PollShipmentTrackingJob implements ShouldQueue
return; return;
} }
Shipment::query() $jobs = Shipment::query()
->whereIn('carrier', $trackableCarriers) ->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at') ->whereNull('cancelled_at')
// Pending vouchers (issued on print) have no number to track yet. // Pending vouchers (issued on print) have no number to track yet.
@@ -60,26 +63,17 @@ class PollShipmentTrackingJob implements ShouldQueue
TrackingStatus::Cancelled->value, TrackingStatus::Cancelled->value,
]); ]);
}) })
->chunkById(50, function ($shipments) { ->pluck('id')
$shipments->groupBy('carrier')->each( ->map(fn (int $id) => new RefreshShipmentTrackingJob($id));
fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group)
);
});
}
private function pollCarrierShipments(string $carrier, $shipments): void if ($jobs->isEmpty()) {
{
if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) {
return; return;
} }
foreach ($shipments as $shipment) { Bus::batch($jobs)
try { ->name('Poll shipment tracking')
app(ShipmentTrackingRecorder::class)->refresh($shipment); ->allowFailures()
} catch (Throwable $e) { ->dispatch();
report($e);
}
}
} }
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
@@ -0,0 +1,63 @@
<?php
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Services\ShipmentTrackingRecorder;
/**
* Refreshes one shipment's tracking from its carrier — one job per
* shipment in PollShipmentTrackingJob's batch, so a slow or failing
* carrier call only costs its own job, not the rest of the run.
*
* Unique per shipment: while one is queued or running, another for the
* same shipment isn't queued, so two refreshes can't both record the same
* checkpoint (the recorder's dedup reads, then writes).
*/
class RefreshShipmentTrackingJob implements ShouldBeUnique, ShouldQueue
{
use Batchable;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
/** A few carrier calls at a 10–15s HTTP timeout each; under the queue's 90s retry_after. */
public int $timeout = 45;
public bool $failOnTimeout = true;
/** The next poll, five minutes later, is the retry. */
public int $tries = 1;
/** Releases the lock if a worker dies mid-job and never releases it. */
public int $uniqueFor = 300;
public function __construct(public readonly int $shipmentId) {}
public function uniqueId(): string
{
return (string) $this->shipmentId;
}
public function handle(ShipmentTrackingRecorder $recorder): void
{
if ($this->batch()?->cancelled()) {
return;
}
$shipment = Shipment::find($this->shipmentId);
// Cancelled (or deleted) since the batch was built.
if (! $shipment || $shipment->isCancelled()) {
return;
}
$recorder->refresh($shipment);
}
}
+4 -4
View File
@@ -67,15 +67,15 @@ class Shipment extends Model
} }
/** /**
* A link to the carrier's own tracking page — manual carriers only. * A link to the carrier's own tracking page — manual carriers and
* Integrated carriers' history is synced, so customers follow it on * typed-in vouchers only. API-created shipments' history is synced, so
* our own order page instead. * customers follow it on our own order page instead.
*/ */
public function trackingUrl(): ?string public function trackingUrl(): ?string
{ {
$template = $this->meta['tracking_url'] ?? null; $template = $this->meta['tracking_url'] ?? null;
if ($this->source !== self::SOURCE_MANUAL || blank($template) || blank($this->tracking_reference)) { if (! in_array($this->source, [self::SOURCE_MANUAL, self::SOURCE_MANUAL_VOUCHER], true) || blank($template) || blank($this->tracking_reference)) {
return null; return null;
} }