250 lines
9.7 KiB
PHP
250 lines
9.7 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
namespace Modules\Core\Shipping\Carriers\Elta;
|
||
|
|
|
||
|
|
use Illuminate\Support\Carbon;
|
||
|
|
use Illuminate\Support\Collection;
|
||
|
|
use Lunar\Models\Order;
|
||
|
|
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
|
||
|
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||
|
|
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||
|
|
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||
|
|
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
|
||
|
|
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||
|
|
use Modules\Core\Shipping\Models\Shipment;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Not SupportsManifestBatching — no manifest/pickup-list operation was
|
||
|
|
* found in either ELTA operation family, unlike ACS's
|
||
|
|
* ACS_Issue_Pickup_List. Same shape as Box Now, which has no manifest
|
||
|
|
* step either.
|
||
|
|
*/
|
||
|
|
class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly EltaClient $client,
|
||
|
|
private readonly EltaLabelRenderer $labelRenderer,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||
|
|
{
|
||
|
|
$address = $order->shippingAddress;
|
||
|
|
$weight = $request->weight ?? 0.5;
|
||
|
|
|
||
|
|
$params = [
|
||
|
|
'pel_apost_code' => config('elta.apost_code'),
|
||
|
|
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"),
|
||
|
|
'pel_paral_address' => $address->line_one,
|
||
|
|
'pel_paral_area' => $address->city,
|
||
|
|
'pel_paral_tk' => $address->postcode,
|
||
|
|
'pel_paral_thl_1' => $address->contact_phone,
|
||
|
|
'pel_paral_thl_2' => '',
|
||
|
|
'pel_service' => '',
|
||
|
|
'pel_baros' => number_format($weight, 3, '.', ''),
|
||
|
|
'pel_baros_xyz' => '',
|
||
|
|
'pel_x' => '',
|
||
|
|
'pel_y' => '',
|
||
|
|
'pel_z' => '',
|
||
|
|
'pel_temaxia' => (string) $request->packageCount,
|
||
|
|
'pel_paral_sxolia' => '',
|
||
|
|
'pel_sur_1' => '',
|
||
|
|
'pel_sur_2' => '',
|
||
|
|
'pel_sur_3' => '',
|
||
|
|
'pel_ant_poso' => '',
|
||
|
|
'pel_ant_poso1' => '',
|
||
|
|
'pel_ant_poso2' => '',
|
||
|
|
'pel_ant_poso3' => '',
|
||
|
|
'pel_ant_poso4' => '',
|
||
|
|
'pel_ant_date1' => '',
|
||
|
|
'pel_ant_date2' => '',
|
||
|
|
'pel_ant_date3' => '',
|
||
|
|
'pel_ant_date4' => '',
|
||
|
|
'pel_asf_poso' => '',
|
||
|
|
'pel_user' => config('elta.user_code'),
|
||
|
|
'pel_ref_no' => (string) $order->id,
|
||
|
|
'pel_insert_flag' => '',
|
||
|
|
'pel_paral_code' => '',
|
||
|
|
'pel_retur_code' => '',
|
||
|
|
];
|
||
|
|
|
||
|
|
$codAmount = null;
|
||
|
|
|
||
|
|
if ($request->paymentMode === 'cod') {
|
||
|
|
$codAmount = $request->amountToCollect ?? $order->total->decimal;
|
||
|
|
$params['pel_ant_poso'] = number_format($codAmount, 2, '.', '');
|
||
|
|
}
|
||
|
|
|
||
|
|
$response = $this->client->createVoucher($params)->throwIfError();
|
||
|
|
|
||
|
|
$voucherNo = (string) $response->data['vg_code'];
|
||
|
|
|
||
|
|
$shipment = Shipment::create([
|
||
|
|
'order_id' => $order->id,
|
||
|
|
'carrier' => 'elta',
|
||
|
|
'tracking_reference' => $voucherNo,
|
||
|
|
'meta' => [
|
||
|
|
'weight' => $weight,
|
||
|
|
'ocr_line' => $response->data['ocr_line'] ?? null,
|
||
|
|
'date_time' => $response->data['date_time'] ?? null,
|
||
|
|
'package_count' => $request->packageCount,
|
||
|
|
'cod_amount' => $codAmount,
|
||
|
|
],
|
||
|
|
]);
|
||
|
|
|
||
|
|
// PELVGNEW's own response already carries every child voucher
|
||
|
|
// number for a multi-piece shipment in vg_child_no — no second
|
||
|
|
// request needed, same as the old CREATEAWB/vg_child shape.
|
||
|
|
foreach (array_filter((array) ($response->data['vg_child_no'] ?? [])) as $childVoucherNo) {
|
||
|
|
Shipment::create([
|
||
|
|
'order_id' => $order->id,
|
||
|
|
'carrier' => 'elta',
|
||
|
|
'tracking_reference' => $childVoucherNo,
|
||
|
|
'parent_reference' => $voucherNo,
|
||
|
|
'meta' => $shipment->meta?->toArray() ?? [],
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $shipment;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PELB64VG (the old, deprecated operation this used to call) fails
|
||
|
|
* with the same st_flag=3 as CREATEAWB — it's the same legacy family.
|
||
|
|
* There's no *NEW replacement: ELTA's own official client renders
|
||
|
|
* labels entirely locally from a bundled report template, never
|
||
|
|
* fetching one from the server at all (see EltaLabelRenderer's
|
||
|
|
* docblock). This reproduces that local rendering instead.
|
||
|
|
*/
|
||
|
|
public function printLabel(Shipment $shipment): string
|
||
|
|
{
|
||
|
|
$pdf = $this->labelRenderer->render($shipment);
|
||
|
|
|
||
|
|
$shipment->update(['label_printed_at' => now()]);
|
||
|
|
|
||
|
|
return $pdf;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Unlike the old, deprecated operation set (which genuinely has no
|
||
|
|
* delete operation), the *NEW family does support cancellation via
|
||
|
|
* PELVGDEL — but it identifies a voucher by its internal pel_vg_id,
|
||
|
|
* not the AWB/tracking number PELVGNEW returns (vg_code). Getting
|
||
|
|
* pel_vg_id requires first listing the account's vouchers via
|
||
|
|
* PELPARALVGNEW1 and matching the row whose pel_paral_vg equals our
|
||
|
|
* tracking_reference.
|
||
|
|
*/
|
||
|
|
public function cancelShipment(Shipment $shipment): void
|
||
|
|
{
|
||
|
|
$voucherId = $this->findVoucherId($shipment->tracking_reference);
|
||
|
|
|
||
|
|
if ($voucherId === null) {
|
||
|
|
throw new EltaApiException(
|
||
|
|
'Could not find voucher '.$shipment->tracking_reference.' in ELTA\'s voucher list — it may already be too old or cancelled to look up.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->client->deleteVoucher(['pel_vg_id' => $voucherId])->throwIfError();
|
||
|
|
|
||
|
|
$shipment->update(['cancelled_at' => now()]);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function findVoucherId(string $trackingReference): ?string
|
||
|
|
{
|
||
|
|
$inId = '0';
|
||
|
|
|
||
|
|
// PELPARALVGNEW1 paginates via in_id, same cursor pattern the
|
||
|
|
// official client uses — walk pages until the voucher is found
|
||
|
|
// or a page comes back empty. flag_1/flag_2 map to the real
|
||
|
|
// client's "Show All"/"All Users" checkboxes — live-confirmed
|
||
|
|
// both must be '1', otherwise even a voucher created moments ago
|
||
|
|
// is filtered out of the (default, unfiltered-looking) list.
|
||
|
|
for ($page = 0; $page < 50; $page++) {
|
||
|
|
$response = $this->client->listVouchers([
|
||
|
|
'pel_code' => config('elta.apost_code'),
|
||
|
|
'pel_user_code' => config('elta.user_code'),
|
||
|
|
'flag_1' => '1',
|
||
|
|
'flag_2' => '1',
|
||
|
|
'in_id' => $inId,
|
||
|
|
])->throwIfError();
|
||
|
|
|
||
|
|
$rows = array_filter(
|
||
|
|
(array) ($response->data['vg_rec'] ?? []),
|
||
|
|
fn (array $row) => filled($row['pel_vg_id'] ?? null),
|
||
|
|
);
|
||
|
|
|
||
|
|
if ($rows === []) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$match = collect($rows)->first(
|
||
|
|
fn (array $row) => ($row['pel_paral_vg'] ?? null) === $trackingReference,
|
||
|
|
);
|
||
|
|
|
||
|
|
if ($match !== null) {
|
||
|
|
return $match['pel_vg_id'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$inId = (string) end($rows)['pel_vg_id'];
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function trackShipment(Shipment $shipment): Collection
|
||
|
|
{
|
||
|
|
$response = $this->client->getTracking([
|
||
|
|
'web_vg' => $shipment->tracking_reference,
|
||
|
|
'pel_code' => config('elta.apost_code'),
|
||
|
|
])->throwIfError();
|
||
|
|
|
||
|
|
$rows = array_filter(
|
||
|
|
(array) ($response->data['web_status'] ?? []),
|
||
|
|
fn (array $row) => filled($row['web_date_time'] ?? null),
|
||
|
|
);
|
||
|
|
|
||
|
|
// No explicit delivered-confirmation field like the old
|
||
|
|
// PELTT01's pod_name — pel_rec.a_rec_date_time is blank until
|
||
|
|
// delivery per the real client's own rendering, used as the
|
||
|
|
// delivered signal here; unconfirmed against a genuinely
|
||
|
|
// delivered parcel yet.
|
||
|
|
$isDelivered = filled(trim($response->data['pel_rec']['a_rec_date_time'] ?? '', ' /:'));
|
||
|
|
|
||
|
|
return collect(array_values($rows))->map(function (array $row, int $index) use ($rows, $isDelivered) {
|
||
|
|
$isLast = $index === count($rows) - 1;
|
||
|
|
$dateTime = $row['web_date_time'] ?? '';
|
||
|
|
|
||
|
|
return new TrackingCheckpoint(
|
||
|
|
status: $isLast && $isDelivered
|
||
|
|
? TrackingStatus::Delivered
|
||
|
|
: $this->guessStatusFromTitle($row['web_status_name'] ?? ''),
|
||
|
|
carrierStatus: $row['web_status_name'] ?? null,
|
||
|
|
message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null),
|
||
|
|
location: $row['web_station'] ?? null,
|
||
|
|
occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12)),
|
||
|
|
meta: $row,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* web_status_name is free text with no structured status code — same
|
||
|
|
* limitation Acs\AcsFulfillmentService::guessStatusFromAction() has.
|
||
|
|
* Live-confirmed text seen so far: "ΔΗΜΙΟΥΡΓΙΑ ΣΥ.ΔΕ.ΤΑ. ΑΠΟ ΠΕΛΑΤΗ"
|
||
|
|
* (voucher created) — matched via a Pending-ish fallback below since
|
||
|
|
* it isn't yet in transit. Other statuses are best-guess substring
|
||
|
|
* matches to refine as more real tracking text is observed.
|
||
|
|
*/
|
||
|
|
private function guessStatusFromTitle(string $title): TrackingStatus
|
||
|
|
{
|
||
|
|
$title = mb_strtolower($title);
|
||
|
|
|
||
|
|
return match (true) {
|
||
|
|
str_contains($title, 'παραδόθηκε') => TrackingStatus::Delivered,
|
||
|
|
str_contains($title, 'διανομή') => TrackingStatus::OutForDelivery,
|
||
|
|
str_contains($title, 'παραλαβή') => TrackingStatus::CollectedFromSender,
|
||
|
|
str_contains($title, 'μεταφορά') || str_contains($title, 'διαμετακόμιση') => TrackingStatus::InTransit,
|
||
|
|
default => TrackingStatus::Pending,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|