Files
core/src/Shipping/Services/CarrierVoucherSync.php
T

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]);
}
}