Feat: Adding Elta integrations
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user