Files
core/src/Shipping/Carriers/Elta/EltaFulfillmentService.php
T

623 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* ELTA vouchers are created **pending** (pel_insert_flag "0", live-verified):
* ELTA stores the voucher with an internal id but no voucher number, and it
* can still be cancelled. Printing issues it (PELVG01NEW1 — what ELTA's own
* client does from its group-editing screen): that assigns the voucher
* number, OCR line, destination station and service, and from then on ELTA
* refuses to delete it ("Δεν Επιτρέπεται! Εχει Γίνει Εκτύπωση"). So a
* shipment has no tracking_reference until its label is printed.
*
* Not SupportsManifestBatching — ELTA has no manifest/pickup-list step.
*/
class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVoucherOnPrint, SupportsBatchLabels, SupportsExtraServices, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{
/** PELMANIF2 / PELPARALVGNEW1 page through 100 rows at a time. */
private const MAX_PAGES = 50;
public function __construct(
private readonly EltaClient $client,
private readonly EltaLabelRenderer $labelRenderer,
) {}
/**
* ELTA surcharge codes (pel_sur_1..3), from its own client's checkboxes.
* Insurance isn't a surcharge — it's the amount in pel_asf_poso.
*/
public function extraServices(): array
{
return [
ExtraService::SaturdayDelivery->value => '004',
ExtraService::TimedDelivery->value => '003',
ExtraService::SpecialHandling->value => '002',
ExtraService::Insurance->value => 'pel_asf_poso',
];
}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$weight = $request->weight ?? 0.5;
$surcharges = $this->surchargeCodes($request);
$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' => $surcharges[0] ?? '',
'pel_sur_2' => $surcharges[1] ?? '',
'pel_sur_3' => $surcharges[2] ?? '',
// pel_ant_poso is the cash-on-delivery amount; pel_ant_poso1..4
// are cheques (with dates) in ELTA's client — never used here.
'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' => $request->has(ExtraService::Insurance) && $request->insuranceAmount
? number_format($request->insuranceAmount, 2, '.', '')
: '',
'pel_user' => config('elta.user_code'),
// Our order reference — comes back in ELTA's lists and tracking,
// which is how we find the pending voucher's id below and match
// vouchers to orders on the Carrier Vouchers screen.
'pel_ref_no' => (string) $order->reference,
// "0" = save without issuing (see this class's docblock).
'pel_insert_flag' => '0',
'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, '.', '');
}
$this->client->createVoucher($params)->throwIfError();
$pieces = max(1, $request->packageCount);
// PELVGNEW doesn't return the new voucher's id, so find it in the
// pending list by our reference. Saved even if that lookup fails —
// the voucher exists at ELTA either way, and printing retries it.
$master = Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null,
'meta' => [
'carrier_id' => $this->findPendingVoucherId((string) $order->reference),
'reference' => (string) $order->reference,
'weight' => $weight,
'package_count' => $pieces,
'cod_amount' => $codAmount,
'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
...($pieces > 1 ? ['piece' => 1, 'pieces' => $pieces] : []),
],
]);
// One row per extra parcel straight away, so the order shows every
// parcel; ELTA gives them their own voucher numbers only when the
// main voucher is issued (issue()). They belong to the main voucher:
// no COD or services of their own.
for ($piece = 2; $piece <= $pieces; $piece++) {
Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null,
'meta' => [
'master_shipment_id' => $master->id,
'reference' => (string) $order->reference,
'piece' => $piece,
'pieces' => $pieces,
'weight' => $weight,
'package_count' => $pieces,
],
]);
}
return $master;
}
/**
* Like ELTA's own client: printing the main voucher of a multi-parcel
* send prints every parcel (main first, then each child); printing a
* child prints just that parcel. Either way the main voucher is issued
* first — that's what gives the children their numbers.
*/
public function printLabel(Shipment $shipment): string
{
$master = $this->masterOf($shipment);
$this->issue($master);
$labels = $shipment->is($master)
? collect([$master->refresh(), ...$this->childrenOf($master)])
: collect([$shipment->refresh()]);
$pdf = $this->labelRenderer->renderMany($labels);
Shipment::whereIn('id', $labels->pluck('id'))->update(['label_printed_at' => now()]);
return $pdf;
}
/**
* Several labels in one PDF. A selected main voucher brings its
* children along (as printLabel() does); each parcel is printed once,
* main voucher before its children.
*/
public function printLabels(Collection $shipments): string
{
$labels = collect();
foreach ($shipments as $shipment) {
$master = $this->masterOf($shipment);
$this->issue($master);
$group = $shipment->is($master)
? [$master->refresh(), ...$this->childrenOf($master)]
: [$shipment->refresh()];
foreach ($group as $label) {
$labels->put($label->id, $label);
}
}
$pdf = $this->labelRenderer->renderMany($labels->values());
Shipment::whereIn('id', $labels->keys())->update(['label_printed_at' => now()]);
return $pdf;
}
/**
* Pending (not yet printed) vouchers are deleted at ELTA. Issued ones
* can't be — ELTA only allows that before printing.
*/
public function cancelShipment(Shipment $shipment): void
{
$master = $this->masterOf($shipment);
if (! $shipment->is($master)) {
$piece = $shipment->meta['piece'] ?? null;
throw new EltaApiException(
'This is '.($piece ? "parcel {$piece}" : 'an extra parcel').' of a multi-parcel ELTA voucher — ELTA cancels the whole voucher, so cancel the main parcel instead.',
);
}
if (filled($shipment->tracking_reference)) {
throw new EltaApiException(
"ELTA voucher {$shipment->tracking_reference} is already printed, and ELTA only allows cancelling vouchers that haven't been printed yet. Contact ELTA to cancel it.",
);
}
$this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->throwIfError();
$shipment->update(['cancelled_at' => now()]);
$this->childrenOf($shipment)->each->update(['cancelled_at' => now()]);
}
/**
* The main voucher's shipment for a child parcel, or the shipment
* itself. Children created by createShipment() point at it via
* meta.master_shipment_id; older ones only via parent_reference.
*/
private function masterOf(Shipment $shipment): Shipment
{
if ($id = $shipment->meta['master_shipment_id'] ?? null) {
return Shipment::find($id) ?? $shipment;
}
if (filled($shipment->parent_reference)) {
return Shipment::where('tracking_reference', $shipment->parent_reference)->first() ?? $shipment;
}
return $shipment;
}
/**
* @return Collection<int, Shipment> the main voucher's child parcels, in piece order
*/
private function childrenOf(Shipment $master): Collection
{
return Shipment::query()
->where('carrier', 'elta')
->where(fn ($query) => $query
->where('meta->master_shipment_id', $master->id)
->when($master->tracking_reference, fn ($q) => $q->orWhere('parent_reference', $master->tracking_reference)))
->orderBy('id')
->get()
->sortBy(fn (Shipment $child) => $child->meta['piece'] ?? PHP_INT_MAX)
->values();
}
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'),
])->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,
// ELTA reports Greek local time.
occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12), 'Europe/Athens')->setTimezone(config('app.timezone')),
meta: $row,
);
});
}
/**
* PELMANIF2 (the client's "Πολλαπλή Αναζήτηση"): every issued voucher
* in the date range with its latest status. A second pass with
* status_flag 3 ("Προς Επιστροφή") marks the ones heading back to us.
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
{
$returning = collect($this->searchVouchers($from, $to, '3'))->pluck('pel_col_1')->flip();
foreach ($this->searchVouchers($from, $to, '0') as $row) {
$number = trim($row['pel_col_1']);
yield new CarrierVoucher(
carrier: 'elta',
voucherNumber: $number,
reference: filled($row['pel_col_2'] ?? null) ? trim($row['pel_col_2']) : null,
recipientName: trim($row['pel_col_3'] ?? '') ?: null,
postcode: trim($row['pel_col_4'] ?? '') ?: null,
statusText: trim($row['pel_col_6'] ?? '') ?: null,
status: filled(trim($row['pel_col_6'] ?? '')) ? $this->guessStatusFromTitle(trim($row['pel_col_6'])) : null,
codAmount: (float) ($row['pel_col_8'] ?? 0) ?: null,
isReturn: $returning->has($row['pel_col_1']),
date: $this->dateFromListRow($row['pel_col_7'] ?? ''),
raw: $row,
);
}
}
/**
* PELTTNEW01 (tracking) also returns the voucher's details (pel_rec):
* recipient, postcode, our reference, cash on delivery.
*/
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
{
$response = $this->client->getTracking([
'web_vg' => trim($voucherNumber),
'pel_code' => config('elta.apost_code'),
]);
$record = $response->data['pel_rec'] ?? [];
if ($response->hasError || blank($record['a_vg_3'] ?? null)) {
return null;
}
$statuses = array_values(array_filter(
(array) ($response->data['web_status'] ?? []),
fn (array $row) => filled($row['web_date_time'] ?? null),
));
return new CarrierVoucher(
carrier: 'elta',
voucherNumber: trim($record['a_vg_3']),
reference: trim($record['a_ref'] ?? '') ?: null,
recipientName: trim($record['a_rec_title'] ?? '') ?: null,
postcode: trim($record['a_rec_postal'] ?? '') ?: null,
phone: trim($record['a_rec_tel_1'] ?? '') ?: null,
statusText: filled($statuses) ? trim(end($statuses)['web_status_name'] ?? '') : null,
status: filled($statuses) ? $this->guessStatusFromTitle(trim(end($statuses)['web_status_name'] ?? '')) : null,
codAmount: (float) ($record['a_antik'] ?? 0) ?: null,
date: $this->dateFromListRow($record['a_sender_date'] ?? ''),
raw: $record,
);
}
/**
* Issues a pending voucher (no-op once it has a number): stores the
* voucher number and what ELTA returns for the label, plus one shipment
* per child voucher of a multi-piece send.
*/
private function issue(Shipment $shipment): void
{
if (filled($shipment->tracking_reference)) {
return;
}
$response = $this->client->issueVoucher([
'pel_id' => $this->pendingVoucherId($shipment),
'sender_station' => $this->senderStation(),
])->throwIfError();
$data = $response->data;
$voucherNo = trim((string) ($data['vg_code'] ?? ''));
if ($voucherNo === '') {
throw new EltaApiException('ELTA issued the voucher but returned no voucher number.', $data);
}
$shipment->tracking_reference = $voucherNo;
$shipment->meta = array_merge($shipment->meta?->toArray() ?? [], [
'ocr_line' => $data['ocr_line'] ?? null,
'date_time' => $data['date_time'] ?? null,
'rec_station' => $data['rec_station'] ?? null,
'rec_station_t' => $data['rec_station_t'] ?? null,
'rec_srv' => $data['rec_srv'] ?? null,
'rec_srv_t' => $data['rec_srv_t'] ?? null,
'return_vg' => trim((string) ($data['return_vg'] ?? '')) ?: null,
'epitagh_vg' => trim((string) ($data['epitagh_vg'] ?? '')) ?: null,
]);
$shipment->save();
// The children's numbers, in ELTA's order (vg_child_no), go to the
// pending child rows in piece order; any parcel without a row (older
// shipments) gets one.
$pending = $this->childrenOf($shipment)->filter(fn (Shipment $child) => blank($child->tracking_reference))->values();
$pieces = (int) ($shipment->meta['package_count'] ?? 1);
$childNumbers = array_values(array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? []))));
foreach ($childNumbers as $i => $childVoucherNo) {
$child = $pending->get($i) ?? new Shipment([
'order_id' => $shipment->order_id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'meta' => [
'master_shipment_id' => $shipment->id,
'reference' => $shipment->meta['reference'] ?? null,
'piece' => $i + 2,
'pieces' => max($pieces, count($childNumbers) + 1),
'weight' => $shipment->meta['weight'] ?? null,
'package_count' => max($pieces, count($childNumbers) + 1),
],
]);
$child->tracking_reference = $childVoucherNo;
$child->parent_reference = $voucherNo;
$child->save();
}
}
/**
* @return array<int, string>
*/
private function surchargeCodes(ShipmentRequest $request): array
{
$codes = $this->extraServices();
return collect($request->services)
->reject(fn (ExtraService $service) => $service === ExtraService::Insurance)
->map(fn (ExtraService $service) => $codes[$service->value] ?? null)
->filter()
->values()
->take(3)
->all();
}
private function pendingVoucherId(Shipment $shipment): string
{
$id = $shipment->meta['carrier_id'] ?? null;
if (blank($id) && $shipment->order) {
$id = $this->findPendingVoucherId((string) $shipment->order->reference);
if ($id) {
$shipment->meta = array_merge($shipment->meta?->toArray() ?? [], ['carrier_id' => $id]);
$shipment->save();
}
}
if (blank($id)) {
throw new EltaApiException("Couldn't find this shipment's pending voucher at ELTA.");
}
return $id;
}
/**
* The newest not-yet-issued voucher in ELTA's list carrying our
* reference. flag_1/flag_2 are the client's "Show all" / "All users"
* checkboxes — both "1", or even a just-created voucher is filtered out.
*/
private function findPendingVoucherId(string $reference): ?string
{
$inId = '0';
$match = null;
for ($page = 0; $page < self::MAX_PAGES; $page++) {
$rows = array_values(array_filter(
(array) ($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()->data['vg_rec'] ?? []),
fn (array $row) => filled($row['pel_vg_id'] ?? null),
));
foreach ($rows as $row) {
if (trim($row['pel_ref_no'] ?? '') === $reference && blank(trim($row['pel_paral_vg'] ?? ''))
&& ($match === null || $row['pel_vg_id'] > $match)) {
$match = $row['pel_vg_id'];
}
}
if (count($rows) < 100) {
break;
}
$inId = (string) end($rows)['pel_vg_id'];
}
return $match;
}
/**
* @return array<int, array<string, string>>
*/
private function searchVouchers(CarbonInterface $from, CarbonInterface $to, string $statusFlag): array
{
$inId = '0';
$all = [];
for ($page = 0; $page < self::MAX_PAGES; $page++) {
$rows = array_values(array_filter(
(array) ($this->client->searchVouchers([
'pel_code' => config('elta.apost_code'),
'date_1' => $from->format('d/m/Y'),
'date_2' => $to->format('d/m/Y'),
'paral_code' => '',
'status_flag' => $statusFlag,
'in_id' => $inId,
])->throwIfError()->data['ag_pel_rec'] ?? []),
fn (array $row) => filled(trim($row['pel_col_1'] ?? '')),
));
array_push($all, ...$rows);
if (count($rows) < 100) {
break;
}
$inId = (string) end($rows)['pel_col_id'];
}
return $all;
}
/**
* ELTA dates in its lists look like "29/09/26 09:41 PEL CLIENT" or
* "29/09/2026 09:51".
*/
private function dateFromListRow(string $value): ?CarbonInterface
{
if (! preg_match('#(\d{2})/(\d{2})/(\d{2,4})#', $value, $m)) {
return null;
}
$year = strlen($m[3]) === 2 ? '20'.$m[3] : $m[3];
return Carbon::createFromDate((int) $year, (int) $m[2], (int) $m[1])->startOfDay();
}
/**
* The station ELTA needs when issuing a voucher: configured
* (ELTA_ORIGIN_STATION_CODE), or read from the login response once —
* ELTA returns the account's user_station there even though our
* account's login password is rejected.
*/
private function senderStation(): string
{
if (filled(config('elta.origin_station_code'))) {
return (string) config('elta.origin_station_code');
}
return (string) Cache::rememberForever('elta.user_station', fn () => $this->client->login([
'pel_code' => config('elta.apost_code'),
'user_code' => config('elta.user_code'),
'user_pass' => config('elta.user_pass'),
])->data['user_station'] ?? '');
}
/**
* 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.
*/
/**
* ELTA's status texts come in unaccented capitals ("ΠΑΡΑΔΟΘΗΚΕ"), so
* they're lowercased and stripped of accents before matching.
*/
private function guessStatusFromTitle(string $title): TrackingStatus
{
$title = strtr(mb_strtolower($title), [
'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω',
'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ',
]);
return match (true) {
str_contains($title, 'επιστροφ') => TrackingStatus::Returned,
str_contains($title, 'παραδοθηκε') => TrackingStatus::Delivered,
str_contains($title, 'διανομη') => TrackingStatus::OutForDelivery,
str_contains($title, 'παραλαβη') => TrackingStatus::CollectedFromSender,
str_contains($title, 'μεταφορα') || str_contains($title, 'διαμετακομιση') || str_contains($title, 'διακινηση') => TrackingStatus::InTransit,
default => TrackingStatus::Pending,
};
}
}