Compare commits

...
18 changed files with 207 additions and 49 deletions
+53
View File
@@ -4,6 +4,59 @@ 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/).
## [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
### Fixed
- Queued shipping jobs did nothing: `PollShipmentTrackingJob` and
`SyncCarrierVouchersJob` ran every time but finished in milliseconds
without contacting any carrier. The queue worker clears facade instances
before each job, and the Shipping manager was bound with `bind()`, so the
facade then built a fresh manager with no drivers registered. It's now a
singleton. Tracking from the scheduler never worked on a deployed app
because of this (a "Sync now" click worked, since it doesn't go through
the queue).
- ELTA extra parcels are no longer tracked by their own number: ELTA only
tracks a multi-parcel send by its main voucher and rejects the extra
parcels' numbers ("Λάθος ΣΥΔΕΤΑ"), which logged an error for each of them
on every tracking run.
## [0.29.2] - 2026-09-30
### Added
- `core.display_timezone` (env `DISPLAY_TIMEZONE`, default `Europe/Athens`):
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.29.2",
"version": "0.30.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@boboko/core",
"version": "0.29.2",
"version": "0.30.0",
"private": true,
"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.",
@@ -206,6 +206,10 @@
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;">
<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>
@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 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>
{{-- 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 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>
{{-- 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 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,
// Active, numbered, non-return shipments: the voucher numbers
// 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.
'shipments' => $order->shipments()->get()
->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference))
@@ -118,6 +118,12 @@ class OrderFulfillmentService
'meta' => [
'services' => array_map(fn (ExtraService $service) => $service->value, $services),
'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(),
],
]);
+7 -1
View File
@@ -154,7 +154,13 @@ class ShippingServiceProvider extends ServiceProvider
// run before the Shipping::extend() calls below, which resolve —
// and the facade then CACHES — whatever's bound at that moment).
$this->app->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
// A singleton, not bind(): the queue worker clears every
// facade's cached instance before each job
// (Facade::clearResolvedInstances()), so with bind() the Shipping
// facade then built a fresh, empty manager — without the drivers
// extended below — and every queued job (tracking, voucher sync)
// saw no carriers at all.
$this->app->singleton(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
// Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
@@ -72,6 +72,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtr
'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'
? (float) ($request->amountToCollect ?? $order->total->decimal)
: null;
@@ -63,6 +63,11 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
$weight = $request->weight ?? 0.5;
$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 = [
'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"),
@@ -78,7 +83,7 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
'pel_y' => '',
'pel_z' => '',
'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '',
'pel_paral_sxolia' => $remarks,
'pel_sur_1' => $surcharges[0] ?? '',
'pel_sur_2' => $surcharges[1] ?? '',
'pel_sur_3' => $surcharges[2] ?? '',
@@ -129,6 +134,7 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
'meta' => [
'carrier_id' => $this->findPendingVoucherId((string) $order->reference),
'reference' => (string) $order->reference,
'delivery_instructions' => $remarks ?: null,
'weight' => $weight,
'package_count' => $pieces,
'cod_amount' => $codAmount,
@@ -278,6 +284,13 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
public function trackShipment(Shipment $shipment): Collection
{
// ELTA tracks a multi-parcel send by its main voucher only — an
// extra parcel's own number is rejected ("Λάθος ΣΥΔΕΤΑ"). Its
// history is the main voucher's, which is tracked on its own row.
if (! $shipment->is($this->masterOf($shipment))) {
return collect();
}
$response = $this->client->getTracking([
'web_vg' => $shipment->tracking_reference,
'pel_code' => config('elta.apost_code'),
@@ -240,6 +240,11 @@ class EltaLabelRenderer
// older vouchers were sent the order id).
'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
// extra services, in ELTA's own wording and slots.
'sur_1' => $surcharges[1],
@@ -124,12 +124,13 @@ class OrderShipmentsExtension extends ViewPageExtension
->schema(fn (Shipment $record) => [
TextInput::make('tracking_reference')
->label('Voucher / tracking number')
->required($record->source === Shipment::SOURCE_MANUAL_VOUCHER)
->unique(Shipment::class, 'tracking_reference', ignorable: $record),
])
->action(fn (Shipment $record, array $data) => $record->update([
'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')
->label('Add tracking update')
->icon('heroicon-o-plus-circle')
@@ -152,7 +153,7 @@ class OrderShipmentsExtension extends ViewPageExtension
->helperText('Shown to the customer on their order page.'),
])
->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')
->label('Cancel')
->icon('heroicon-o-x-circle')
@@ -130,7 +130,7 @@ class OrderViewExtension extends ViewPageExtension
return [
TextInput::make('tracking_reference')
->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.'),
TextInput::make('package_count')
->label('Number of packages')
@@ -305,7 +305,7 @@ class OrderViewExtension extends ViewPageExtension
TextInput::make('voucher_number')
->label('Voucher number')
->required()
->unique(Shipment::class, 'tracking_reference')
->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->live(onBlur: true)
->afterStateUpdated(fn (Set $set) => $set('lookup', null))
->suffixAction(
@@ -145,9 +145,8 @@ class ShippingMethodResourceExtension extends ResourceExtension
->required($isManual),
TextInput::make('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}.')
->rule('starts_with:http://,https://')
->visible($isManual),
->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://'),
Toggle::make('collects_cash')
->label('Courier collects cash on delivery')
->helperText('Offers cash on delivery at checkout for this shipping method.')
+27 -33
View File
@@ -3,40 +3,43 @@
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Bus;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus;
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,
* skipping carriers whose fulfillment service doesn't implement
* SupportsTracking. New checkpoints are recorded in shipment_info and
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint.
* Carrier-agnostic: finds every Shipment not yet in a terminal state on a
* carrier whose fulfillment service implements SupportsTracking, and
* dispatches one RefreshShipmentTrackingJob per shipment as a batch. The
* 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
* pollCarrierShipments() — one shipment's tracking lookup failing (a
* carrier 500, a malformed parcel response) must not stop the rest of that
* carrier's shipments in the same batch from being polled. The failure is
* reported and the loop continues.
* Unique: a scheduled run is dropped while the previous one is still
* queued or building its batch. The schedule's withoutOverlapping() alone
* can't do this — for a queued job it only covers the dispatch itself.
*/
class PollShipmentTrackingJob implements ShouldQueue
class PollShipmentTrackingJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
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
{
@@ -48,7 +51,7 @@ class PollShipmentTrackingJob implements ShouldQueue
return;
}
Shipment::query()
$jobs = Shipment::query()
->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at')
// Pending vouchers (issued on print) have no number to track yet.
@@ -60,26 +63,17 @@ class PollShipmentTrackingJob implements ShouldQueue
TrackingStatus::Cancelled->value,
]);
})
->chunkById(50, function ($shipments) {
$shipments->groupBy('carrier')->each(
fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group)
);
});
}
->pluck('id')
->map(fn (int $id) => new RefreshShipmentTrackingJob($id));
private function pollCarrierShipments(string $carrier, $shipments): void
{
if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) {
if ($jobs->isEmpty()) {
return;
}
foreach ($shipments as $shipment) {
try {
app(ShipmentTrackingRecorder::class)->refresh($shipment);
} catch (Throwable $e) {
report($e);
}
}
Bus::batch($jobs)
->name('Poll shipment tracking')
->allowFailures()
->dispatch();
}
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.
* Integrated carriers' history is synced, so customers follow it on
* our own order page instead.
* A link to the carrier's own tracking page — manual carriers and
* typed-in vouchers only. API-created shipments' history is synced, so
* customers follow it on our own order page instead.
*/
public function trackingUrl(): ?string
{
$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;
}