2026-09-29 14:31:30 +03:00
<? php
namespace Modules\Core\Shipping\Carriers\Elta ;
2026-09-30 17:28:47 +03:00
use Carbon\CarbonInterface ;
2026-09-29 14:31:30 +03:00
use Illuminate\Support\Carbon ;
use Illuminate\Support\Collection ;
2026-09-30 17:28:47 +03:00
use Illuminate\Support\Facades\Cache ;
2026-09-29 14:31:30 +03:00
use Lunar\Models\Order ;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException ;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface ;
2026-09-30 17:28:47 +03:00
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint ;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels ;
use Modules\Core\Shipping\Contracts\SupportsExtraServices ;
2026-09-29 14:31:30 +03:00
use Modules\Core\Shipping\Contracts\SupportsTracking ;
2026-09-30 17:28:47 +03:00
use Modules\Core\Shipping\Contracts\SupportsVoucherListing ;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup ;
use Modules\Core\Shipping\DTOs\CarrierVoucher ;
2026-09-29 14:31:30 +03:00
use Modules\Core\Shipping\DTOs\ShipmentRequest ;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint ;
2026-09-30 17:28:47 +03:00
use Modules\Core\Shipping\Enums\ExtraService ;
2026-09-29 14:31:30 +03:00
use Modules\Core\Shipping\Enums\TrackingStatus ;
use Modules\Core\Shipping\Models\Shipment ;
/**
2026-09-30 17:28:47 +03:00
* 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.
2026-09-29 14:31:30 +03:00
*/
2026-09-30 17:28:47 +03:00
class EltaFulfillmentService implements CarrierFulfillmentInterface , IssuesVoucherOnPrint , SupportsBatchLabels , SupportsExtraServices , SupportsTracking , SupportsVoucherListing , SupportsVoucherLookup
2026-09-29 14:31:30 +03:00
{
2026-09-30 17:28:47 +03:00
/** PELMANIF2 / PELPARALVGNEW1 page through 100 rows at a time. */
private const MAX_PAGES = 50 ;
2026-09-29 14:31:30 +03:00
public function __construct (
private readonly EltaClient $client ,
private readonly EltaLabelRenderer $labelRenderer ,
) {}
2026-09-30 17:28:47 +03:00
/**
* 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' ,
];
}
2026-09-29 14:31:30 +03:00
public function createShipment ( Order $order , ShipmentRequest $request ) : Shipment
{
$address = $order -> shippingAddress ;
$weight = $request -> weight ?? 0.5 ;
2026-09-30 17:28:47 +03:00
$surcharges = $this -> surchargeCodes ( $request );
2026-09-29 14:31:30 +03:00
$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' => '' ,
2026-09-30 17:28:47 +03:00
'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.
2026-09-29 14:31:30 +03:00
'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' => '' ,
2026-09-30 17:28:47 +03:00
'pel_asf_poso' => $request -> has ( ExtraService :: Insurance ) && $request -> insuranceAmount
? number_format ( $request -> insuranceAmount , 2 , '.' , '' )
: '' ,
2026-09-29 14:31:30 +03:00
'pel_user' => config ( 'elta.user_code' ),
2026-09-30 17:28:47 +03:00
// 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' ,
2026-09-29 14:31:30 +03:00
'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 , '.' , '' );
}
2026-09-30 17:28:47 +03:00
$this -> client -> createVoucher ( $params ) -> throwIfError ();
2026-09-29 14:31:30 +03:00
2026-09-30 17:28:47 +03:00
// 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.
return Shipment :: create ([
2026-09-29 14:31:30 +03:00
'order_id' => $order -> id ,
'carrier' => 'elta' ,
2026-09-30 17:28:47 +03:00
'source' => Shipment :: SOURCE_CREATED ,
'tracking_reference' => null ,
2026-09-29 14:31:30 +03:00
'meta' => [
2026-09-30 17:28:47 +03:00
'carrier_id' => $this -> findPendingVoucherId (( string ) $order -> reference ),
2026-09-29 14:31:30 +03:00
'weight' => $weight ,
'package_count' => $request -> packageCount ,
'cod_amount' => $codAmount ,
2026-09-30 17:28:47 +03:00
'services' => $request -> serviceValues (),
'insurance_amount' => $request -> has ( ExtraService :: Insurance ) ? $request -> insuranceAmount : null ,
2026-09-29 14:31:30 +03:00
],
]);
}
public function printLabel ( Shipment $shipment ) : string
{
2026-09-30 17:28:47 +03:00
$this -> issue ( $shipment );
$pdf = $this -> labelRenderer -> render ( $shipment -> refresh ());
2026-09-29 14:31:30 +03:00
$shipment -> update ([ 'label_printed_at' => now ()]);
return $pdf ;
}
2026-09-30 17:28:47 +03:00
public function printLabels ( Collection $shipments ) : string
2026-09-29 14:31:30 +03:00
{
2026-09-30 17:28:47 +03:00
$shipments -> each ( fn ( Shipment $shipment ) => $this -> issue ( $shipment ));
2026-09-29 14:31:30 +03:00
2026-09-30 17:28:47 +03:00
$pdf = $this -> labelRenderer -> renderMany ( $shipments -> map -> refresh ());
2026-09-29 14:31:30 +03:00
2026-09-30 17:28:47 +03:00
Shipment :: whereIn ( 'id' , $shipments -> pluck ( 'id' )) -> update ([ 'label_printed_at' => now ()]);
2026-09-29 14:31:30 +03:00
2026-09-30 17:28:47 +03:00
return $pdf ;
2026-09-29 14:31:30 +03:00
}
2026-09-30 17:28:47 +03:00
/**
* 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
2026-09-29 14:31:30 +03:00
{
2026-09-30 17:28:47 +03:00
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." ,
2026-09-29 14:31:30 +03:00
);
}
2026-09-30 17:28:47 +03:00
$this -> client -> deleteVoucher ([ 'pel_vg_id' => $this -> pendingVoucherId ( $shipment )]) -> throwIfError ();
$shipment -> update ([ 'cancelled_at' => now ()]);
2026-09-29 14:31:30 +03:00
}
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 ,
2026-09-30 17:28:47 +03:00
// ELTA reports Greek local time.
occurredAt : Carbon :: createFromFormat ( 'YmdHi' , substr ( $dateTime , 0 , 12 ), 'Europe/Athens' ) -> setTimezone ( config ( 'app.timezone' )),
2026-09-29 14:31:30 +03:00
meta : $row ,
);
});
}
2026-09-30 17:28:47 +03:00
/**
* 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 ();
foreach ( array_filter ( array_map ( 'trim' , ( array ) ( $data [ 'vg_child_no' ] ?? []))) as $childVoucherNo ) {
Shipment :: create ([
'order_id' => $shipment -> order_id ,
'carrier' => 'elta' ,
'source' => Shipment :: SOURCE_CREATED ,
'tracking_reference' => $childVoucherNo ,
'parent_reference' => $voucherNo ,
'label_printed_at' => now (),
'meta' => $shipment -> meta -> toArray (),
]);
}
}
/**
* @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' ] ?? '' );
}
2026-09-29 14:31:30 +03:00
/**
* 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.
*/
2026-09-30 17:28:47 +03:00
/**
* ELTA's status texts come in unaccented capitals ("ΠΑΡΑΔΟΘΗΚΕ"), so
* they're lowercased and stripped of accents before matching.
*/
2026-09-29 14:31:30 +03:00
private function guessStatusFromTitle ( string $title ) : TrackingStatus
{
2026-09-30 17:28:47 +03:00
$title = strtr ( mb_strtolower ( $title ), [
'ά' => 'α ' , 'έ' => 'ε' , 'ή' => 'η' , 'ί' => 'ι ' , 'ό' => 'ο ' , 'ύ' => 'υ ' , 'ώ' => 'ω' ,
'ϊ' => 'ι ' , 'ϋ' => 'υ ' , 'ΐ' => 'ι ' , 'ΰ' => 'υ ' ,
]);
2026-09-29 14:31:30 +03:00
return match ( true ) {
2026-09-30 17:28:47 +03:00
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 ,
2026-09-29 14:31:30 +03:00
default => TrackingStatus :: Pending ,
};
}
}