diff --git a/composer.json b/composer.json index ece4e5f..7ae801f 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,9 @@ "lunarphp/search": "*", "lunarphp/meilisearch": "*", "spatie/laravel-translation-loader": "^2.8", - "stripe/stripe-php": "^16.6" + "stripe/stripe-php": "^16.6", + "picqer/php-barcode-generator": "^3.3", + "barryvdh/laravel-dompdf": "^3.1" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/config/shippingCarriers/elta.php b/config/shippingCarriers/elta.php new file mode 100644 index 0000000..014b8cb --- /dev/null +++ b/config/shippingCarriers/elta.php @@ -0,0 +1,70 @@ + env('ELTA_SERVICE_LOCATION', 'http://212.205.47.226:9003'), + + 'user_code' => env('ELTA_USER_CODE'), + 'user_pass' => env('ELTA_USER_PASS'), + + 'apost_code' => env('ELTA_APOST_CODE'), + 'apost_sub_code' => env('ELTA_APOST_SUB_CODE'), + + 'sender_name' => env('ELTA_SENDER_NAME'), + 'sender_address' => env('ELTA_SENDER_ADDRESS'), + 'sender_postcode' => env('ELTA_SENDER_POSTCODE'), + 'sender_area' => env('ELTA_SENDER_AREA'), + 'sender_phone' => env('ELTA_SENDER_PHONE'), + 'origin_station_code' => env('ELTA_ORIGIN_STATION_CODE'), + + 'label_paper_size' => env('ELTA_LABEL_PAPER_SIZE', 'a4'), + + 'timeout' => env('ELTA_HTTP_TIMEOUT', 15), + +]; diff --git a/resources/logos/elta-courier-logo.png b/resources/logos/elta-courier-logo.png new file mode 100644 index 0000000..aee4f2b Binary files /dev/null and b/resources/logos/elta-courier-logo.png differ diff --git a/resources/views/shipping/carriers/elta/label-a4.blade.php b/resources/views/shipping/carriers/elta/label-a4.blade.php new file mode 100644 index 0000000..185dbb0 --- /dev/null +++ b/resources/views/shipping/carriers/elta/label-a4.blade.php @@ -0,0 +1,123 @@ + + + + + + + +
+ + {{-- Copy 1: delivery copy — RDLC band top=0 --}} +
+ @include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'delivery']) +
+
+
3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ
+
+ +
✂ ΑΠΟΚΟΨΤΕ ΕΔΩ
+ + {{-- Copy 2: sender's copy — RDLC band top≈8.94cm on the full page --}} +
+ @include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'sender']) +
+ +
✂ ΑΠΟΚΟΨΤΕ ΕΔΩ
+ + {{-- Copy 3: ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ stub — RDLC band top≈19.22cm --}} +
+ ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ   Ο. Αριθμός Λογ/μού Ταχυπληρωμής {{ $siimvasi }} +
+
+ @include('core::shipping.carriers.elta.partials._payment-stub') +
+ +
ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ
+ + +
+ + diff --git a/resources/views/shipping/carriers/elta/label-a6.blade.php b/resources/views/shipping/carriers/elta/label-a6.blade.php new file mode 100644 index 0000000..0bfb277 --- /dev/null +++ b/resources/views/shipping/carriers/elta/label-a6.blade.php @@ -0,0 +1,225 @@ + + + + + + + +
+ {{-- Header: logo (0.10,0.12,1.66x1.26) + service box (6.42,0.10,3.93x0.84) --}} + +
+
ΥΠΗΡΕΣΙΑ: {{ $service_code }}
+
{{ $service_name }}
+
+
+ ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια
Ταχ/κων Υπηρεσιών +
+ +
{{ $date }}
+
ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ
+ {{-- The RDLC's own barcode textbox here uses the "Free 3 of 9 + Extended" barcode font — we don't have that font, so this + would-be *{voucher}* fallback text is dropped in favor of the + real Code 128 barcode image rendered near the bottom instead. --}} +
{{ $time }}
+ +
+
Γρ. Επίδοσης :
+
{{ $station_pros }}
+
{{ $station_pros_title }}
+
+ + {{-- ΧΡΕΩΣΗ / REFERENCE / ΣΥΜΒΑΣΗ 3-cell row, and the Γρ.Κατάθεσης / + ΒΑΡΟΣ / ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ / Τεμάχια row right below it: both are + genuinely tabular single rows of fixed-width cells, so — like + the equivalent A4 rows — they're built as real + instead of independently- + bordered absolute divs, which was drawing every shared edge + (each cell-to-cell seam, and the seam between these two rows) + twice, visibly doubling/thickening those lines versus a real + printed ELTA label. Each table is positioned at the row's own + RDLC top/left; the first table's bottom border is dropped since + the second table's top border already draws that shared line. --}} +
+ + + + + + + + +
+
{{ $xreosi }}
+
+
{{ $ocr_reference }}
+
+
{{ $siimvasi }}
+
+ + + + + + + + + + +
+
Γρ.Κατάθεσης:
+
{{ $station_apo }}
+
+
ΒΑΡΟΣ(Kgr)
+
{{ $weight }}
+
+
ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)
+
{{ $volumetric_weight ?? '0.000' }}
+
+
Τεμάχια
+
{{ $package_label }}
+
+ + {{-- ΑΠΟΣΤΟΛΕΑΣ + ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ --}} +
+ @include('core::shipping.carriers.elta.partials._party-box', [ + 'title' => 'ΑΠΟΣΤΟΛΕΑΣ', + 'lines' => $sender_lines, + ]) +
+
+
ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ
+
+
Άγνωστος
+
Ελλειπή Δ/νση
+
Απαράδεκτο
+
Άλλαξε Δ/νση
+
Αζήτητο
+
+
+ + {{-- ΠΑΡΑΛΗΠΤΗΣ + ΑΝΤΙΚΑΤΑΒΟΛΗ column --}} +
+ @include('core::shipping.carriers.elta.partials._party-box', [ + 'title' => 'ΠΑΡΑΛΗΠΤΗΣ', + 'lines' => $recipient_lines, + ]) +
+
+ @if ($antik_1 ?? null) +
{{ $antik_1 }}
+ @endif +
+ + {{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}} +
+
REFERENCE
+
{{ $order_reference }}
+
+
+
* ΕΠΙΒΑΡΥΝΣΕΙΣ *
+
+ + {{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}} +
+
* ΠΑΡΑΤΗΡΗΣΕΙΣ *
+
+ + @if ($multiPiece) +
+
* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ * {{ $package_label }}
+
+ @endif + +
+ +
+ + @if ($antik_1 ?? null) +
+
{{ $antik_1 }}
+
+ @endif + +
+ +
+
{{ $voucher_no }}
+
+ + diff --git a/resources/views/shipping/carriers/elta/partials/_main-copy.blade.php b/resources/views/shipping/carriers/elta/partials/_main-copy.blade.php new file mode 100644 index 0000000..89a7c33 --- /dev/null +++ b/resources/views/shipping/carriers/elta/partials/_main-copy.blade.php @@ -0,0 +1,153 @@ +{{-- + One "ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ" copy (delivery or sender's), + geometry transcribed directly from ELTA_PEL.sydetaE.rdlc's delivery- + copy band (Top/Left values there are relative to that band; here + they're absolute cm offsets from THIS partial's own 0,0, since the + caller wraps it in a `position:relative` container sized 15.75cm x + 8.75cm — the same width/height as the RDLC band, read off its own + rectangle8/rectangle9/rectangle11/rectangle12 extents). + + Expects: $copyType ('delivery'|'sender') and all of + EltaLabelRenderer's $data. +--}} + +
+
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.
+
Έδρα: Απελλού 1, 105 51, Αθήνα
+
Υποκ/μα: Λ. Μεσογείων 395, 153 43, Αγ. Παρασκευή
+
Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ
+
Τ. 210-6073000 | Ε. info@elta-courier.gr
+
www.eltacourier.gr
+
+
ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών
+ +{{-- Title / barcode / voucher, centered column at x=8.52..15.58 --}} +
+
ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ
+
+ +
+ {{-- The RDLC's own barcode textbox uses the "Free 3 of 9 Extended" + barcode font (literal *{voucher}* text rendered as bars by that + font) — we don't have that font, so we render a real Code 128 + barcode image above instead (visually equivalent, actually + scannable), making this second plain-text *{voucher}* line + redundant with it rather than a distinct field. --}} +
{{ $voucher_no }}
+
+ +{{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely + tabular single row of fixed-width cells, so it's built as a real + rather than independently-bordered + absolute divs: that was drawing every shared internal edge twice + (once per adjacent cell), producing a visibly doubled/thickened line + that real printed ELTA labels don't show. The table is positioned via + the same absolute top/left/width the RDLC geometry gives the row as a + whole; each . --}} +
keeps its own RDLC-derived width via
+ + + + + + + + + + +
+
Γρ.Κατάθεσης
+
{{ $station_apo }}
+
+
Γρ.Προορισμού
+
{{ $station_pros }}
+
+
Βάρος
+
{{ $weight }}
+
+
Τεμάχια
+
{{ $package_label }}
+
+
Ογκ/κο Βάρος
+
{{ $volumetric_weight ?? '0.000' }}
+
+
+
ΥΠΗΡΕΣΙΑ :
+
{{ $service_code }}
+
{{ $service_name }}
+
+ +{{-- Sender box --}} +
+
ΑΠΟΣΤΟΛΕΑΣ
+ @foreach ($sender_lines as $i => $line) + @if (trim((string) $line) !== '') +
{{ $line }}
+ @endif + @endforeach +
+ +{{-- Recipient box --}} +
+
ΠΑΡΑΛΗΠΤΗΣ
+ @foreach ($recipient_lines as $i => $line) + @if (trim((string) $line) !== '') +
{{ $line }}
+ @endif + @endforeach +
+ +{{-- Charge banner + right-column panel stack (ΧΡΕΩΣΗ / Συν.Χρέωσης / + REFERENCE / ΠΑΡΑΤΗΡΗΣΕΙΣ, and the Πρόσθετες Υπηρεσίες column beside + them): these panels sit only a hair apart (~0.09-0.10cm, the RDLC's + own geometry, left untouched), close enough that each pair's two + independent borders read as one thick/doubled line at print + resolution. Each panel below keeps only the border sides it "owns" + on a shared seam (border-top/border-left removed where the + neighbouring panel above/left already draws that same line), so + every seam is drawn exactly once while every panel's outward-facing + sides keep their border. --}} +
{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}
+ +
+
Συν.Χρέωσης (€):
+
+
+
Πρόσθετες Υπηρεσίες
+ @foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $i => $sur) + @if (!empty($sur)) +
{{ $sur }}
+ @endif + @endforeach +
+ +
+
* REFERENCE No*
+
{{ $order_reference }}
+
+ +
+
* ΠΑΡΑΤΗΡΗΣΕΙΣ *
+
+ +{{-- Right-hand signature / print-timestamp column, differs by copy type --}} +@if ($copyType === 'delivery') +
+
{{ $antik_1 ?? '' }}
+
+
+
ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ
+
ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ
+
{{ $date }} {{ $time }}
+
+@else +
+
{{ $antik_1 ?? '' }}
+
+
+
ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ
+
Ημερομηνία - Ωρα Εκτύπωσης: {{ $date }} {{ $time }}
+
+@endif diff --git a/resources/views/shipping/carriers/elta/partials/_party-box.blade.php b/resources/views/shipping/carriers/elta/partials/_party-box.blade.php new file mode 100644 index 0000000..a3bd02d --- /dev/null +++ b/resources/views/shipping/carriers/elta/partials/_party-box.blade.php @@ -0,0 +1,17 @@ +{{-- + Sender/recipient box, matching SydetaLabelE.rdlc's rectangle10/ + rectangle11 (ΑΠΟΣΤΟΛΕΑΣ/ΠΑΡΑΛΗΠΤΗΣ) layout: a bold title line, then + the RDLC's sender_1..5 / rec_1..5 fields as separate plain text + lines (ELTA's own client pre-wraps the address into these fixed- + width lines server-side, so we render each line separately rather + than reflowing the address ourselves, to match the real line + breaks). + + Expects: $title, $lines (array of up to 5 strings). +--}} +
{{ $title }}
+@foreach ($lines as $line) + @if (trim((string) $line) !== '') +
{{ $line }}
+ @endif +@endforeach diff --git a/resources/views/shipping/carriers/elta/partials/_payment-stub.blade.php b/resources/views/shipping/carriers/elta/partials/_payment-stub.blade.php new file mode 100644 index 0000000..05c554a --- /dev/null +++ b/resources/views/shipping/carriers/elta/partials/_payment-stub.blade.php @@ -0,0 +1,120 @@ +{{-- + Copy 3: the ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ/ΜΕΤΑΒΙΒΑΣΗ payment-receipt stub, + geometry transcribed from sydetaE.rdlc's third band (source Top + values there run ~19.22cm-27.0cm on the full A4 page; offsets below + are relative to this partial's own container, i.e. the RDLC's Top + minus 19.22cm). This band is narrower than copies 1/2 (starts at + x=3.01cm instead of x=0.27cm) — the RDLC leaves its left margin for + the perforated tear line + rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ" + text, reproduced by the caller outside this partial. + + Expects all of EltaLabelRenderer's $data. +--}} +
+
+ +
+
{{ $voucher_no }}
+
+
{{ $voucher_no }}
+ +{{-- 4-cell deposit/destination/weight/pieces row: same doubled-border + issue and same fix as _main-copy.blade.php's 5-cell row — a real + in place of 4 independently-bordered + absolute divs, positioned at the row's own RDLC top/left/width. --}} +
+ + + + {{-- border-bottom:none on every cell: the ΑΠΟΣΤΟΛΕΑΣ party box + immediately below (top:2.44cm, this row ends at 2.42cm) already + draws that shared line with its own border-top. --}} + + + + + + +
+
Γρ.Κατάθεσης
+
{{ $station_apo }}
+
+
Γρ.Προορισμού
+
{{ $station_pros }}
+
+
Βάρος
+
{{ $weight }}
+
+
Τεμάχια
+
{{ $package_label }}
+
+
+
{{ $service_code }}
+
{{ $service_name }}
+
+ +{{-- Below: several panel pairs sit a hair apart (or, for the + Συν.Χρέωσης/ΕΠΙΒΑΡΥΝΣΕΙΣ pair, even overlap slightly) per the RDLC's + own geometry — left untouched — close enough that two independent + borders read as one doubled/thick line. border-top/border-bottom is + removed from one side of each pair so only the remaining box's + border draws that shared line. --}} +
{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}
+ +
+
ΑΠΟΣΤΟΛΕΑΣ
+ @foreach ($sender_lines as $i => $line) + @if (trim((string) $line) !== '') +
{{ $line }}
+ @endif + @endforeach +
+
+
Συν.Χρέωσης (€):
+
Πρόσθετες Υπηρεσίες
+
+ +
+
ΠΑΡΑΛΗΠΤΗΣ
+ @foreach ($recipient_lines as $i => $line) + @if (trim((string) $line) !== '') +
{{ $line }}
+ @endif + @endforeach +
+
+
* ΕΠΙΒΑΡΥΝΣΕΙΣ *
+
+ +
+ Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι ανεπιφύλακτα +
+ +{{-- rectangle29 --}} +
+
ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ
+
{{ $date }}
+
+{{-- rectangle43 --}} +
+
ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ
+
Ημ/νία:     Ώρα:
+
+{{-- rectangle44 --}} +
+
Π Ο Σ Ο
+
{{ number_format($cod_amount ?? 0, 2) }}
+
+{{-- rectangle45: small ELTA-branded legal box; the real RDLC fills it via + its own Image18 bitmap (baked "ΠΟΡΤΑ-ΠΟΡΤΑ ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ..." + legal text + logo graphic) rather than text fields — approximated + here with our own extracted logo + the same legal text as plain + HTML, since we can't extract that embedded bitmap. --}} +
+
+ +
diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 64cd7e2..3c78f7b 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -24,6 +24,9 @@ use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; +use Modules\Core\Shipping\Carriers\Elta\EltaClient; +use Modules\Core\Shipping\Carriers\Elta\EltaFulfillmentService; +use Modules\Core\Shipping\Carriers\Elta\EltaRateDriver; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver; @@ -39,14 +42,17 @@ class ShippingServiceProvider extends ServiceProvider { $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs'); $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow'); + $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/elta.php', 'elta'); $this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs'))); $this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow'))); + $this->app->singleton(EltaClient::class, fn () => new EltaClient(config('elta'))); $this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) { return match ($params['carrier'] ?? null) { 'acs' => $app->make(AcsFulfillmentService::class), 'box-now' => $app->make(BoxNowFulfillmentService::class), + 'elta' => $app->make(EltaFulfillmentService::class), default => null, }; }); @@ -67,6 +73,7 @@ class ShippingServiceProvider extends ServiceProvider $this->publishes([ __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'), __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), + __DIR__ . '/../../config/shippingCarriers/elta.php' => config_path('shippingCarriers/elta.php'), ], 'core-config'); // Signed-URL auth only, same model as Lunar's own vendor @@ -137,6 +144,7 @@ class ShippingServiceProvider extends ServiceProvider Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class)); + Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::class)); $this->app->make(ConsoleSchedule::class) ->job(new WarmAcsAreaCacheJob) diff --git a/src/Shipping/Carriers/Elta/AreaResolver.php b/src/Shipping/Carriers/Elta/AreaResolver.php new file mode 100644 index 0000000..5ea0c62 --- /dev/null +++ b/src/Shipping/Carriers/Elta/AreaResolver.php @@ -0,0 +1,39 @@ +addDays(30), + function () use ($postcode) { + $response = $this->client->getStation([ + 'pel_tk' => $postcode, + 'sender_station' => '', + ])->throwIfError(); + + $fields = $response->data['pel_fields'] ?? []; + + return new EltaStation( + code: $fields['rec_station'] ?? '', + name: $fields['rec_station_t'] ?? '', + ); + }, + ); + } +} diff --git a/src/Shipping/Carriers/Elta/EltaClient.php b/src/Shipping/Carriers/Elta/EltaClient.php new file mode 100644 index 0000000..754c28e --- /dev/null +++ b/src/Shipping/Carriers/Elta/EltaClient.php @@ -0,0 +1,156 @@ +call('PELLOGINNEW', $params); + } + + public function createVoucher(array $params): EltaResponse + { + return $this->call('PELVGNEW', $params); + } + + public function listVouchers(array $params): EltaResponse + { + return $this->call('PELPARALVGNEW1', $params); + } + + public function deleteVoucher(array $params): EltaResponse + { + return $this->call('PELVGDEL', $params); + } + + public function getTracking(array $params): EltaResponse + { + return $this->call('PELTTNEW01', $params); + } + + public function getStation(array $params): EltaResponse + { + return $this->call('PELTKNEW', $params); + } + + private function call(string $operation, array $params): EltaResponse + { + $namespace = '/'.$operation; + + $body = ''; + foreach ($params as $key => $value) { + $body .= ''.htmlspecialchars((string) $value, ENT_XML1).''; + } + + $envelope = '' + .'' + .''.$body.''; + + $response = Http::withHeaders([ + 'SOAPAction' => 'Get', + 'Content-Type' => 'text/xml', + ]) + ->timeout($this->config['timeout']) + ->withBody($envelope, 'text/xml') + ->post($this->config['service_location']); + + if ($response->failed()) { + throw new EltaApiException('ELTA request failed: HTTP '.$response->status()); + } + + return EltaResponse::fromSoapResult($this->parseEnvelope($response->body())); + } + + private function parseEnvelope(string $xml): array + { + $dom = new DOMDocument(); + + if (! $dom->loadXML($xml)) { + throw new EltaApiException('ELTA returned a response that could not be parsed as XML.'); + } + + $xpath = new DOMXPath($dom); + $xpath->registerNamespace('soapenv', 'http://schemas.xmlsoap.org/soap/envelope/'); + $readResponse = $xpath->query('//soapenv:Body/*')->item(0); + + if (! $readResponse instanceof DOMElement) { + throw new EltaApiException('ELTA response did not contain a SOAP body.'); + } + + return $this->nodeToArray($readResponse); + } + + private function nodeToArray(DOMElement $element): array + { + $data = []; + + foreach ($element->childNodes as $child) { + if (! $child instanceof DOMElement) { + continue; + } + + $name = $child->localName; + $hasElementChildren = false; + + foreach ($child->childNodes as $grandchild) { + if ($grandchild instanceof DOMElement) { + $hasElementChildren = true; + break; + } + } + + $value = $hasElementChildren ? $this->nodeToArray($child) : trim($child->textContent); + + if (array_key_exists($name, $data)) { + if (! is_array($data[$name]) || ! array_is_list($data[$name])) { + $data[$name] = [$data[$name]]; + } + $data[$name][] = $value; + } else { + $data[$name] = $value; + } + } + + return $data; + } +} diff --git a/src/Shipping/Carriers/Elta/EltaFulfillmentService.php b/src/Shipping/Carriers/Elta/EltaFulfillmentService.php new file mode 100644 index 0000000..0e73d9c --- /dev/null +++ b/src/Shipping/Carriers/Elta/EltaFulfillmentService.php @@ -0,0 +1,249 @@ +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, + }; + } +} diff --git a/src/Shipping/Carriers/Elta/EltaLabelRenderer.php b/src/Shipping/Carriers/Elta/EltaLabelRenderer.php new file mode 100644 index 0000000..f34ebce --- /dev/null +++ b/src/Shipping/Carriers/Elta/EltaLabelRenderer.php @@ -0,0 +1,184 @@ +order; + $address = $order->shippingAddress; + $meta = $shipment->meta?->toArray() ?? []; + + $destinationStation = $this->areaResolver->resolve($address->postcode); + + $dateTime = (string) ($meta['date_time'] ?? ''); + $occurredAt = strlen($dateTime) >= 12 + ? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12)) + : now(); + + $voucherNo = $shipment->tracking_reference; + $ocrLine = (string) ($meta['ocr_line'] ?? ''); + $packageCount = (int) ($meta['package_count'] ?? 1); + + $generator = new BarcodeGeneratorPNG(); + + $data = [ + 'sender_name' => config('elta.sender_name'), + 'sender_address' => config('elta.sender_address'), + 'sender_postcode' => config('elta.sender_postcode'), + 'sender_area' => config('elta.sender_area'), + 'sender_phone' => config('elta.sender_phone'), + 'apost_code' => config('elta.apost_code'), + + 'recipient_name' => trim("{$address->first_name} {$address->last_name}"), + 'recipient_address' => $address->line_one, + 'recipient_postcode' => $address->postcode, + 'recipient_area' => $address->city, + 'recipient_phone' => $address->contact_phone, + + // Mirrors Sydeta.cs::print_vg()'s own dataRow["sender_1..5"] / + // dataRow["rec_1..5"] construction: name on its own line, then + // address, then "TK: ΤΗΛ:" — each of + // the RDLC's rectangle8/rectangle9 boxes renders these as 3 + // separate textbox lines (not 5; sender_2/sender_3 and + // rec_2/rec_3 only appear when a line's text exceeds the + // report's 40-char wrap width, which our own data never hits + // in practice for a name/address/contact triple). + 'sender_lines' => [ + config('elta.sender_name'), + config('elta.sender_address'), + 'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'), + ], + 'recipient_lines' => [ + trim("{$address->first_name} {$address->last_name}"), + $address->line_one, + 'TK:'.$address->postcode.' '.$address->city, + 'ΤΗΛ: '.$address->contact_phone, + ], + + 'date' => $occurredAt->format('d/m/Y'), + 'time' => $occurredAt->format('H:i'), + + 'voucher_no' => $voucherNo, + 'barcode_voucher' => $this->barcodeDataUri($generator, $voucherNo), + + 'weight' => $meta['weight'] ?? null, + 'package_count' => $packageCount, + 'package_label' => $packageCount > 1 + ? sprintf('001/%03d', $packageCount) + : '001', + 'multiPiece' => $packageCount > 1, + + 'station_apo' => config('elta.origin_station_code'), + 'station_pros' => $destinationStation->code, + 'station_pros_title' => $destinationStation->name, + + 'service_code' => '214', + 'service_name' => 'ΠΟΛΗ ΠΟΛΗ -ΠΘ- ΝΗΣΙ', + + 'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ', + 'siimvasi' => '131775-9', + + 'cod_amount' => $meta['cod_amount'] ?? null, + 'ocr_line' => $ocrLine, + // The A6 layout's 3-cell row shows a 13-digit reference derived + // from the OCR line, not the barcode's own voucher text — + // confirmed against the real sample: ">14260009814363<..." + // yields reference "1426000981436" (13 chars after the '>'). + 'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : '', + + 'order_reference' => (string) $order->id, + + // sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4) and the + // ΑΝΤΙΚΑΤΑΒΟΛΗ banner (antik_1) are only populated by ELTA's + // own client when the shipper ticks special-service checkboxes + // (Sydeta.cs::print_vg()'s checkBox2-5) or a COD amount is set + // — neither is tracked on our Shipment model today, so these + // render as empty boxes, matching what a plain "credit" send + // with no extras or COD looks like on a real label. + 'sur_1' => null, + 'sur_2' => null, + 'sur_3' => null, + 'sur_4' => null, + 'antik_1' => $meta['cod_amount'] ?? null ? '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *' : null, + 'volumetric_weight' => $meta['volumetric_weight'] ?? null, + + 'eltaLogo' => $this->logoDataUri(), + ]; + + $paperSize = config('elta.label_paper_size', 'a4'); + $view = $paperSize === 'a6' + ? 'core::shipping.carriers.elta.label-a6' + : 'core::shipping.carriers.elta.label-a4'; + + $pdf = Pdf::loadView($view, $data); + + // A6 = 104mm x 148mm, per RDLCPrinter.cs's own DeviceInfo + // override for printer_size==2 (1mm ≈ 2.8346pt) — note this is + // ELTA's own thermal-label size, not the ISO A6 (105x148mm) the + // old comment here named. A 4-value paper array plus an + // orientation string together confuse dompdf into doubling the + // canvas and silently overflowing to a second blank page + // (reproduced with a bare empty page); the array alone is + // already portrait (height > width), so the orientation + // argument is both redundant and the actual bug. + $paperSize === 'a6' + ? $pdf->setPaper([0, 0, 294.80, 419.53]) + : $pdf->setPaper('a4'); + + return $pdf->output(); + } + + /** + * PNG over SVG — dompdf's inline support doesn't reliably + * render the barcode generator's -based bars (confirmed: the + * SVG output rendered as literal text in a live test), while a + * base64 PNG is unambiguous. + */ + private function barcodeDataUri(BarcodeGeneratorPNG $generator, string $payload): string + { + $png = $generator->getBarcode($payload, BarcodeGeneratorPNG::TYPE_CODE_128); + + return 'data:image/png;base64,'.base64_encode($png); + } + + /** + * Embedded as a base64 data URI rather than referenced by URL — + * resources/logos/ is only copied into a consuming app's public/ on + * vendor:publish, which isn't guaranteed to have run, and dompdf + * needs either a real filesystem path or a working absolute URL. + */ + private function logoDataUri(): string + { + $path = __DIR__.'/../../../../resources/logos/elta-courier-logo.png'; + + return 'data:image/png;base64,'.base64_encode(file_get_contents($path)); + } +} diff --git a/src/Shipping/Carriers/Elta/EltaRateDriver.php b/src/Shipping/Carriers/Elta/EltaRateDriver.php new file mode 100644 index 0000000..34b7389 --- /dev/null +++ b/src/Shipping/Carriers/Elta/EltaRateDriver.php @@ -0,0 +1,70 @@ +cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) { + return null; + } + + return $this->resolveFixedPrice( + $shippingOptionRequest->shippingRate, + $shippingOptionRequest->shippingRate->shippingMethod, + $shippingOptionRequest->cart, + ); + } + + public function on(ShippingRate $shippingRate): self + { + $this->shippingRate = $shippingRate; + + return $this; + } +} diff --git a/src/Shipping/Carriers/Elta/EltaResponse.php b/src/Shipping/Carriers/Elta/EltaResponse.php new file mode 100644 index 0000000..2475ae5 --- /dev/null +++ b/src/Shipping/Carriers/Elta/EltaResponse.php @@ -0,0 +1,43 @@ +hasError) { + throw new EltaApiException($this->errorMessage ?? 'Unknown ELTA API error', $this->data); + } + + return $this; + } +} diff --git a/src/Shipping/Carriers/Elta/EltaStation.php b/src/Shipping/Carriers/Elta/EltaStation.php new file mode 100644 index 0000000..0979e00 --- /dev/null +++ b/src/Shipping/Carriers/Elta/EltaStation.php @@ -0,0 +1,11 @@ +