Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates
This commit is contained in:
@@ -3,22 +3,45 @@
|
||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||
|
||||
use RuntimeException;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
|
||||
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
|
||||
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
|
||||
use Modules\Core\Shipping\DTOs\CarrierVoucher;
|
||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||
use Modules\Core\Shipping\DTOs\ManifestResult;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Modules\Core\Shipping\Enums\ExtraService;
|
||||
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Models\Manifest;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
|
||||
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtraServices, SupportsManifestBatching, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
|
||||
{
|
||||
/**
|
||||
* ACS's Acs_Delivery_Products codes. Timed delivery also sends
|
||||
* Appointment_Until_Time; insurance sends Insurance_Ammount.
|
||||
*/
|
||||
public function extraServices(): array
|
||||
{
|
||||
return [
|
||||
ExtraService::SaturdayDelivery->value => 'SAT',
|
||||
ExtraService::MorningDelivery->value => 'MDV',
|
||||
ExtraService::TimedDelivery->value => 'TDD',
|
||||
ExtraService::Insurance->value => 'INS',
|
||||
ExtraService::ReturnDocuments->value => 'RDO',
|
||||
ExtraService::RemoteArea->value => 'REM',
|
||||
ExtraService::Protocol->value => 'PRO',
|
||||
];
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private readonly AcsClient $client,
|
||||
private readonly AreaResolver $areaResolver,
|
||||
@@ -44,12 +67,35 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
'Charge_Type' => 2,
|
||||
'Item_Quantity' => $request->packageCount,
|
||||
'Weight' => $weight,
|
||||
// Our order reference, so the vouchers ACS reports back can be
|
||||
// matched to their order.
|
||||
'Reference_Key1' => (string) $order->reference,
|
||||
];
|
||||
|
||||
if ($request->paymentMode === 'cod') {
|
||||
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
|
||||
$codAmount = $request->paymentMode === 'cod'
|
||||
? (float) ($request->amountToCollect ?? $order->total->decimal)
|
||||
: null;
|
||||
|
||||
$products = collect($request->services)
|
||||
->map(fn (ExtraService $service) => $this->extraServices()[$service->value] ?? null)
|
||||
->filter();
|
||||
|
||||
if ($codAmount !== null) {
|
||||
$params['Cod_Ammount'] = $codAmount;
|
||||
$params['Cod_Payment_Way'] = 0; // cash
|
||||
$params['Acs_Delivery_Products'] = 'COD';
|
||||
$products->push('COD');
|
||||
}
|
||||
|
||||
if ($products->isNotEmpty()) {
|
||||
$params['Acs_Delivery_Products'] = $products->unique()->implode(',');
|
||||
}
|
||||
|
||||
if ($request->has(ExtraService::Insurance) && $request->insuranceAmount) {
|
||||
$params['Insurance_Ammount'] = $request->insuranceAmount;
|
||||
}
|
||||
|
||||
if ($request->has(ExtraService::TimedDelivery) && $request->deliveryUntil) {
|
||||
$params['Appointment_Until_Time'] = substr($request->deliveryUntil, 0, 5);
|
||||
}
|
||||
|
||||
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
|
||||
@@ -64,6 +110,9 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
'station_destination' => $destination->stationId,
|
||||
'weight' => $weight,
|
||||
'pickup_date' => now()->toDateString(),
|
||||
'cod_amount' => $codAmount,
|
||||
'services' => $request->serviceValues(),
|
||||
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -167,6 +216,68 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ACS can't list vouchers directly: each day's pickup lists
|
||||
* (ACS_Get_Pickup_Lists) → their vouchers (ACS_Pickup_List_Display_
|
||||
* Voucher). Only finalized vouchers appear, with our Reference_Key1 but
|
||||
* no recipient or status — tracking fills those in.
|
||||
*/
|
||||
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
|
||||
{
|
||||
for ($day = $from->copy()->startOfDay(); $day->lte($to); $day = $day->addDay()) {
|
||||
$date = $day->toDateString();
|
||||
|
||||
$lists = $this->client->call('ACS_Get_Pickup_Lists', ['Pickup_Date' => $date])
|
||||
->throwIfError()->tableOutput['Table_Data'] ?? [];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$vouchers = $this->client->call('ACS_Pickup_List_Display_Voucher', [
|
||||
'PickupList_No' => $list['PickupList_No'],
|
||||
'Pickup_Date' => $date,
|
||||
])->throwIfError()->tableOutput['Table_Data'] ?? [];
|
||||
|
||||
foreach ($vouchers as $row) {
|
||||
yield new CarrierVoucher(
|
||||
carrier: 'acs',
|
||||
voucherNumber: (string) $row['Voucher_no'],
|
||||
reference: filled($row['Reference_Key1'] ?? null) ? (string) $row['Reference_Key1'] : null,
|
||||
date: $day->copy(),
|
||||
raw: $row,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACS_Trackingsummary: recipient and delivery/return flags — ACS doesn't
|
||||
* return our reference here.
|
||||
*/
|
||||
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
|
||||
{
|
||||
$row = $this->client->call('ACS_Trackingsummary', ['Voucher_No' => trim($voucherNumber)])
|
||||
->throwIfError()->tableOutput['Table_Data'][0] ?? null;
|
||||
|
||||
if (! $row || blank($row['voucher_no'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CarrierVoucher(
|
||||
carrier: 'acs',
|
||||
voucherNumber: (string) $row['voucher_no'],
|
||||
recipientName: $row['recipient'] ?? ($row['consignee'] ?? null),
|
||||
statusText: (int) ($row['delivery_flag'] ?? 0) === 1 ? 'Delivered' : ($row['delivery_info'] ?? null),
|
||||
status: match (true) {
|
||||
(int) ($row['returned_flag'] ?? 0) === 1 => TrackingStatus::Returned,
|
||||
(int) ($row['delivery_flag'] ?? 0) === 1 => TrackingStatus::Delivered,
|
||||
default => null,
|
||||
},
|
||||
isReturn: (int) ($row['returned_flag'] ?? 0) === 1,
|
||||
date: filled($row['pickup_date'] ?? null) ? Carbon::parse($row['pickup_date']) : null,
|
||||
raw: $row,
|
||||
);
|
||||
}
|
||||
|
||||
private function isDelivered(Shipment $shipment): bool
|
||||
{
|
||||
try {
|
||||
@@ -177,7 +288,14 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
|
||||
// The summary row is in ACSTableOutput.Table_Data, not
|
||||
// ACSValueOutput — reading the latter always came back empty, so no
|
||||
// ACS shipment was ever marked delivered. delivery_flag = 1 is
|
||||
// ACS's documented "delivered"; shipment_status 4 means the same.
|
||||
$summary = $response->tableOutput['Table_Data'][0] ?? [];
|
||||
|
||||
return (int) ($summary['delivery_flag'] ?? 0) === 1
|
||||
|| (int) ($summary['shipment_status'] ?? 0) === 4;
|
||||
}
|
||||
|
||||
private function guessStatusFromAction(string $action): TrackingStatus
|
||||
|
||||
@@ -6,6 +6,7 @@ use Lunar\DataTypes\Price;
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
||||
@@ -40,7 +41,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
|
||||
return 'carrier';
|
||||
}
|
||||
|
||||
public function collectsCash(): bool
|
||||
public function collectsCash(ShippingMethod $method): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
|
||||
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
|
||||
use Modules\Core\Shipping\DTOs\CarrierVoucher;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
@@ -32,7 +36,7 @@ use Modules\Core\Shipping\Models\Shipment;
|
||||
* sends that many entries in a single delivery request rather than
|
||||
* several separate ones.
|
||||
*/
|
||||
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
||||
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
|
||||
{
|
||||
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
|
||||
|
||||
@@ -53,8 +57,21 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
|
||||
$isCod = $request->paymentMode === 'cod';
|
||||
|
||||
// Box Now rejects an orderNumber it has seen before (P410), even
|
||||
// for a cancelled request — so a re-created shipment after a cancel
|
||||
// gets "-2", "-3", …; the first attempt keeps "{reference}-{id}".
|
||||
$previousRequests = Shipment::where('order_id', $order->id)
|
||||
->where('carrier', 'box-now')
|
||||
->where('source', Shipment::SOURCE_CREATED)
|
||||
->get()
|
||||
->map(fn (Shipment $shipment) => $shipment->meta['delivery_request_id'] ?? $shipment->id)
|
||||
->unique()
|
||||
->count();
|
||||
|
||||
$orderNumber = $order->reference.'-'.$order->id.($previousRequests > 0 ? '-'.($previousRequests + 1) : '');
|
||||
|
||||
$response = $this->client->request('post', '/delivery-requests', [
|
||||
'orderNumber' => $order->reference.'-'.$order->id,
|
||||
'orderNumber' => $orderNumber,
|
||||
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
|
||||
'paymentMode' => $isCod ? 'cod' : 'prepaid',
|
||||
'amountToBeCollected' => $isCod
|
||||
@@ -92,13 +109,18 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
// operate per-Shipment), even though all boxes were submitted in
|
||||
// one delivery request. Siblings are linked via the shared
|
||||
// delivery_request_id in meta.
|
||||
$shipments = $parcels->map(fn (array $parcel) => Shipment::create([
|
||||
// The cash is collected once for the whole request, so only the
|
||||
// first parcel carries cod_amount.
|
||||
$codAmount = $isCod ? (float) ($request->amountToCollect ?? $order->total->decimal) : null;
|
||||
|
||||
$shipments = $parcels->values()->map(fn (array $parcel, int $index) => Shipment::create([
|
||||
'order_id' => $order->id,
|
||||
'carrier' => 'box-now',
|
||||
'tracking_reference' => (string) $parcel['id'],
|
||||
'meta' => [
|
||||
'delivery_request_id' => $response['id'] ?? null,
|
||||
'locker_id' => $destinationLocationId,
|
||||
'cod_amount' => $index === 0 ? $codAmount : null,
|
||||
],
|
||||
]));
|
||||
|
||||
@@ -186,6 +208,91 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every parcel on the account (GET /parcels, 100 per page). Box Now has
|
||||
* no date filter, so pages are read until a whole page is older than
|
||||
* $from — its order isn't documented, so one old parcel alone doesn't
|
||||
* stop the scan.
|
||||
*/
|
||||
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
|
||||
{
|
||||
$pageToken = null;
|
||||
|
||||
do {
|
||||
$response = $this->client->request('get', '/parcels', array_filter([
|
||||
'limit' => 100,
|
||||
'pageToken' => $pageToken,
|
||||
]));
|
||||
|
||||
$parcels = $response['data'] ?? [];
|
||||
$anyInRange = false;
|
||||
|
||||
foreach ($parcels as $parcel) {
|
||||
$created = Carbon::parse($parcel['createTime'] ?? 'now');
|
||||
|
||||
if ($created->lt($from)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$anyInRange = true;
|
||||
|
||||
if ($created->lte($to)) {
|
||||
yield $this->voucherFromParcel($parcel);
|
||||
}
|
||||
}
|
||||
|
||||
$pageToken = $this->nextPageToken($response['pagination']['next'] ?? null);
|
||||
} while ($pageToken && $anyInRange && $parcels !== []);
|
||||
}
|
||||
|
||||
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
|
||||
{
|
||||
$parcel = $this->client->request('get', '/parcels', ['parcelId' => trim($voucherNumber)])['data'][0] ?? null;
|
||||
|
||||
return $parcel ? $this->voucherFromParcel($parcel) : null;
|
||||
}
|
||||
|
||||
private function voucherFromParcel(array $parcel): CarrierVoucher
|
||||
{
|
||||
$request = $parcel['deliveryRequest'] ?? [];
|
||||
$destination = $request['destination'] ?? [];
|
||||
$state = $parcel['state'] ?? null;
|
||||
|
||||
return new CarrierVoucher(
|
||||
carrier: 'box-now',
|
||||
voucherNumber: (string) $parcel['id'],
|
||||
reference: $request['orderNumber'] ?? null,
|
||||
recipientName: $destination['contactName'] ?? null,
|
||||
postcode: $destination['postalCode'] ?? ($destination['address']['postalCode'] ?? null),
|
||||
phone: $destination['contactNumber'] ?? null,
|
||||
statusText: $state,
|
||||
status: $state ? $this->mapState($state) : null,
|
||||
codAmount: ($request['paymentMode'] ?? null) === 'cod' ? ((float) ($request['amountToBeCollected'] ?? 0) ?: null) : null,
|
||||
isReturn: in_array($state, ['returned', 'expired-return', 'accepted-for-return'], true),
|
||||
date: isset($parcel['createTime']) ? Carbon::parse($parcel['createTime']) : null,
|
||||
raw: $parcel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* pagination.next is documented only by name — accept either the bare
|
||||
* token or a URL carrying it as ?pageToken=.
|
||||
*/
|
||||
private function nextPageToken(mixed $next): ?string
|
||||
{
|
||||
if (blank($next) || ! is_string($next)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($next, 'pageToken=')) {
|
||||
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
|
||||
|
||||
return $query['pageToken'] ?? null;
|
||||
}
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
private function mapState(string $state): TrackingStatus
|
||||
{
|
||||
// TODO: confirm against a live BoxNow webhook payload whether a
|
||||
@@ -201,7 +308,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
|
||||
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
|
||||
'delivered' => TrackingStatus::Delivered,
|
||||
'returned', 'accepted-for-return' => TrackingStatus::Returned,
|
||||
'cancelled' => TrackingStatus::Cancelled,
|
||||
'cancelled', 'canceled' => TrackingStatus::Cancelled,
|
||||
'expired-return', 'missing' => TrackingStatus::Failed,
|
||||
default => TrackingStatus::Unknown,
|
||||
};
|
||||
|
||||
@@ -58,11 +58,37 @@ class EltaClient
|
||||
return $this->call('PELPARALVGNEW1', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a pending voucher (created with pel_insert_flag "0"): assigns
|
||||
* its voucher number, OCR line, destination station and service. This
|
||||
* is the official client's "print" — after it, the voucher can no
|
||||
* longer be cancelled.
|
||||
*/
|
||||
public function issueVoucher(array $params): EltaResponse
|
||||
{
|
||||
return $this->call('PELVG01NEW1', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only pending (not yet issued) vouchers can be deleted.
|
||||
*/
|
||||
public function deleteVoucher(array $params): EltaResponse
|
||||
{
|
||||
return $this->call('PELVGDEL', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's "Πολλαπλή Αναζήτηση": every issued voucher on the
|
||||
* account between date_1 and date_2 (dd/mm/yyyy), with its latest
|
||||
* status. status_flag: 0 all, 1 delivered, 2 undelivered, 3 to be
|
||||
* returned. Paged 100 at a time via in_id (the last row's
|
||||
* pel_col_id).
|
||||
*/
|
||||
public function searchVouchers(array $params): EltaResponse
|
||||
{
|
||||
return $this->call('PELMANIF2', $params);
|
||||
}
|
||||
|
||||
public function getTracking(array $params): EltaResponse
|
||||
{
|
||||
return $this->call('PELTTNEW01', $params);
|
||||
|
||||
@@ -2,34 +2,66 @@
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Elta;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
|
||||
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
|
||||
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
|
||||
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
|
||||
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
|
||||
use Modules\Core\Shipping\DTOs\CarrierVoucher;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Enums\ExtraService;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* ELTA vouchers are created **pending** (pel_insert_flag "0", live-verified):
|
||||
* ELTA stores the voucher with an internal id but no voucher number, and it
|
||||
* can still be cancelled. Printing issues it (PELVG01NEW1 — what ELTA's own
|
||||
* client does from its group-editing screen): that assigns the voucher
|
||||
* number, OCR line, destination station and service, and from then on ELTA
|
||||
* refuses to delete it ("Δεν Επιτρέπεται! Εχει Γίνει Εκτύπωση"). So a
|
||||
* shipment has no tracking_reference until its label is printed.
|
||||
*
|
||||
* Not SupportsManifestBatching — ELTA has no manifest/pickup-list step.
|
||||
*/
|
||||
class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
||||
class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVoucherOnPrint, SupportsBatchLabels, SupportsExtraServices, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
|
||||
{
|
||||
/** PELMANIF2 / PELPARALVGNEW1 page through 100 rows at a time. */
|
||||
private const MAX_PAGES = 50;
|
||||
|
||||
public function __construct(
|
||||
private readonly EltaClient $client,
|
||||
private readonly EltaLabelRenderer $labelRenderer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ELTA surcharge codes (pel_sur_1..3), from its own client's checkboxes.
|
||||
* Insurance isn't a surcharge — it's the amount in pel_asf_poso.
|
||||
*/
|
||||
public function extraServices(): array
|
||||
{
|
||||
return [
|
||||
ExtraService::SaturdayDelivery->value => '004',
|
||||
ExtraService::TimedDelivery->value => '003',
|
||||
ExtraService::SpecialHandling->value => '002',
|
||||
ExtraService::Insurance->value => 'pel_asf_poso',
|
||||
];
|
||||
}
|
||||
|
||||
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||||
{
|
||||
$address = $order->shippingAddress;
|
||||
$weight = $request->weight ?? 0.5;
|
||||
$surcharges = $this->surchargeCodes($request);
|
||||
|
||||
$params = [
|
||||
'pel_apost_code' => config('elta.apost_code'),
|
||||
@@ -47,9 +79,11 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
|
||||
'pel_z' => '',
|
||||
'pel_temaxia' => (string) $request->packageCount,
|
||||
'pel_paral_sxolia' => '',
|
||||
'pel_sur_1' => '',
|
||||
'pel_sur_2' => '',
|
||||
'pel_sur_3' => '',
|
||||
'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' => '',
|
||||
@@ -59,10 +93,16 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
|
||||
'pel_ant_date2' => '',
|
||||
'pel_ant_date3' => '',
|
||||
'pel_ant_date4' => '',
|
||||
'pel_asf_poso' => '',
|
||||
'pel_asf_poso' => $request->has(ExtraService::Insurance) && $request->insuranceAmount
|
||||
? number_format($request->insuranceAmount, 2, '.', '')
|
||||
: '',
|
||||
'pel_user' => config('elta.user_code'),
|
||||
'pel_ref_no' => (string) $order->id,
|
||||
'pel_insert_flag' => '',
|
||||
// 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' => '',
|
||||
];
|
||||
@@ -74,122 +114,66 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
|
||||
$params['pel_ant_poso'] = number_format($codAmount, 2, '.', '');
|
||||
}
|
||||
|
||||
$response = $this->client->createVoucher($params)->throwIfError();
|
||||
$this->client->createVoucher($params)->throwIfError();
|
||||
|
||||
$voucherNo = (string) $response->data['vg_code'];
|
||||
|
||||
$shipment = Shipment::create([
|
||||
// PELVGNEW doesn't return the new voucher's id, so find it in the
|
||||
// pending list by our reference. Saved even if that lookup fails —
|
||||
// the voucher exists at ELTA either way, and printing retries it.
|
||||
return Shipment::create([
|
||||
'order_id' => $order->id,
|
||||
'carrier' => 'elta',
|
||||
'tracking_reference' => $voucherNo,
|
||||
'source' => Shipment::SOURCE_CREATED,
|
||||
'tracking_reference' => null,
|
||||
'meta' => [
|
||||
'carrier_id' => $this->findPendingVoucherId((string) $order->reference),
|
||||
'weight' => $weight,
|
||||
'ocr_line' => $response->data['ocr_line'] ?? null,
|
||||
'date_time' => $response->data['date_time'] ?? null,
|
||||
'package_count' => $request->packageCount,
|
||||
'cod_amount' => $codAmount,
|
||||
'services' => $request->serviceValues(),
|
||||
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
|
||||
],
|
||||
]);
|
||||
|
||||
// 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);
|
||||
$this->issue($shipment);
|
||||
|
||||
$pdf = $this->labelRenderer->render($shipment->refresh());
|
||||
|
||||
$shipment->update(['label_printed_at' => now()]);
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
public function printLabels(Collection $shipments): string
|
||||
{
|
||||
$shipments->each(fn (Shipment $shipment) => $this->issue($shipment));
|
||||
|
||||
$pdf = $this->labelRenderer->renderMany($shipments->map->refresh());
|
||||
|
||||
Shipment::whereIn('id', $shipments->pluck('id'))->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.
|
||||
* 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
|
||||
{
|
||||
$voucherId = $this->findVoucherId($shipment->tracking_reference);
|
||||
|
||||
if ($voucherId === null) {
|
||||
if (filled($shipment->tracking_reference)) {
|
||||
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.',
|
||||
"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' => $voucherId])->throwIfError();
|
||||
$this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->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([
|
||||
@@ -220,12 +204,269 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
|
||||
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)),
|
||||
// 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();
|
||||
|
||||
foreach (array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? []))) as $childVoucherNo) {
|
||||
Shipment::create([
|
||||
'order_id' => $shipment->order_id,
|
||||
'carrier' => 'elta',
|
||||
'source' => Shipment::SOURCE_CREATED,
|
||||
'tracking_reference' => $childVoucherNo,
|
||||
'parent_reference' => $voucherNo,
|
||||
'label_printed_at' => now(),
|
||||
'meta' => $shipment->meta->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function surchargeCodes(ShipmentRequest $request): array
|
||||
{
|
||||
$codes = $this->extraServices();
|
||||
|
||||
return collect($request->services)
|
||||
->reject(fn (ExtraService $service) => $service === ExtraService::Insurance)
|
||||
->map(fn (ExtraService $service) => $codes[$service->value] ?? null)
|
||||
->filter()
|
||||
->values()
|
||||
->take(3)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function pendingVoucherId(Shipment $shipment): string
|
||||
{
|
||||
$id = $shipment->meta['carrier_id'] ?? null;
|
||||
|
||||
if (blank($id) && $shipment->order) {
|
||||
$id = $this->findPendingVoucherId((string) $shipment->order->reference);
|
||||
|
||||
if ($id) {
|
||||
$shipment->meta = array_merge($shipment->meta?->toArray() ?? [], ['carrier_id' => $id]);
|
||||
$shipment->save();
|
||||
}
|
||||
}
|
||||
|
||||
if (blank($id)) {
|
||||
throw new EltaApiException("Couldn't find this shipment's pending voucher at ELTA.");
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest not-yet-issued voucher in ELTA's list carrying our
|
||||
* reference. flag_1/flag_2 are the client's "Show all" / "All users"
|
||||
* checkboxes — both "1", or even a just-created voucher is filtered out.
|
||||
*/
|
||||
private function findPendingVoucherId(string $reference): ?string
|
||||
{
|
||||
$inId = '0';
|
||||
$match = null;
|
||||
|
||||
for ($page = 0; $page < self::MAX_PAGES; $page++) {
|
||||
$rows = array_values(array_filter(
|
||||
(array) ($this->client->listVouchers([
|
||||
'pel_code' => config('elta.apost_code'),
|
||||
'pel_user_code' => config('elta.user_code'),
|
||||
'flag_1' => '1',
|
||||
'flag_2' => '1',
|
||||
'in_id' => $inId,
|
||||
])->throwIfError()->data['vg_rec'] ?? []),
|
||||
fn (array $row) => filled($row['pel_vg_id'] ?? null),
|
||||
));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (trim($row['pel_ref_no'] ?? '') === $reference && blank(trim($row['pel_paral_vg'] ?? ''))
|
||||
&& ($match === null || $row['pel_vg_id'] > $match)) {
|
||||
$match = $row['pel_vg_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($rows) < 100) {
|
||||
break;
|
||||
}
|
||||
|
||||
$inId = (string) end($rows)['pel_vg_id'];
|
||||
}
|
||||
|
||||
return $match;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function searchVouchers(CarbonInterface $from, CarbonInterface $to, string $statusFlag): array
|
||||
{
|
||||
$inId = '0';
|
||||
$all = [];
|
||||
|
||||
for ($page = 0; $page < self::MAX_PAGES; $page++) {
|
||||
$rows = array_values(array_filter(
|
||||
(array) ($this->client->searchVouchers([
|
||||
'pel_code' => config('elta.apost_code'),
|
||||
'date_1' => $from->format('d/m/Y'),
|
||||
'date_2' => $to->format('d/m/Y'),
|
||||
'paral_code' => '',
|
||||
'status_flag' => $statusFlag,
|
||||
'in_id' => $inId,
|
||||
])->throwIfError()->data['ag_pel_rec'] ?? []),
|
||||
fn (array $row) => filled(trim($row['pel_col_1'] ?? '')),
|
||||
));
|
||||
|
||||
array_push($all, ...$rows);
|
||||
|
||||
if (count($rows) < 100) {
|
||||
break;
|
||||
}
|
||||
|
||||
$inId = (string) end($rows)['pel_col_id'];
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* ELTA dates in its lists look like "29/09/26 09:41 PEL CLIENT" or
|
||||
* "29/09/2026 09:51".
|
||||
*/
|
||||
private function dateFromListRow(string $value): ?CarbonInterface
|
||||
{
|
||||
if (! preg_match('#(\d{2})/(\d{2})/(\d{2,4})#', $value, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$year = strlen($m[3]) === 2 ? '20'.$m[3] : $m[3];
|
||||
|
||||
return Carbon::createFromDate((int) $year, (int) $m[2], (int) $m[1])->startOfDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* The station ELTA needs when issuing a voucher: configured
|
||||
* (ELTA_ORIGIN_STATION_CODE), or read from the login response once —
|
||||
* ELTA returns the account's user_station there even though our
|
||||
* account's login password is rejected.
|
||||
*/
|
||||
private function senderStation(): string
|
||||
{
|
||||
if (filled(config('elta.origin_station_code'))) {
|
||||
return (string) config('elta.origin_station_code');
|
||||
}
|
||||
|
||||
return (string) Cache::rememberForever('elta.user_station', fn () => $this->client->login([
|
||||
'pel_code' => config('elta.apost_code'),
|
||||
'user_code' => config('elta.user_code'),
|
||||
'user_pass' => config('elta.user_pass'),
|
||||
])->data['user_station'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* web_status_name is free text with no structured status code — same
|
||||
* limitation Acs\AcsFulfillmentService::guessStatusFromAction() has.
|
||||
@@ -234,15 +475,23 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
|
||||
* 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 = mb_strtolower($title);
|
||||
$title = strtr(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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Modules\Core\Shipping\Carriers\Elta;
|
||||
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Picqer\Barcode\BarcodeGeneratorPNG;
|
||||
|
||||
@@ -28,15 +30,88 @@ use Picqer\Barcode\BarcodeGeneratorPNG;
|
||||
*/
|
||||
class EltaLabelRenderer
|
||||
{
|
||||
/**
|
||||
* ELTA's own wording for each surcharge, as its client prints them in
|
||||
* the label's "Πρόσθετες Υπηρεσίες" box (Sydeta.cs::print_vg()).
|
||||
*/
|
||||
private const SERVICE_TEXT = [
|
||||
'special_handling' => '002 ΕΙΔΙΚΗ ΔΙΑΧΕΙΡΗΣΗ',
|
||||
'timed_delivery' => '003 ΠΡΟΚΑΘΟΡΙΣΜΕΝΗ ΩΡΑ',
|
||||
'saturday_delivery' => '004 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ',
|
||||
];
|
||||
|
||||
public function __construct(private readonly AreaResolver $areaResolver) {}
|
||||
|
||||
public function render(Shipment $shipment): string
|
||||
{
|
||||
return $this->renderMany(collect([$shipment]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Several labels in one PDF (the Pending Vouchers screen's "Print
|
||||
* selected"). Each label view is a full page — a single `.page` block
|
||||
* inside <body> — so the pages are stacked under the first view's
|
||||
* <head> (the styles are the same for every label) with a page break
|
||||
* between them.
|
||||
*
|
||||
* @param Collection<int, Shipment> $shipments
|
||||
*/
|
||||
public function renderMany(Collection $shipments): string
|
||||
{
|
||||
$paperSize = config('elta.label_paper_size', 'a4');
|
||||
$view = $paperSize === 'a6'
|
||||
? 'core::shipping.carriers.elta.label-a6'
|
||||
: 'core::shipping.carriers.elta.label-a4';
|
||||
|
||||
$head = null;
|
||||
$pages = [];
|
||||
|
||||
foreach ($shipments as $shipment) {
|
||||
$html = view($view, $this->data($shipment))->render();
|
||||
|
||||
$head ??= Str::before($html, '<body');
|
||||
$pages[] = Str::beforeLast(Str::after(Str::after($html, '<body'), '>'), '</body>');
|
||||
}
|
||||
|
||||
$html = $head
|
||||
.'<style>.page { page-break-after: always; } .page:last-child { page-break-after: auto; }</style>'
|
||||
.'<body>'.implode('', $pages).'</body></html>';
|
||||
|
||||
$pdf = Pdf::loadHTML($html);
|
||||
|
||||
// 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). A
|
||||
// 4-value paper array plus an orientation string together confuse
|
||||
// dompdf into doubling the canvas and silently overflowing to a
|
||||
// second blank page; the array alone is already portrait (height >
|
||||
// width), so no orientation argument.
|
||||
$paperSize === 'a6'
|
||||
? $pdf->setPaper([0, 0, 294.80, 419.53])
|
||||
: $pdf->setPaper('a4');
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function data(Shipment $shipment): array
|
||||
{
|
||||
$order = $shipment->order;
|
||||
$address = $order->shippingAddress;
|
||||
$meta = $shipment->meta?->toArray() ?? [];
|
||||
|
||||
$destinationStation = $this->areaResolver->resolve($address->postcode);
|
||||
// Issuing a pending voucher (PELVG01NEW1) returns the real
|
||||
// destination station and service; vouchers issued before that flow
|
||||
// existed don't have them, so fall back to the postcode lookup.
|
||||
$station = filled($meta['rec_station'] ?? null)
|
||||
? ['code' => $meta['rec_station'], 'name' => $meta['rec_station_t'] ?? '']
|
||||
: (fn ($s) => ['code' => $s->code, 'name' => $s->name])($this->areaResolver->resolve($address->postcode));
|
||||
|
||||
$services = (array) ($meta['services'] ?? []);
|
||||
$surcharges = $this->surchargeSlots($services);
|
||||
$cod = $this->codFields(isset($meta['cod_amount']) ? (float) $meta['cod_amount'] : null);
|
||||
|
||||
$dateTime = (string) ($meta['date_time'] ?? '');
|
||||
$occurredAt = strlen($dateTime) >= 12
|
||||
@@ -49,7 +124,7 @@ class EltaLabelRenderer
|
||||
|
||||
$generator = new BarcodeGeneratorPNG();
|
||||
|
||||
$data = [
|
||||
return [
|
||||
'sender_name' => config('elta.sender_name'),
|
||||
'sender_address' => config('elta.sender_address'),
|
||||
'sender_postcode' => config('elta.sender_postcode'),
|
||||
@@ -72,6 +147,7 @@ class EltaLabelRenderer
|
||||
// report's 40-char wrap width, which our own data never hits
|
||||
// in practice for a name/address/contact triple).
|
||||
'sender_lines' => [
|
||||
'Κωδικός:'.config('elta.apost_code'),
|
||||
config('elta.sender_name'),
|
||||
config('elta.sender_address'),
|
||||
'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'),
|
||||
@@ -95,13 +171,14 @@ class EltaLabelRenderer
|
||||
? sprintf('001/%03d', $packageCount)
|
||||
: '001',
|
||||
'multiPiece' => $packageCount > 1,
|
||||
'polaplo' => $packageCount > 1 ? '* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *' : null,
|
||||
|
||||
'station_apo' => config('elta.origin_station_code'),
|
||||
'station_pros' => $destinationStation->code,
|
||||
'station_pros_title' => $destinationStation->name,
|
||||
'station_pros' => $station['code'],
|
||||
'station_pros_title' => $station['name'],
|
||||
|
||||
'service_code' => '214',
|
||||
'service_name' => 'ΠΟΛΗ ΠΟΛΗ -ΠΘ- ΝΗΣΙ',
|
||||
'service_code' => $meta['rec_srv'] ?? '',
|
||||
'service_name' => $meta['rec_srv_t'] ?? '',
|
||||
|
||||
'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ',
|
||||
'siimvasi' => '131775-9',
|
||||
@@ -114,46 +191,95 @@ class EltaLabelRenderer
|
||||
// yields reference "1426000981436" (13 chars after the '>').
|
||||
'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : '',
|
||||
|
||||
'order_reference' => (string) $order->id,
|
||||
// The reference we send ELTA as pel_ref_no (order reference;
|
||||
// older vouchers were sent the order id).
|
||||
'order_reference' => (string) $order->reference,
|
||||
|
||||
// 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,
|
||||
// sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked
|
||||
// extra services, in ELTA's own wording and slots.
|
||||
'sur_1' => $surcharges[1],
|
||||
'sur_2' => $surcharges[2],
|
||||
'sur_3' => $surcharges[3],
|
||||
'sur_4' => null,
|
||||
'antik_1' => $meta['cod_amount'] ?? null ? '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *' : null,
|
||||
...$cod,
|
||||
// ogos_2: "LxWxH = volume", only when dimensions were given —
|
||||
// we never send any, so the box stays empty like the client's.
|
||||
'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';
|
||||
/**
|
||||
* The client's own sur_1..3 placement (Sydeta.cs::print_vg()): 002 is
|
||||
* always sur_1; 003 is always sur_2; 004 is sur_3 after 003, else
|
||||
* sur_2. Slots can stay empty (003 alone leaves sur_1 blank).
|
||||
*
|
||||
* @param array<int, string> $services ExtraService values
|
||||
* @return array{1: ?string, 2: ?string, 3: ?string}
|
||||
*/
|
||||
private function surchargeSlots(array $services): array
|
||||
{
|
||||
$has = fn (string $service) => in_array($service, $services, true);
|
||||
$slots = [1 => null, 2 => null, 3 => null];
|
||||
|
||||
$pdf = Pdf::loadView($view, $data);
|
||||
if ($has('special_handling')) {
|
||||
$slots[1] = self::SERVICE_TEXT['special_handling'];
|
||||
}
|
||||
|
||||
// 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');
|
||||
if ($has('timed_delivery')) {
|
||||
$slots[2] = self::SERVICE_TEXT['timed_delivery'];
|
||||
|
||||
return $pdf->output();
|
||||
if ($has('saturday_delivery')) {
|
||||
$slots[3] = self::SERVICE_TEXT['saturday_delivery'];
|
||||
}
|
||||
} elseif ($has('saturday_delivery')) {
|
||||
$slots[2] = self::SERVICE_TEXT['saturday_delivery'];
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cash-on-delivery texts exactly as the client prints them
|
||||
* (Sydeta.cs::print_vg(), cash branch): antik_1 "ΑΝΤΙΚΑΤΑΒΟΛΗ 17.00"
|
||||
* heads the COD column and the A6 bottom banner, then "* ΑΝΑΛΥΣΗ *" and
|
||||
* the cash line ("17.00 MΕΤΡΗΤΑ" — the amount as sent in pel_ant_poso,
|
||||
* the client's own Latin "M" kept). antik_minima / apodiksi / antik_poso
|
||||
* are the A4 payment stub's. We only ever send cash (no cheques), so
|
||||
* antik_4..7 stay empty.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function codFields(?float $amount): array
|
||||
{
|
||||
if (! $amount) {
|
||||
return [
|
||||
'antik_1' => null, 'antik_2' => null, 'antik_3' => null,
|
||||
'antik_4' => null, 'antik_5' => null, 'antik_6' => null, 'antik_7' => null,
|
||||
'antik_lines' => [],
|
||||
'antik_minima' => null,
|
||||
'apodiksi' => null,
|
||||
'antik_poso' => '0.00',
|
||||
];
|
||||
}
|
||||
|
||||
$sent = number_format($amount, 2, '.', '');
|
||||
|
||||
$lines = [
|
||||
'antik_1' => 'ΑΝΤΙΚΑΤΑΒΟΛΗ '.$sent,
|
||||
'antik_2' => '* ΑΝΑΛΥΣΗ *',
|
||||
'antik_3' => $sent.' MΕΤΡΗΤΑ',
|
||||
'antik_4' => null, 'antik_5' => null, 'antik_6' => null, 'antik_7' => null,
|
||||
];
|
||||
|
||||
return [
|
||||
...$lines,
|
||||
'antik_lines' => array_values(array_filter($lines)),
|
||||
'antik_minima' => '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *',
|
||||
'apodiksi' => '* Απόδειξη Είσπαξης *',
|
||||
'antik_poso' => $sent,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Core\Shipping\Carriers\Elta;
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Lunar\Shipping\Models\ShippingRate;
|
||||
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
|
||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||
@@ -38,7 +39,7 @@ class EltaRateDriver implements ShippingRateInterface, DeclaresFulfillmentType,
|
||||
return 'carrier';
|
||||
}
|
||||
|
||||
public function collectsCash(): bool
|
||||
public function collectsCash(ShippingMethod $method): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Manual;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Shipments of a manual carrier (ManualRateDriver): recorded here only,
|
||||
* with no carrier API. The carrier's name and tracking URL template are
|
||||
* copied from the shipping method into the shipment's meta, so links keep
|
||||
* working if the method is edited later. Tracking updates are added by
|
||||
* staff from the order page.
|
||||
*/
|
||||
class ManualFulfillmentService implements CarrierFulfillmentInterface
|
||||
{
|
||||
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||||
{
|
||||
$code = $order->shippingAddress?->shipping_option;
|
||||
$method = $code ? ShippingMethod::where('code', $code)->first() : null;
|
||||
|
||||
return Shipment::create([
|
||||
'order_id' => $order->id,
|
||||
'carrier' => 'manual',
|
||||
'source' => Shipment::SOURCE_MANUAL,
|
||||
'tracking_reference' => filled($request->trackingReference) ? trim($request->trackingReference) : null,
|
||||
'meta' => [
|
||||
'carrier_name' => $method?->data['carrier_name'] ?? null,
|
||||
'tracking_url' => $method?->data['tracking_url'] ?? null,
|
||||
'package_count' => $request->packageCount,
|
||||
'weight' => $request->weight,
|
||||
'cod_amount' => $request->paymentMode === 'cod'
|
||||
? (float) ($request->amountToCollect ?? $order->total->decimal)
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function printLabel(Shipment $shipment): string
|
||||
{
|
||||
throw new RuntimeException('Manual carriers have no printable label.');
|
||||
}
|
||||
|
||||
public function cancelShipment(Shipment $shipment): void
|
||||
{
|
||||
$shipment->update(['cancelled_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Carriers\Manual;
|
||||
|
||||
use Lunar\DataTypes\ShippingOption;
|
||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
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;
|
||||
|
||||
/**
|
||||
* A carrier we have no integration with (e.g. Geniki), added by the
|
||||
* merchant as a shipping method with this driver. The method's `data`
|
||||
* holds the carrier's details, set in the admin
|
||||
* (ShippingMethodResourceExtension):
|
||||
*
|
||||
* - carrier_name: shown to staff and customers on its shipments;
|
||||
* - tracking_url: optional link template, `{number}` = the voucher number;
|
||||
* - collects_cash: whether its courier takes cash on delivery.
|
||||
*
|
||||
* Priced like Store Pickup / Box Now (charge_by + price breaks); its
|
||||
* shipments are created and tracked by hand (ManualFulfillmentService).
|
||||
*/
|
||||
class ManualRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, SupportsCashCollection
|
||||
{
|
||||
use ResolvesFixedPricing;
|
||||
use ExcludesRestrictedProducts;
|
||||
|
||||
public ShippingRate $shippingRate;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Manual carrier';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'A carrier without an integration — shipments are entered and tracked by hand.';
|
||||
}
|
||||
|
||||
public function fulfillmentType(): string
|
||||
{
|
||||
return 'carrier';
|
||||
}
|
||||
|
||||
public function collectsCash(ShippingMethod $method): bool
|
||||
{
|
||||
return (bool) ($method->data['collects_cash'] ?? false);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user