value => '004', ExtraService::TimedDelivery->value => '003', ExtraService::SpecialHandling->value => '002', ExtraService::Insurance->value => 'pel_asf_poso', ]; } public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; $weight = $request->weight ?? 0.5; $surcharges = $this->surchargeCodes($request); $params = [ 'pel_apost_code' => config('elta.apost_code'), 'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"), 'pel_paral_address' => $address->line_one, 'pel_paral_area' => $address->city, 'pel_paral_tk' => $address->postcode, 'pel_paral_thl_1' => $address->contact_phone, 'pel_paral_thl_2' => '', 'pel_service' => '', 'pel_baros' => number_format($weight, 3, '.', ''), 'pel_baros_xyz' => '', 'pel_x' => '', 'pel_y' => '', 'pel_z' => '', 'pel_temaxia' => (string) $request->packageCount, 'pel_paral_sxolia' => '', 'pel_sur_1' => $surcharges[0] ?? '', 'pel_sur_2' => $surcharges[1] ?? '', 'pel_sur_3' => $surcharges[2] ?? '', // pel_ant_poso is the cash-on-delivery amount; pel_ant_poso1..4 // are cheques (with dates) in ELTA's client — never used here. 'pel_ant_poso' => '', 'pel_ant_poso1' => '', 'pel_ant_poso2' => '', 'pel_ant_poso3' => '', 'pel_ant_poso4' => '', 'pel_ant_date1' => '', 'pel_ant_date2' => '', 'pel_ant_date3' => '', 'pel_ant_date4' => '', 'pel_asf_poso' => $request->has(ExtraService::Insurance) && $request->insuranceAmount ? number_format($request->insuranceAmount, 2, '.', '') : '', 'pel_user' => config('elta.user_code'), // Our order reference — comes back in ELTA's lists and tracking, // which is how we find the pending voucher's id below and match // vouchers to orders on the Carrier Vouchers screen. 'pel_ref_no' => (string) $order->reference, // "0" = save without issuing (see this class's docblock). 'pel_insert_flag' => '0', 'pel_paral_code' => '', 'pel_retur_code' => '', ]; $codAmount = null; if ($request->paymentMode === 'cod') { $codAmount = $request->amountToCollect ?? $order->total->decimal; $params['pel_ant_poso'] = number_format($codAmount, 2, '.', ''); } $this->client->createVoucher($params)->throwIfError(); $pieces = max(1, $request->packageCount); // PELVGNEW doesn't return the new voucher's id, so find it in the // pending list by our reference. Saved even if that lookup fails — // the voucher exists at ELTA either way, and printing retries it. $master = Shipment::create([ 'order_id' => $order->id, 'carrier' => 'elta', 'source' => Shipment::SOURCE_CREATED, 'tracking_reference' => null, 'meta' => [ 'carrier_id' => $this->findPendingVoucherId((string) $order->reference), 'reference' => (string) $order->reference, 'weight' => $weight, 'package_count' => $pieces, 'cod_amount' => $codAmount, 'services' => $request->serviceValues(), 'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null, ...($pieces > 1 ? ['piece' => 1, 'pieces' => $pieces] : []), ], ]); // One row per extra parcel straight away, so the order shows every // parcel; ELTA gives them their own voucher numbers only when the // main voucher is issued (issue()). They belong to the main voucher: // no COD or services of their own. for ($piece = 2; $piece <= $pieces; $piece++) { Shipment::create([ 'order_id' => $order->id, 'carrier' => 'elta', 'source' => Shipment::SOURCE_CREATED, 'tracking_reference' => null, 'meta' => [ 'master_shipment_id' => $master->id, 'reference' => (string) $order->reference, 'piece' => $piece, 'pieces' => $pieces, 'weight' => $weight, 'package_count' => $pieces, ], ]); } return $master; } /** * Like ELTA's own client: printing the main voucher of a multi-parcel * send prints every parcel (main first, then each child); printing a * child prints just that parcel. Either way the main voucher is issued * first — that's what gives the children their numbers. */ public function printLabel(Shipment $shipment): string { $master = $this->masterOf($shipment); $this->issue($master); $labels = $shipment->is($master) ? collect([$master->refresh(), ...$this->childrenOf($master)]) : collect([$shipment->refresh()]); $pdf = $this->labelRenderer->renderMany($labels); Shipment::whereIn('id', $labels->pluck('id'))->update(['label_printed_at' => now()]); return $pdf; } /** * Several labels in one PDF. A selected main voucher brings its * children along (as printLabel() does); each parcel is printed once, * main voucher before its children. */ public function printLabels(Collection $shipments): string { $labels = collect(); foreach ($shipments as $shipment) { $master = $this->masterOf($shipment); $this->issue($master); $group = $shipment->is($master) ? [$master->refresh(), ...$this->childrenOf($master)] : [$shipment->refresh()]; foreach ($group as $label) { $labels->put($label->id, $label); } } $pdf = $this->labelRenderer->renderMany($labels->values()); Shipment::whereIn('id', $labels->keys())->update(['label_printed_at' => now()]); return $pdf; } /** * Pending (not yet printed) vouchers are deleted at ELTA. Issued ones * can't be — ELTA only allows that before printing. */ public function cancelShipment(Shipment $shipment): void { $master = $this->masterOf($shipment); if (! $shipment->is($master)) { $piece = $shipment->meta['piece'] ?? null; throw new EltaApiException( 'This is '.($piece ? "parcel {$piece}" : 'an extra parcel').' of a multi-parcel ELTA voucher — ELTA cancels the whole voucher, so cancel the main parcel instead.', ); } if (filled($shipment->tracking_reference)) { throw new EltaApiException( "ELTA voucher {$shipment->tracking_reference} is already printed, and ELTA only allows cancelling vouchers that haven't been printed yet. Contact ELTA to cancel it.", ); } $this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->throwIfError(); $shipment->update(['cancelled_at' => now()]); $this->childrenOf($shipment)->each->update(['cancelled_at' => now()]); } /** * The main voucher's shipment for a child parcel, or the shipment * itself. Children created by createShipment() point at it via * meta.master_shipment_id; older ones only via parent_reference. */ private function masterOf(Shipment $shipment): Shipment { if ($id = $shipment->meta['master_shipment_id'] ?? null) { return Shipment::find($id) ?? $shipment; } if (filled($shipment->parent_reference)) { return Shipment::where('tracking_reference', $shipment->parent_reference)->first() ?? $shipment; } return $shipment; } /** * @return Collection the main voucher's child parcels, in piece order */ private function childrenOf(Shipment $master): Collection { return Shipment::query() ->where('carrier', 'elta') ->where(fn ($query) => $query ->where('meta->master_shipment_id', $master->id) ->when($master->tracking_reference, fn ($q) => $q->orWhere('parent_reference', $master->tracking_reference))) ->orderBy('id') ->get() ->sortBy(fn (Shipment $child) => $child->meta['piece'] ?? PHP_INT_MAX) ->values(); } public function trackShipment(Shipment $shipment): Collection { // ELTA tracks a multi-parcel send by its main voucher only — an // extra parcel's own number is rejected ("Λάθος ΣΥΔΕΤΑ"). Its // history is the main voucher's, which is tracked on its own row. if (! $shipment->is($this->masterOf($shipment))) { return collect(); } $response = $this->client->getTracking([ 'web_vg' => $shipment->tracking_reference, 'pel_code' => config('elta.apost_code'), ])->throwIfError(); $rows = array_filter( (array) ($response->data['web_status'] ?? []), fn (array $row) => filled($row['web_date_time'] ?? null), ); // No explicit delivered-confirmation field like the old // PELTT01's pod_name — pel_rec.a_rec_date_time is blank until // delivery per the real client's own rendering, used as the // delivered signal here; unconfirmed against a genuinely // delivered parcel yet. $isDelivered = filled(trim($response->data['pel_rec']['a_rec_date_time'] ?? '', ' /:')); return collect(array_values($rows))->map(function (array $row, int $index) use ($rows, $isDelivered) { $isLast = $index === count($rows) - 1; $dateTime = $row['web_date_time'] ?? ''; return new TrackingCheckpoint( status: $isLast && $isDelivered ? TrackingStatus::Delivered : $this->guessStatusFromTitle($row['web_status_name'] ?? ''), carrierStatus: $row['web_status_name'] ?? null, message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null), location: $row['web_station'] ?? null, // ELTA reports Greek local time. occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12), 'Europe/Athens')->setTimezone(config('app.timezone')), meta: $row, ); }); } /** * PELMANIF2 (the client's "Πολλαπλή Αναζήτηση"): every issued voucher * in the date range with its latest status. A second pass with * status_flag 3 ("Προς Επιστροφή") marks the ones heading back to us. */ public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable { $returning = collect($this->searchVouchers($from, $to, '3'))->pluck('pel_col_1')->flip(); foreach ($this->searchVouchers($from, $to, '0') as $row) { $number = trim($row['pel_col_1']); yield new CarrierVoucher( carrier: 'elta', voucherNumber: $number, reference: filled($row['pel_col_2'] ?? null) ? trim($row['pel_col_2']) : null, recipientName: trim($row['pel_col_3'] ?? '') ?: null, postcode: trim($row['pel_col_4'] ?? '') ?: null, statusText: trim($row['pel_col_6'] ?? '') ?: null, status: filled(trim($row['pel_col_6'] ?? '')) ? $this->guessStatusFromTitle(trim($row['pel_col_6'])) : null, codAmount: (float) ($row['pel_col_8'] ?? 0) ?: null, isReturn: $returning->has($row['pel_col_1']), date: $this->dateFromListRow($row['pel_col_7'] ?? ''), raw: $row, ); } } /** * PELTTNEW01 (tracking) also returns the voucher's details (pel_rec): * recipient, postcode, our reference, cash on delivery. */ public function lookupVoucher(string $voucherNumber): ?CarrierVoucher { $response = $this->client->getTracking([ 'web_vg' => trim($voucherNumber), 'pel_code' => config('elta.apost_code'), ]); $record = $response->data['pel_rec'] ?? []; if ($response->hasError || blank($record['a_vg_3'] ?? null)) { return null; } $statuses = array_values(array_filter( (array) ($response->data['web_status'] ?? []), fn (array $row) => filled($row['web_date_time'] ?? null), )); return new CarrierVoucher( carrier: 'elta', voucherNumber: trim($record['a_vg_3']), reference: trim($record['a_ref'] ?? '') ?: null, recipientName: trim($record['a_rec_title'] ?? '') ?: null, postcode: trim($record['a_rec_postal'] ?? '') ?: null, phone: trim($record['a_rec_tel_1'] ?? '') ?: null, statusText: filled($statuses) ? trim(end($statuses)['web_status_name'] ?? '') : null, status: filled($statuses) ? $this->guessStatusFromTitle(trim(end($statuses)['web_status_name'] ?? '')) : null, codAmount: (float) ($record['a_antik'] ?? 0) ?: null, date: $this->dateFromListRow($record['a_sender_date'] ?? ''), raw: $record, ); } /** * Issues a pending voucher (no-op once it has a number): stores the * voucher number and what ELTA returns for the label, plus one shipment * per child voucher of a multi-piece send. */ private function issue(Shipment $shipment): void { if (filled($shipment->tracking_reference)) { return; } $response = $this->client->issueVoucher([ 'pel_id' => $this->pendingVoucherId($shipment), 'sender_station' => $this->senderStation(), ])->throwIfError(); $data = $response->data; $voucherNo = trim((string) ($data['vg_code'] ?? '')); if ($voucherNo === '') { throw new EltaApiException('ELTA issued the voucher but returned no voucher number.', $data); } $shipment->tracking_reference = $voucherNo; $shipment->meta = array_merge($shipment->meta?->toArray() ?? [], [ 'ocr_line' => $data['ocr_line'] ?? null, 'date_time' => $data['date_time'] ?? null, 'rec_station' => $data['rec_station'] ?? null, 'rec_station_t' => $data['rec_station_t'] ?? null, 'rec_srv' => $data['rec_srv'] ?? null, 'rec_srv_t' => $data['rec_srv_t'] ?? null, 'return_vg' => trim((string) ($data['return_vg'] ?? '')) ?: null, 'epitagh_vg' => trim((string) ($data['epitagh_vg'] ?? '')) ?: null, ]); $shipment->save(); // The children's numbers, in ELTA's order (vg_child_no), go to the // pending child rows in piece order; any parcel without a row (older // shipments) gets one. $pending = $this->childrenOf($shipment)->filter(fn (Shipment $child) => blank($child->tracking_reference))->values(); $pieces = (int) ($shipment->meta['package_count'] ?? 1); $childNumbers = array_values(array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? [])))); foreach ($childNumbers as $i => $childVoucherNo) { $child = $pending->get($i) ?? new Shipment([ 'order_id' => $shipment->order_id, 'carrier' => 'elta', 'source' => Shipment::SOURCE_CREATED, 'meta' => [ 'master_shipment_id' => $shipment->id, 'reference' => $shipment->meta['reference'] ?? null, 'piece' => $i + 2, 'pieces' => max($pieces, count($childNumbers) + 1), 'weight' => $shipment->meta['weight'] ?? null, 'package_count' => max($pieces, count($childNumbers) + 1), ], ]); $child->tracking_reference = $childVoucherNo; $child->parent_reference = $voucherNo; $child->save(); } } /** * @return array */ private function surchargeCodes(ShipmentRequest $request): array { $codes = $this->extraServices(); return collect($request->services) ->reject(fn (ExtraService $service) => $service === ExtraService::Insurance) ->map(fn (ExtraService $service) => $codes[$service->value] ?? null) ->filter() ->values() ->take(3) ->all(); } private function pendingVoucherId(Shipment $shipment): string { $id = $shipment->meta['carrier_id'] ?? null; if (blank($id) && $shipment->order) { $id = $this->findPendingVoucherId((string) $shipment->order->reference); if ($id) { $shipment->meta = array_merge($shipment->meta?->toArray() ?? [], ['carrier_id' => $id]); $shipment->save(); } } if (blank($id)) { throw new EltaApiException("Couldn't find this shipment's pending voucher at ELTA."); } return $id; } /** * The newest not-yet-issued voucher in ELTA's list carrying our * reference. flag_1/flag_2 are the client's "Show all" / "All users" * checkboxes — both "1", or even a just-created voucher is filtered out. */ private function findPendingVoucherId(string $reference): ?string { $inId = '0'; $match = null; for ($page = 0; $page < self::MAX_PAGES; $page++) { $rows = array_values(array_filter( (array) ($this->client->listVouchers([ 'pel_code' => config('elta.apost_code'), 'pel_user_code' => config('elta.user_code'), 'flag_1' => '1', 'flag_2' => '1', 'in_id' => $inId, ])->throwIfError()->data['vg_rec'] ?? []), fn (array $row) => filled($row['pel_vg_id'] ?? null), )); foreach ($rows as $row) { if (trim($row['pel_ref_no'] ?? '') === $reference && blank(trim($row['pel_paral_vg'] ?? '')) && ($match === null || $row['pel_vg_id'] > $match)) { $match = $row['pel_vg_id']; } } if (count($rows) < 100) { break; } $inId = (string) end($rows)['pel_vg_id']; } return $match; } /** * @return array> */ private function searchVouchers(CarbonInterface $from, CarbonInterface $to, string $statusFlag): array { $inId = '0'; $all = []; for ($page = 0; $page < self::MAX_PAGES; $page++) { $rows = array_values(array_filter( (array) ($this->client->searchVouchers([ 'pel_code' => config('elta.apost_code'), 'date_1' => $from->format('d/m/Y'), 'date_2' => $to->format('d/m/Y'), 'paral_code' => '', 'status_flag' => $statusFlag, 'in_id' => $inId, ])->throwIfError()->data['ag_pel_rec'] ?? []), fn (array $row) => filled(trim($row['pel_col_1'] ?? '')), )); array_push($all, ...$rows); if (count($rows) < 100) { break; } $inId = (string) end($rows)['pel_col_id']; } return $all; } /** * ELTA dates in its lists look like "29/09/26 09:41 PEL CLIENT" or * "29/09/2026 09:51". */ private function dateFromListRow(string $value): ?CarbonInterface { if (! preg_match('#(\d{2})/(\d{2})/(\d{2,4})#', $value, $m)) { return null; } $year = strlen($m[3]) === 2 ? '20'.$m[3] : $m[3]; return Carbon::createFromDate((int) $year, (int) $m[2], (int) $m[1])->startOfDay(); } /** * The station ELTA needs when issuing a voucher: configured * (ELTA_ORIGIN_STATION_CODE), or read from the login response once — * ELTA returns the account's user_station there even though our * account's login password is rejected. */ private function senderStation(): string { if (filled(config('elta.origin_station_code'))) { return (string) config('elta.origin_station_code'); } return (string) Cache::rememberForever('elta.user_station', fn () => $this->client->login([ 'pel_code' => config('elta.apost_code'), 'user_code' => config('elta.user_code'), 'user_pass' => config('elta.user_pass'), ])->data['user_station'] ?? ''); } /** * web_status_name is free text with no structured status code — same * limitation Acs\AcsFulfillmentService::guessStatusFromAction() has. * Live-confirmed text seen so far: "ΔΗΜΙΟΥΡΓΙΑ ΣΥ.ΔΕ.ΤΑ. ΑΠΟ ΠΕΛΑΤΗ" * (voucher created) — matched via a Pending-ish fallback below since * it isn't yet in transit. Other statuses are best-guess substring * matches to refine as more real tracking text is observed. */ /** * ELTA's status texts come in unaccented capitals ("ΠΑΡΑΔΟΘΗΚΕ"), so * they're lowercased and stripped of accents before matching. */ private function guessStatusFromTitle(string $title): TrackingStatus { $title = strtr(mb_strtolower($title), [ 'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω', 'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ', ]); return match (true) { str_contains($title, 'επιστροφ') => TrackingStatus::Returned, str_contains($title, 'παραδοθηκε') => TrackingStatus::Delivered, str_contains($title, 'διανομη') => TrackingStatus::OutForDelivery, str_contains($title, 'παραλαβη') => TrackingStatus::CollectedFromSender, str_contains($title, 'μεταφορα') || str_contains($title, 'διαμετακομιση') || str_contains($title, 'διακινηση') => TrackingStatus::InTransit, default => TrackingStatus::Pending, }; } }