Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates

This commit is contained in:
2026-09-30 17:28:47 +03:00
parent f411dec1da
commit 49ec4333af
63 changed files with 3143 additions and 347 deletions
@@ -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
+2 -1
View File
@@ -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,
};
+26
View File
@@ -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,
};
}
+162 -36
View File
@@ -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;
}
}