225 lines
7.9 KiB
PHP
225 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Shipping\Services;
|
|
|
|
use Carbon\CarbonInterface;
|
|
use Lunar\Shipping\Facades\Shipping;
|
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
|
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
|
|
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
|
|
use Modules\Core\Shipping\DTOs\CarrierVoucher;
|
|
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
|
|
use Modules\Core\Shipping\Models\Shipment;
|
|
use Modules\Core\Shipping\Support\VoucherOrderMatcher;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Brings the vouchers each carrier reports into `shipments` (source
|
|
* `synced`), for the Carrier Vouchers screen: returns, and vouchers that
|
|
* never made it onto an order.
|
|
*
|
|
* A voucher we already have (any source) is never duplicated: its history
|
|
* is brought up to date from the carrier's tracking, it's flagged when the
|
|
* carrier lists it as a return (`meta.reported_return`), and one that came
|
|
* from the carrier also takes the carrier's latest details. The order link
|
|
* and our own shipments' details are never changed.
|
|
*
|
|
* Nothing is ever linked to an order here: vouchers get suggested orders
|
|
* (VoucherOrderMatcher, the reference match first), and staff link them.
|
|
* An unlinked voucher's suggestions are refreshed on every sync.
|
|
*/
|
|
class CarrierVoucherSync
|
|
{
|
|
/** Checkpoints added for the voucher being recorded (sync stats). */
|
|
private int $checkpointsAdded = 0;
|
|
|
|
public function __construct(
|
|
private readonly VoucherOrderMatcher $matcher,
|
|
private readonly ShipmentTrackingRecorder $tracking,
|
|
) {}
|
|
|
|
/**
|
|
* One carrier failing (API down, not configured) doesn't stop the
|
|
* others; its entry carries the error instead.
|
|
*
|
|
* @return array<string, array{seen: int, created: int, suggested: int, updated: int, error?: string}>
|
|
*/
|
|
public function syncAll(CarbonInterface $from, CarbonInterface $to): array
|
|
{
|
|
return collect(Shipping::getSupportedDrivers())->keys()
|
|
->filter(fn (string $carrier) => $this->service($carrier) instanceof SupportsVoucherListing)
|
|
->mapWithKeys(function (string $carrier) use ($from, $to) {
|
|
try {
|
|
return [$carrier => $this->sync($carrier, $from, $to)];
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
|
|
return [$carrier => ['seen' => 0, 'created' => 0, 'suggested' => 0, 'updated' => 0, 'error' => $e->getMessage()]];
|
|
}
|
|
})
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array{seen: int, created: int, suggested: int, updated: int}
|
|
*/
|
|
public function sync(string $carrier, CarbonInterface $from, CarbonInterface $to): array
|
|
{
|
|
$service = $this->service($carrier);
|
|
$stats = ['seen' => 0, 'created' => 0, 'suggested' => 0, 'updated' => 0];
|
|
|
|
if (! $service instanceof SupportsVoucherListing) {
|
|
return $stats;
|
|
}
|
|
|
|
foreach ($service->listVouchers($from, $to) as $voucher) {
|
|
$stats['seen']++;
|
|
|
|
$this->checkpointsAdded = 0;
|
|
$shipment = $this->record($voucher);
|
|
|
|
if ($shipment?->wasRecentlyCreated) {
|
|
$stats['created']++;
|
|
$stats['suggested'] += filled($shipment->meta['suggested_order_ids'] ?? []) ? 1 : 0;
|
|
} elseif ($this->checkpointsAdded > 0) {
|
|
$stats['updated']++;
|
|
}
|
|
}
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* A voucher that hasn't synced yet, looked up by number. Returns the
|
|
* shipment it's recorded as (existing or new), or null when the carrier
|
|
* doesn't know it.
|
|
*/
|
|
public function lookup(string $carrier, string $voucherNumber): ?Shipment
|
|
{
|
|
$service = $this->service($carrier);
|
|
|
|
if (! $service instanceof SupportsVoucherLookup) {
|
|
return null;
|
|
}
|
|
|
|
$voucher = $service->lookupVoucher($voucherNumber);
|
|
|
|
return $voucher ? $this->record($voucher) : null;
|
|
}
|
|
|
|
private function record(CarrierVoucher $voucher): ?Shipment
|
|
{
|
|
if (blank($voucher->voucherNumber)) {
|
|
return null;
|
|
}
|
|
|
|
$existing = Shipment::where('tracking_reference', $voucher->voucherNumber)->first();
|
|
|
|
if ($existing) {
|
|
$this->refreshExisting($existing, $voucher);
|
|
|
|
return $existing;
|
|
}
|
|
|
|
$shipment = Shipment::create([
|
|
'carrier' => $voucher->carrier,
|
|
'source' => Shipment::SOURCE_SYNCED,
|
|
'tracking_reference' => $voucher->voucherNumber,
|
|
'meta' => [
|
|
'reference' => $voucher->reference,
|
|
'recipient_name' => $voucher->recipientName,
|
|
'postcode' => $voucher->postcode,
|
|
'phone' => $voucher->phone,
|
|
'cod_amount' => $voucher->codAmount,
|
|
'is_return' => $voucher->isReturn,
|
|
'original_voucher' => $voucher->originalVoucher,
|
|
'voucher_date' => $voucher->date?->toDateString(),
|
|
'suggested_order_ids' => $this->matcher->suggestions($voucher),
|
|
],
|
|
]);
|
|
|
|
$this->recordHistory($shipment, $voucher);
|
|
|
|
return $shipment;
|
|
}
|
|
|
|
/**
|
|
* A voucher we already have, seen again: its history is brought up to
|
|
* date (any source, unless cancelled here), and a voucher that came
|
|
* from the carrier also takes the carrier's latest details. The order
|
|
* link and our own shipments' details are never changed.
|
|
*/
|
|
private function refreshExisting(Shipment $shipment, CarrierVoucher $voucher): void
|
|
{
|
|
$meta = $shipment->meta?->toArray() ?? [];
|
|
|
|
if ($voucher->isReturn) {
|
|
$meta['reported_return'] = true;
|
|
}
|
|
|
|
if ($shipment->source === Shipment::SOURCE_SYNCED) {
|
|
// Only what the carrier reported this time — ACS's list, for
|
|
// one, has no recipient, which mustn't wipe what we have.
|
|
$meta = [...$meta, ...array_filter([
|
|
'recipient_name' => $voucher->recipientName,
|
|
'postcode' => $voucher->postcode,
|
|
'phone' => $voucher->phone,
|
|
'cod_amount' => $voucher->codAmount,
|
|
'original_voucher' => $voucher->originalVoucher,
|
|
], fn ($value) => $value !== null)];
|
|
|
|
$meta['is_return'] = ($meta['is_return'] ?? false) || $voucher->isReturn;
|
|
|
|
if ($shipment->order_id === null) {
|
|
$meta['suggested_order_ids'] = $this->matcher->suggestions($voucher);
|
|
}
|
|
}
|
|
|
|
if ($meta !== ($shipment->meta?->toArray() ?? [])) {
|
|
$shipment->update(['meta' => $meta]);
|
|
}
|
|
|
|
if (! $shipment->isCancelled()) {
|
|
$this->recordHistory($shipment, $voucher);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What the carrier reports becomes the voucher's history, like any
|
|
* shipment's: new checkpoints from its tracking when the carrier has
|
|
* it; otherwise the status from the voucher list, when it differs from
|
|
* the latest one we have.
|
|
*/
|
|
private function recordHistory(Shipment $shipment, CarrierVoucher $voucher): void
|
|
{
|
|
try {
|
|
$this->checkpointsAdded += $this->tracking->refresh($shipment);
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
}
|
|
|
|
if ($this->checkpointsAdded > 0 || ! $voucher->status) {
|
|
return;
|
|
}
|
|
|
|
$latest = $shipment->shipmentInfo()->latest('occurred_at')->first();
|
|
|
|
if ($latest?->status === $voucher->status) {
|
|
return;
|
|
}
|
|
|
|
$this->checkpointsAdded += $this->tracking->record($shipment, [new TrackingCheckpoint(
|
|
status: $voucher->status,
|
|
carrierStatus: $voucher->statusText,
|
|
message: $voucher->statusText,
|
|
location: null,
|
|
occurredAt: $latest ? now() : ($voucher->date ?? now()),
|
|
)]);
|
|
}
|
|
|
|
private function service(string $carrier): ?CarrierFulfillmentInterface
|
|
{
|
|
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
|
}
|
|
}
|