Feat: Adding Elta integrations

This commit is contained in:
2026-09-29 14:31:30 +03:00
parent 6f6ae03428
commit 1b06486b2b
17 changed files with 1485 additions and 1 deletions
+3 -1
View File
@@ -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",
+70
View File
@@ -0,0 +1,70 @@
<?php
/*
|--------------------------------------------------------------------------
| ELTA Courier credentials
|--------------------------------------------------------------------------
|
| ELTA's API is SOAP (unlike ACS's JSON gateway or Box Now's REST +
| OAuth2). Two operation families live at the same endpoint — see
| Modules\Core\Shipping\Carriers\Elta\EltaClient's docblock for why only
| the "*NEW" family (create/track/station/cancel) is used; the old
| family (including its label-print operation) is unreachable with this
| account and isn't called anywhere in this integration. Labels are
| rendered locally instead — see
| Modules\Core\Shipping\Carriers\Elta\EltaLabelRenderer — which is why
| sender identity (name/address, unlike PELVGNEW's request which needs
| only apost_code) is configured here.
|
| Set these via environment variables — never commit real values.
|
| ELTA_SERVICE_LOCATION SOAP endpoint (defaults to the live production
| host; only needed if ELTA gives you a separate
| sandbox host).
| ELTA_USER_CODE Account user code (pel_user_code / pel_user).
| ELTA_USER_PASS Account security code — not used by anything the
| *NEW family calls; kept only in case ELTA ever
| asks for it.
| ELTA_APOST_CODE Sender/account code (pel_apost_code).
| ELTA_APOST_SUB_CODE Sender sub-code (pel_apost_sub_code).
| ELTA_SENDER_NAME Sender name printed on the label — PELVGNEW's
| own request has no such field, so this only
| matters for our own locally-rendered label.
| ELTA_SENDER_ADDRESS Sender street address printed on the label.
| ELTA_SENDER_POSTCODE Sender postcode printed on the label.
| ELTA_SENDER_AREA Sender area/city printed on the label.
| ELTA_SENDER_PHONE Sender phone printed on the label.
| ELTA_ORIGIN_STATION_CODE This account's home ELTA station code — shown
| on the label as "Γ.Κατάθεσης"/"Από". ELTA's own
| client gets this from a PELLOGINNEW response
| (user_station); configured here instead so a
| label print doesn't need an extra API call.
| ELTA_LABEL_PAPER_SIZE "a4" (default, 3 copies per page) or "a6"
| (single thermal label) — matches the real
| client's own one-time printer-setup choice, not
| varied per shipment.
|
*/
return [
'service_location' => 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),
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry is transcribed directly from ELTA_PEL.sydetaE.rdlc
(the "delivery copy" RDLC ELTA's own client selects for
printer_size==1, i.e. plain A4, per Sydeta.cs::print_vg()) —
every .mc-*/.ps-* absolute position below is that report's
own Top/Left in cm, walked through its Rectangle/Textbox
nesting so each value is already relative to its own
containing band. Static label text and font sizes/weights
also come from the RDLC's own Textbox/Style elements, not
guessed from the screenshot in ELTA's setup manual (that
manual image was used only as a sanity check afterwards).
--}}
@page { margin: 0; size: 21cm 29.7cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.5pt; color: #000; }
.page { position: relative; width: 21cm; height: 29.7cm; }
/* Each RDLC "band" (one ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ copy) is its own
positioning context, so every child offset below is a direct
transcription of the RDLC's own relative-to-band Top/Left —
no manual offset arithmetic. */
.band { position: absolute; left: 0.27cm; }
.band > * { position: absolute; }
.mc-logo, .mc-company, .mc-box, .mc-cell, .mc-partybox { position: absolute; }
.mc-logo img { width: 100%; height: 100%; object-fit: contain; }
.mc-company { font-size: 4.6pt; line-height: 1.25; }
.mc-company-name { font-weight: bold; font-size: 5.2pt; }
.mc-text.mc-small { font-size: 4.6pt; }
.mc-box { border: 1px solid #000; box-sizing: border-box; }
.mc-title { position: absolute; font-weight: bold; font-size: 8pt; text-align: center; white-space: nowrap; }
.mc-barcode { position: absolute; text-align: center; }
.mc-barcode img { height: 26px; }
.mc-voucher { position: absolute; text-align: center; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.5px; }
.mc-copylabel { position: absolute; text-align: center; font-weight: bold; font-size: 8pt; }
.mc-cell { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; }
.mc-cell-label { font-size: 5.5pt; }
.mc-cell-val { font-weight: bold; font-size: 8pt; margin-top: 3px; }
.mc-cell-service { text-align: center; }
/* 5-cell deposit/destination/weight/pieces/volumetric row: a real
table with border-collapse so each internal shared edge is
drawn once, not twice (see _main-copy.blade.php comment). */
.mc-cell-row { position: absolute; border-collapse: collapse; table-layout: fixed; }
.mc-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; vertical-align: top; }
.mc-partybox { border: 1px solid #000; box-sizing: border-box; }
.mc-party-title { position: absolute; font-weight: bold; font-size: 9pt; }
.mc-party-line { position: absolute; font-size: 6.3pt; white-space: nowrap; overflow: hidden; }
.mc-charge { text-align: center; font-weight: bold; font-size: 8pt; box-sizing: border-box; padding-top: 2px; }
/* Payment stub (copy 3) reuses the .mc-* classes but at smaller
scale, matching the RDLC's own narrower band. */
.ps-brandbox { border: 1.5px solid #c8102e; box-sizing: border-box; text-align: center; padding-top: 4px; }
.ps-brand-logo img { width: 60%; margin: 0 auto 8px; display: block; }
.ps-brand-tag { color: #c8102e; font-weight: bold; font-size: 9pt; }
.ps-brandbox-small { border: 1px solid #000; box-sizing: border-box; text-align: center; padding: 2px; }
.ps-brand-logo-sm img { width: 40%; margin: 2px auto; display: block; }
.ps-legal-sm { font-size: 4.6pt; line-height: 1.3; }
/* Perforated tear-off strip on the right of copy 1 only, with
the rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ" label — RDLC's
image10 (x=15.42cm) is the dashed perforation line itself;
reproduced here as a CSS dashed border since we can't extract
that embedded bitmap. transform:rotate() (not
writing-mode:vertical-rl, confirmed broken in dompdf) rotates
a normal-flow block. */
.stub-strip { position: absolute; top: 2.25cm; left: 15.42cm; width: 5.58cm; height: 6.16cm; border-left: 1px dashed #000; }
.stub-vtext {
position: absolute; top: 2.6cm; left: -1.3cm; width: 6cm;
text-align: center; font-size: 6pt; font-weight: bold;
white-space: nowrap; transform: rotate(-90deg);
}
.cut-line { position: absolute; left: 0; width: 20.9cm; border-top: 1px dashed #000; text-align: center; font-weight: bold; font-size: 7pt; }
.cut-line span { position: relative; top: -5px; background: #fff; padding: 0 6px; }
.footer-ocr { position: absolute; top: 27.35cm; left: 0; width: 21cm; text-align: center; font-family: monospace; font-size: 8pt; letter-spacing: 1.5px; }
</style>
</head>
<body>
<div class="page">
{{-- Copy 1: delivery copy — RDLC band top=0 --}}
<div class="band" style="top:0.05cm; width:15.75cm; height:8.7cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'delivery'])
</div>
<div class="stub-strip">
<div class="stub-vtext">3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ</div>
</div>
<div class="cut-line" style="top:9.0cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 2: sender's copy — RDLC band top≈8.94cm on the full page --}}
<div class="band" style="top:9.05cm; width:15.75cm; height:9.9cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'sender'])
</div>
<div class="cut-line" style="top:18.75cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 3: ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ stub — RDLC band top≈19.22cm --}}
<div style="position:absolute; top:19.05cm; left:0.27cm; width:20.4cm; font-weight:bold; font-size:7pt; text-align:center;">
ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ &nbsp; Ο. Αριθμός Λογ/μού Ταχυπληρωμής {{ $siimvasi }}
</div>
<div class="band" style="top:19.45cm; width:16.75cm; height:7.4cm;">
@include('core::shipping.carriers.elta.partials._payment-stub')
</div>
<div class="cut-line" style="top:26.85cm;"><span>ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ</span></div>
<div class="footer-ocr">&gt;{{ $ocr_line }}&lt;</div>
</div>
</body>
</html>
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry transcribed directly from ELTA_PEL.SydetaLabelE.rdlc
— the RDLC ELTA's own client selects for printer_size==2
(the A6/"thermal label" printer-setup radio button), per
Sydeta.cs::print_vg(); RDLCPrinter.cs overrides the actual
print DeviceInfo to 10.4cm x 14.8cm for this printer_size
regardless of what PageWidth/PageHeight the RDLC itself
declares (all ELTA RDLCs declare A4 internally). Every
.a6-* absolute position below is that report's own
Top/Left in cm.
--}}
@page { margin: 0; size: 10.4cm 14.8cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.3pt; color: #000; }
{{--
height is deliberately a hair under the true 14.8cm page —
dompdf's border-box math isn't quite exact at this scale, and
a .page box sized to exactly fill the page (even with
box-sizing:border-box) was empirically confirmed to overflow
a fraction of a point past the page canvas and silently push
a second, blank page (reproduced with an otherwise-empty
.page div: only the border's presence, not any content,
triggered it — 14.7cm was stable, 14.75cm was not).
--}}
.page { position: relative; width: 10.4cm; height: 14.65cm; border: 1.5px solid #000; box-sizing: border-box; overflow: hidden; }
.page > * { position: absolute; box-sizing: border-box; }
.a6-logo img { width: 100%; height: 100%; object-fit: contain; }
.a6-service-box { border: 1px solid #000; text-align: center; }
.a6-service-code { font-weight: bold; font-size: 7pt; }
.a6-service-name { font-weight: bold; font-size: 6pt; text-align: center; }
.a6-title { font-weight: bold; font-size: 7pt; }
.a6-datetime { font-size: 6pt; }
.a6-copy { font-weight: bold; font-size: 7pt; text-align: right; }
.a6-station-box { border: 1px solid #000; }
.a6-station-label { font-size: 6pt; font-weight: bold; }
.a6-station-val { font-weight: bold; font-size: 8pt; }
.a6-cell { border: 1px solid #000; padding: 1px 3px; }
.a6-cell-label { font-size: 6pt; }
.a6-cell-val { font-weight: bold; font-size: 8pt; }
/* ΧΡΕΩΣΗ/REFERENCE/ΣΥΜΒΑΣΗ row and the Γρ.Κατάθεσης/ΒΑΡΟΣ/
ΟΓΚΟΜΕΤΡΙΚΟ/Τεμάχια row: real tables with border-collapse so
each internal shared edge is drawn once (see label-a6.blade.php
comment above their markup). */
.a6-cell-row { position: absolute; border-collapse: collapse; border-spacing: 0; table-layout: fixed; }
.a6-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 3px; vertical-align: top; overflow: hidden; }
.a6-box { border: 1px solid #000; padding: 2px 3px; }
.a6-party-title { font-weight: bold; font-size: 7pt; }
.a6-party-line { font-size: 8pt; white-space: nowrap; overflow: hidden; }
.a6-return-title { font-weight: bold; font-size: 6pt; }
.a6-return-item { font-size: 6pt; }
.a6-checkbox { border: 1px solid #000; display: inline-block; width: 6px; height: 6px; margin-right: 3px; vertical-align: middle; }
.a6-small-title { font-weight: bold; font-size: 5pt; }
.a6-small-val { font-size: 6pt; }
.a6-legal { font-size: 5pt; text-align: center; line-height: 1.4; }
.a6-barcode { text-align: center; }
.a6-barcode img { height: 30px; }
.a6-voucher { text-align: center; font-weight: bold; font-size: 12pt; letter-spacing: 1px; }
</style>
</head>
<body>
<div class="page">
{{-- Header: logo (0.10,0.12,1.66x1.26) + service box (6.42,0.10,3.93x0.84) --}}
<div class="a6-logo" style="top:0.12cm;left:0.10cm;width:1.66cm;height:1.26cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="a6-service-box" style="top:0.10cm;left:6.42cm;width:3.93cm;height:0.84cm;">
<div class="a6-service-code" style="position:absolute;top:0.02cm;left:0.09cm;">ΥΠΗΡΕΣΙΑ: {{ $service_code }}</div>
<div class="a6-service-name" style="position:absolute;top:0.40cm;left:0.04cm;width:3.81cm;">{{ $service_name }}</div>
</div>
<div class="a6-licence-box" style="top:1.05cm;left:6.42cm;width:3.88cm;height:0.62cm;border:1px solid #000;text-align:center;font-size:5.3pt;font-weight:bold;line-height:1.3;padding-top:2px;">
ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια<br>Ταχ/κων Υπηρεσιών
</div>
<div class="a6-datetime" style="top:1.68cm;left:0.13cm;width:1.68cm;">{{ $date }}</div>
<div class="a6-title" style="top:1.79cm;left:1.93cm;width:5.89cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
{{-- 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. --}}
<div class="a6-datetime" style="top:2.00cm;left:0.16cm;width:1.60cm;">{{ $time }}</div>
<div class="a6-station-box" style="top:2.33cm;left:0.10cm;width:10.20cm;height:0.44cm;">
<div class="a6-station-label" style="position:absolute;top:0.05cm;left:0.10cm;">Γρ. Επίδοσης :</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:2.50cm;">{{ $station_pros }}</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:3.90cm;width:6.06cm;">{{ $station_pros_title }}</div>
</div>
{{-- ΧΡΕΩΣΗ / 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
<table border-collapse:collapse> 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. --}}
<table class="a6-cell-row" style="top:2.82cm;left:0.10cm;width:10.16cm;">
<colgroup>
<col style="width:5.41cm;"><col style="width:2.84cm;"><col style="width:1.91cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.47cm;border-bottom:none;">
<div style="font-size:7pt;">{{ $xreosi }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $ocr_reference }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $siimvasi }}</div>
</td>
</tr>
</table>
<table class="a6-cell-row" style="top:3.39cm;left:0.10cm;width:9.97cm;">
<colgroup>
<col style="width:2.00cm;"><col style="width:2.00cm;"><col style="width:4.12cm;"><col style="width:1.85cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Γρ.Κατάθεσης:</div>
<div class="a6-cell-val">{{ $station_apo }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val">{{ $weight }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Τεμάχια</div>
<div class="a6-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
{{-- ΑΠΟΣΤΟΛΕΑΣ + ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ --}}
<div class="a6-box" style="top:4.43cm;left:0.10cm;width:7.24cm;height:2.44cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΑΠΟΣΤΟΛΕΑΣ',
'lines' => $sender_lines,
])
</div>
<div class="a6-box" style="top:4.43cm;left:7.55cm;width:2.80cm;height:2.44cm;">
<div class="a6-return-title">ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ</div>
<div style="margin-top:4px;">
<div class="a6-return-item"><span class="a6-checkbox"></span>Άγνωστος</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Ελλειπή Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Απαράδεκτο</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Άλλαξε Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Αζήτητο</div>
</div>
</div>
{{-- ΠΑΡΑΛΗΠΤΗΣ + ΑΝΤΙΚΑΤΑΒΟΛΗ column --}}
<div class="a6-box" style="top:6.97cm;left:0.10cm;width:7.26cm;height:2.78cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΠΑΡΑΛΗΠΤΗΣ',
'lines' => $recipient_lines,
])
</div>
<div class="a6-box" style="top:6.97cm;left:7.48cm;width:2.85cm;height:2.79cm;">
@if ($antik_1 ?? null)
<div style="font-size:6pt;">{{ $antik_1 }}</div>
@endif
</div>
{{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}}
<div class="a6-box" style="top:9.84cm;left:0.10cm;width:5.81cm;height:0.69cm;">
<div class="a6-small-title">REFERENCE</div>
<div class="a6-small-val" style="margin-top:3px; font-weight:bold;">{{ $order_reference }}</div>
</div>
<div class="a6-box" style="top:9.84cm;left:6.00cm;width:4.37cm;height:1.78cm;">
<div class="a6-small-title">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
</div>
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:1.01cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</div>
@if ($multiPiece)
<div class="a6-box" style="top:11.68cm;left:0.10cm;width:10.23cm;height:0.54cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:2px;">* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ * {{ $package_label }}</div>
</div>
@endif
<div class="a6-box" style="top:12.20cm;left:0.11cm;width:10.23cm;height:0.57cm;">
<div class="a6-legal">
Ισχύουν οι Γενικοί Οροι Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr<br>
και είναι διαθέσιμοι σε ολα τα καταστήματα της εταιρίας.
</div>
</div>
@if ($antik_1 ?? null)
<div class="a6-box" style="top:12.70cm;left:0.10cm;width:10.23cm;height:0.51cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:4px;">{{ $antik_1 }}</div>
</div>
@endif
<div class="a6-barcode" style="top:13.20cm;left:0.10cm;width:10.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="a6-voucher" style="top:13.95cm;left:0.10cm;width:10.30cm;">{{ $voucher_no }}</div>
</div>
</body>
</html>
@@ -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.
--}}
<div class="mc-logo" style="top:0.09cm;left:0.32cm;width:2.65cm;height:1.93cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="mc-company" style="top:0.12cm;left:3.15cm;width:5.19cm;">
<div class="mc-company-name">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div>
<div>Έδρα: Απελλού 1, 105 51, Αθήνα</div>
<div>Υποκ/μα: Λ. Μεσογείων 395, 153 43, Αγ. Παρασκευή</div>
<div>Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ</div>
<div>Τ. 210-6073000 | Ε. info@elta-courier.gr</div>
<div>www.eltacourier.gr</div>
</div>
<div class="mc-text mc-small" style="top:1.80cm;left:3.15cm;width:5.03cm;">ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών</div>
{{-- Title / barcode / voucher, centered column at x=8.52..15.58 --}}
<div class="mc-box" style="top:0.05cm;left:8.52cm;width:7.06cm;height:1.75cm;">
<div class="mc-title" style="top:0.19cm;left:0.05cm;width:6.85cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="mc-barcode" style="top:0.57cm;left:0.24cm;width:6.67cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
{{-- 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. --}}
<div class="mc-voucher" style="top:1.29cm;left:0.24cm;width:6.64cm;">{{ $voucher_no }}</div>
</div>
{{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely
tabular single row of fixed-width cells, so it's built as a real
<table border-collapse:collapse> 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 <td> keeps its own RDLC-derived width via <col>. --}}
<table class="mc-cell-row" style="top:2.20cm;left:0.27cm;width:8.44cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;"><col style="width:2.83cm;">
</colgroup>
<tr>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Ογκ/κο Βάρος</div>
<div class="mc-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div>
</td>
</tr>
</table>
<div class="mc-cell mc-cell-service" style="top:2.20cm;left:9.19cm;width:6.19cm;height:0.82cm;">
<div style="position:absolute;top:0.05cm;left:0.32cm;font-size:6.5pt;">ΥΠΗΡΕΣΙΑ :</div>
<div style="position:absolute;top:0.03cm;left:3.21cm;font-size:7.5pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.42cm;left:0.05cm;width:6.08cm;text-align:center;font-size:7.5pt;">{{ $service_name }}</div>
</div>
{{-- Sender box --}}
<div class="mc-box mc-partybox" style="top:3.12cm;left:0.27cm;width:6.24cm;height:2.53cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.10cm;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.53 + $i * 0.395 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- Recipient box --}}
<div class="mc-box mc-partybox" style="top:5.76cm;left:0.29cm;width:6.20cm;height:2.68cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.05cm;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.54 + $i * 0.445 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- 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. --}}
<div class="mc-box mc-charge" style="top:3.09cm;left:6.60cm;width:8.78cm;height:0.48cm;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box" style="top:3.66cm;left:6.60cm;width:4.37cm;height:0.44cm;border-top:none;">
<div style="position:absolute;top:0.05cm;left:0.05cm;font-size:7pt;font-weight:bold;">Συν.Χρέωσης (€):</div>
</div>
<div class="mc-box" style="top:3.66cm;left:11.07cm;width:4.31cm;height:2.65cm;border-top:none;">
<div style="position:absolute;top:0.04cm;left:0.05cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
@foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $i => $sur)
@if (!empty($sur))
<div style="position:absolute;top:{{ 0.35 + $i * 0.365 }}cm;left:0.08cm;width:4.18cm;font-size:6pt;">{{ $sur }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:4.20cm;left:6.59cm;width:4.37cm;height:0.67cm;border-top:none;border-right:none;">
<div style="position:absolute;top:0.03cm;left:0.05cm;font-size:6pt;">* REFERENCE No*</div>
<div style="position:absolute;top:0.31cm;left:0.05cm;font-size:6.5pt;">{{ $order_reference }}</div>
</div>
<div class="mc-box" style="top:4.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</div>
{{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@if ($copyType === 'delivery')
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div>
</div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;">ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ</div>
<div style="position:absolute;top:0.38cm;left:0.08cm;font-size:6pt;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">{{ $date }} {{ $time }}</div>
</div>
@else
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div>
</div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">Ημερομηνία - Ωρα Εκτύπωσης: {{ $date }} {{ $time }}</div>
</div>
@endif
@@ -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).
--}}
<div class="pb-title">{{ $title }}</div>
@foreach ($lines as $line)
@if (trim((string) $line) !== '')
<div class="pb-line">{{ $line }}</div>
@endif
@endforeach
@@ -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.
--}}
<div class="mc-box" style="top:0.00cm;left:6.44cm;width:6.64cm;height:1.15cm;">
<div class="mc-barcode" style="top:0.10cm;left:0.22cm;width:6.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="mc-voucher" style="top:0.75cm;left:0.24cm;width:6.14cm;">{{ $voucher_no }}</div>
</div>
<div class="mc-copylabel" style="position:absolute;top:0.10cm;left:0.00cm;width:3.92cm;font-size:8pt;font-weight:bold;">{{ $voucher_no }}</div>
{{-- 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
<table border-collapse:collapse> in place of 4 independently-bordered
absolute divs, positioned at the row's own RDLC top/left/width. --}}
<table class="mc-cell-row" style="top:1.60cm;left:0.02cm;width:5.61cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;">
</colgroup>
{{-- 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. --}}
<tr>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
<div class="mc-box" style="top:1.60cm;left:5.90cm;width:4.87cm;height:0.40cm;">
<div style="position:absolute;top:0.05cm;left:0.13cm;font-size:6pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.03cm;left:0.85cm;width:3.89cm;font-size:6pt;">{{ $service_name }}</div>
</div>
{{-- 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. --}}
<div class="mc-box mc-charge" style="top:2.02cm;left:5.90cm;width:4.87cm;height:0.37cm;font-size:5.4pt;padding-top:4px;white-space:nowrap;overflow:hidden;border-top:none;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box mc-partybox" style="top:2.44cm;left:0.00cm;width:5.98cm;height:0.91cm;overflow:hidden;">
<div class="mc-party-title" style="top:0.03cm;left:0.10cm;font-size:6.5pt;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.30 + $i * 0.19 }}cm;left:0.06cm;width:5.85cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:2.45cm;left:6.06cm;width:4.71cm;height:1.17cm;border-bottom:none;">
<div style="position:absolute;top:0.02cm;left:0.16cm;font-size:6pt;">Συν.Χρέωσης (€):</div>
<div style="position:absolute;top:0.30cm;left:0.16cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
</div>
<div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;">
<div class="mc-party-title" style="top:0.06cm;left:0.10cm;font-size:6.5pt;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.34 + $i * 0.26 }}cm;left:0.10cm;width:5.79cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:3.28cm;left:6.06cm;width:4.71cm;height:1.41cm;">
<div style="position:absolute;top:0.05cm;left:0.14cm;font-size:6pt;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
</div>
<div style="position:absolute;top:4.95cm;left:0.05cm;width:8.75cm;font-size:4.6pt;line-height:1.25;white-space:nowrap;">
Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι ανεπιφύλακτα
</div>
{{-- rectangle29 --}}
<div class="mc-box" style="top:5.46cm;left:0.03cm;width:2.75cm;height:1.66cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">{{ $date }}</div>
</div>
{{-- rectangle43 --}}
<div class="mc-box" style="top:5.47cm;left:2.89cm;width:3.76cm;height:1.66cm;">
<div style="position:absolute;top:0.09cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
</div>
{{-- rectangle44 --}}
<div class="mc-box" style="top:5.48cm;left:6.76cm;width:3.99cm;height:1.68cm;text-align:center;">
<div style="position:absolute;top:0.04cm;left:0;width:100%;font-size:7pt;">Π Ο Σ Ο</div>
<div style="position:absolute;top:0.75cm;left:0;width:100%;font-size:13pt;font-weight:bold;">{{ number_format($cod_amount ?? 0, 2) }}</div>
</div>
{{-- 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. --}}
<div class="mc-box ps-brandbox-small" style="top:5.55cm;left:10.84cm;width:2.72cm;height:1.61cm;">
<div class="ps-brand-logo-sm"><img src="{{ $eltaLogo }}" alt=""></div>
<div class="ps-legal-sm">
<strong>ΠΟΡΤΑ-ΠΟΡΤΑ</strong><br>
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.<br>
ΕΕΤΤ ΑΜ: 99-150
</div>
</div>
@@ -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)
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Illuminate\Support\Facades\Cache;
/**
* Resolves a Greek postcode to its ELTA station code/name via PELTKNEW
* (the *NEW-family replacement for the old, differently-authenticated
* PELSTATION — see EltaClient's docblock). Unlike Acs\AreaResolver,
* there's no daily bulk-warm job — PELTKNEW only accepts a single pel_tk
* (postcode), with no fetch-all variant to warm a whole-country cache
* from, so this caches per-postcode on first lookup instead.
*/
class AreaResolver
{
public function __construct(private readonly EltaClient $client) {}
public function resolve(string $postcode): EltaStation
{
return Cache::remember(
"elta.station.{$postcode}",
now()->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'] ?? '',
);
},
);
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* SOAP transport for ELTA Courier's web services.
*
* ELTA runs two parallel operation families at the same endpoint
* (212.205.47.226:9003). The original CREATEAWB/PELB64VG/PELTT01/PELSTATION
* set (sourced from the unofficial chinchillabrains/eltaws reference client)
* rejects this account's credentials with st_flag=3 ("invalid password").
* Decompiling ELTA's own official desktop client (ELTA_PEL.exe, from their
* public .msi installer) showed it never calls CREATEAWB at all — it
* exclusively uses a newer "*NEW" family (PELVGNEW, PELLOGINNEW,
* PELTTNEW01, PELTKNEW, PELVGDEL, PELPARALVGNEW1). PELVGNEW was
* live-verified directly against the production endpoint with this same
* account and succeeded (st_flag=0, real voucher number returned) — it
* requires no password at all, just the account/user code pair.
*
* No .wsdl files exist for the *NEW family (only reconstructed from the
* decompiled C# proxy classes' method signatures). PHP's SoapClient in
* non-WSDL mode refuses to serialize these calls at all (SoapFault "Error
* in client request message" regardless of style/use/SoapVar wrapping —
* this legacy Micro Focus/CICS gateway's document/literal shape doesn't
* match what ext-soap's non-WSDL client is willing to build), so *NEW
* operations are sent as hand-built SOAP 1.1 envelopes over plain HTTP —
* proven directly against the live endpoint — with the response parsed
* back via DOMDocument/DOMXPath.
*
* The old family's label operation (PELB64VG) is deliberately NOT
* exposed here — it fails with the same st_flag=3 as CREATEAWB (same
* legacy family), and has no *NEW replacement since ELTA's own client
* renders labels locally instead of fetching them (see
* EltaLabelRenderer).
*/
class EltaClient
{
public function __construct(private readonly array $config) {}
public function login(array $params): EltaResponse
{
return $this->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 .= '<cre:'.$key.'>'.htmlspecialchars((string) $value, ENT_XML1).'</cre:'.$key.'>';
}
$envelope = '<?xml version="1.0" encoding="UTF-8"?>'
.'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cre="'.$namespace.'">'
.'<soapenv:Header/><soapenv:Body><cre:READ>'.$body.'</cre:READ></soapenv:Body></soapenv:Envelope>';
$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;
}
}
@@ -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,
};
}
}
@@ -0,0 +1,184 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon;
use Modules\Core\Shipping\Models\Shipment;
use Picqer\Barcode\BarcodeGeneratorPNG;
/**
* Renders an ELTA shipping label as a PDF ourselves — there is no *NEW
* operation for fetching one (see EltaFulfillmentService::printLabel()'s
* docblock), so this reproduces ELTA's own official label design instead
* of a server-fetched file.
*
* Field layout/text is traced directly from ELTA's own 2024 Customer
* Client Installation Manual, which includes real sample images of both
* the A4 voucher (3 stacked copies: delivery copy, sender's copy, a
* ΤΑΧΥΠΛΗΡΩΜΗ payment-receipt stub) and the A6 thermal label (single
* copy, with a return-reason checkbox list the A4 version doesn't have)
* — not from decompiled client code, which an earlier version of this
* class was built from and turned out to look nothing like a real
* label. The barcode is Code 128 (single barcode per label, matching
* both real samples) encoding the voucher number itself — an earlier
* version guessed Code 39 with a second "OCR" barcode from misreading
* decompiled code; neither survived comparison against the real
* samples.
*/
class EltaLabelRenderer
{
public function __construct(private readonly AreaResolver $areaResolver) {}
public function render(Shipment $shipment): string
{
$order = $shipment->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:<postcode> <area> ΤΗΛ:<phone>" — 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 <svg> support doesn't reliably
* render the barcode generator's <rect>-based bars (confirmed: the
* SVG output rendered as literal text in a live test), while a
* base64 PNG <img> 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));
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
/**
* None of ELTA's four SOAP operations (create/label/track/station) expose
* a price-quote/calculation call, so — like Box Now — this always
* resolves the method's own charge_by + price-break configuration, never
* live pricing. Does not implement SupportsLivePricing.
*
* Implements SupportsCashCollection: an ELTA courier hands a parcel to
* the recipient in person, the same fact as AcsRateDriver, unlike Box
* Now's unattended lockers.
*/
class EltaRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, SupportsCashCollection
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'ELTA Courier';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function collectsCash(): bool
{
return true;
}
public function description(): string
{
return 'Delivery via ELTA Courier.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->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;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* Normalizes a SoapClient::READ() stdClass result — every ELTA operation's
* response carries the same st_flag (0=success, 1-99=error)/st_title
* shape, live-verified against the real production endpoint (see
* EltaClient's own docblock). Mirrors Acs\AcsResponse's hasError/data
* shape so the rest of the integration (rate driver, fulfillment service)
* reads identically regardless of transport (SOAP here vs. ACS's JSON).
*/
class EltaResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $data,
) {}
public static function fromSoapResult(mixed $result): self
{
$data = json_decode(json_encode($result), true) ?? [];
$flag = (int) ($data['st_flag'] ?? 0);
return new self(
hasError: $flag !== 0,
errorMessage: $flag !== 0 ? ($data['st_title'] ?? 'Unknown ELTA error') : null,
data: $data,
);
}
public function throwIfError(): self
{
if ($this->hasError) {
throw new EltaApiException($this->errorMessage ?? 'Unknown ELTA API error', $this->data);
}
return $this;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
class EltaStation
{
public function __construct(
public readonly string $code,
public readonly string $name,
) {}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta\Exceptions;
use RuntimeException;
use Throwable;
class EltaApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $data = [], ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}