2a10f9af38
**Shiplogic API Integration:**
- Fixed API base URL configuration (removed /api suffix)
- Implemented comprehensive request/response logging for rates and shipments endpoints
- Fixed PDF fetching: API returns S3 URLs, now downloads actual PDFs from S3
- Added tests and mock API responses for local development (routes/shiplogic-mock.php)
**Courier Service Enhancements:**
- Added redownloadShipmentPdfs() public method for re-downloading corrupted PDFs
- Enhanced error logging with full request/response bodies for debugging
- Proper binary PDF storage using Laravel Storage facade
- URL and S3 download handling for Shiplogic API responses
**Workflow & Operations:**
- Changed to manual "Ready for Collection" button instead of automatic move
- Operators now: scan QR → apply labels → click "Ready for Collection" → moves to Awaiting Collection
- Removed duplicate PDF attachments to Trello (was adding twice from two listeners)
- Fixed NotifySlackOnShipmentCreated to only handle Slack notifications
**Mobile-Optimized Ops Page:**
- Removed QR code display from order detail page
- Implemented responsive single-column layout for mobile phones
- Large touch-friendly buttons (full width, increased padding)
- Bold typography for better readability on small screens
- Larger input fields and tracking number displays
- Clear step-by-step instructions for warehouse operators
- Re-download PDF button for damaged/corrupted labels
**New Features:**
- POST /ops/orders/{uuid}/ready-for-collection endpoint
- Re-download PDFs functionality accessible from awaiting_collection and in_transit states
- Full audit logging for all operations via ops interface
- Proper error handling and user feedback
**Testing:**
- Added ShipmentCreationTest with mock HTTP client
- Created comprehensive testing guide (SHIPLOGIC_TESTING.md)
- Mock API routes for local development without hitting live API
728 lines
28 KiB
PHP
728 lines
28 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Order;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Carbon\Carbon;
|
|
|
|
class CourierService
|
|
{
|
|
protected string $apiKey;
|
|
protected string $baseUrl;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiKey = config('courier.api_key');
|
|
$this->baseUrl = config('courier.api_base_url', 'https://api.shiplogic.com');
|
|
}
|
|
|
|
/**
|
|
* Create shipment with full validation, rates fetching, and document retrieval
|
|
*
|
|
* @param Order $order
|
|
* @return array{waybill_id: string, tracking_number: string, sticker_path: ?string, waybill_path: ?string}
|
|
* @throws \Exception
|
|
*/
|
|
public function createShipmentForOrder(Order $order): array
|
|
{
|
|
Log::info('Creating shipment for order', ['order_uuid' => $order->uuid]);
|
|
|
|
// Guard 1: Validate packing
|
|
if (! $this->validatePacking($order)) {
|
|
throw new \Exception('Order not yet packed with valid dimensions');
|
|
}
|
|
Log::info('Packing validation passed', ['order_uuid' => $order->uuid]);
|
|
|
|
// Guard 2: Validate payment/approval
|
|
if (! $this->validatePayment($order)) {
|
|
throw new \Exception(
|
|
$order->is_custom_order ?
|
|
'Custom order requires proof approved and balance paid' :
|
|
'Standard order must be fully paid'
|
|
);
|
|
}
|
|
Log::info('Payment validation passed', ['order_uuid' => $order->uuid]);
|
|
// Guard 3: Validate order status
|
|
if ($order->status !== 'ready_to_ship') {
|
|
throw new \Exception('Order must be in Ready to Ship state');
|
|
}
|
|
Log::info('Order status validation passed', ['order_uuid' => $order->uuid]);
|
|
// Guard 4: Prevent duplicates
|
|
if ($order->courier_waybill_id) {
|
|
throw new \Exception('Shipment already exists for this order');
|
|
}
|
|
Log::info('Duplicate shipment validation passed', ['order_uuid' => $order->uuid]);
|
|
try {
|
|
// Step 1: Get rates and select ECO (cheapest) service level
|
|
Log::info('Fetching shipping rates', ['order_uuid' => $order->uuid]);
|
|
$rates = $this->getRates($order);
|
|
if (empty($rates)) {
|
|
throw new \Exception('No shipping rates available for this route');
|
|
}
|
|
Log::info('Shipping rates retrieved', [
|
|
'order_uuid' => $order->uuid,
|
|
'rate_count' => count($rates),
|
|
]);
|
|
// Select ECO rate (should be cheapest)
|
|
$selectedRate = $this->selectEcoRate($rates);
|
|
if (!$selectedRate) {
|
|
throw new \Exception('ECO service level not available for this route');
|
|
}
|
|
Log::info('Selected ECO service level', [
|
|
'order_uuid' => $order->uuid,
|
|
'service_level' => $selectedRate['service_level']['code'],
|
|
'rate' => $selectedRate['rate'],
|
|
]);
|
|
// Step 2: Build delivery address from order fields
|
|
$deliveryAddress = $this->buildDeliveryAddress($order);
|
|
Log::info('Built delivery address', [
|
|
'order_uuid' => $order->uuid,
|
|
'delivery_address' => $deliveryAddress,
|
|
]);
|
|
// Step 3: Determine collection and delivery minimum dates
|
|
[$collectionMinDate, $deliveryMinDate] = $this->getMinimumDates();
|
|
Log::info('Determined minimum dates', [
|
|
'order_uuid' => $order->uuid,
|
|
'collection_min_date' => $collectionMinDate,
|
|
'delivery_min_date' => $deliveryMinDate,
|
|
]);
|
|
// Step 4: Call API to create shipment with all proper data
|
|
$shipmentData = $this->callCreateShipmentApi(
|
|
$order,
|
|
$deliveryAddress,
|
|
$selectedRate,
|
|
$collectionMinDate,
|
|
$deliveryMinDate,
|
|
);
|
|
Log::info('Shipment created via API', [
|
|
'order_uuid' => $order->uuid,
|
|
'shipment_id' => $shipmentData['shipment_id'],
|
|
'waybill_id' => $shipmentData['waybill_id'],
|
|
'tracking_number' => $shipmentData['tracking_number'],
|
|
]);
|
|
// Step 5: Save shipment details to database
|
|
$order->update([
|
|
'courier_shipment_id' => $shipmentData['shipment_id'],
|
|
'courier_waybill_id' => $shipmentData['waybill_id'],
|
|
'courier_tracking_number' => $shipmentData['tracking_number'],
|
|
'courier_rate' => $selectedRate['rate'],
|
|
'courier_service_level_code' => $selectedRate['service_level']['code'],
|
|
'courier_service_level_id' => $selectedRate['service_level']['id'],
|
|
'courier_collection_min_date' => $collectionMinDate,
|
|
'courier_delivery_min_date' => $deliveryMinDate,
|
|
'courier_status' => 'awaiting_collection',
|
|
'status' => 'awaiting_collection',
|
|
]);
|
|
|
|
Log::info('Shipment created successfully', [
|
|
'order_uuid' => $order->uuid,
|
|
'shipment_id' => $shipmentData['shipment_id'],
|
|
'waybill_id' => $shipmentData['waybill_id'],
|
|
'service_level' => $selectedRate['service_level']['code'],
|
|
'rate' => $selectedRate['rate'],
|
|
]);
|
|
|
|
// Step 6: Fetch shipping documents
|
|
$stickerPath = $this->fetchAndStoreSticker($shipmentData['shipment_id'], $order->uuid);
|
|
$waybillPath = $this->fetchAndStoreWaybill($shipmentData['shipment_id'], $order->uuid);
|
|
|
|
return [
|
|
'shipment_id' => $shipmentData['shipment_id'],
|
|
'waybill_id' => $shipmentData['waybill_id'],
|
|
'tracking_number' => $shipmentData['tracking_number'],
|
|
'sticker_path' => $stickerPath,
|
|
'waybill_path' => $waybillPath,
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to create shipment', [
|
|
'order_uuid' => $order->uuid,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available shipping rates for an order
|
|
*/
|
|
private function getRates(Order $order): array
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
throw new \Exception('Courier API not configured');
|
|
}
|
|
|
|
try {
|
|
$deliveryAddress = $this->buildDeliveryAddress($order);
|
|
Log::info('Built delivery address for rates', [
|
|
'order_uuid' => $order->uuid,
|
|
'delivery_address' => $deliveryAddress,
|
|
]);
|
|
$collectionAddress = config('services.shiplogic.collection_address');
|
|
Log::info('Using collection address for rates', [
|
|
'order_uuid' => $order->uuid,
|
|
'collection_address' => $collectionAddress,
|
|
]);
|
|
$payload = [
|
|
'collection_address' => $collectionAddress,
|
|
'delivery_address' => $deliveryAddress,
|
|
'parcels' => [
|
|
[
|
|
'submitted_length_cm' => (float) $order->packing_length,
|
|
'submitted_width_cm' => (float) $order->packing_width,
|
|
'submitted_height_cm' => (float) ($order->packing_height ?? 10),
|
|
'submitted_weight_kg' => (float) $order->packing_weight,
|
|
],
|
|
],
|
|
];
|
|
Log::info('Prepared rates request payload', [
|
|
'order_uuid' => $order->uuid,
|
|
'payload' => $payload,
|
|
]);
|
|
|
|
$ratesUrl = "{$this->baseUrl}/rates";
|
|
Log::info('Fetching shipping rates from Shiplogic', [
|
|
'order_uuid' => $order->uuid,
|
|
'base_url' => $this->baseUrl,
|
|
'full_url' => $ratesUrl,
|
|
'api_key_set' => ! empty($this->apiKey),
|
|
'api_key_length' => strlen($this->apiKey ?? ''),
|
|
'collection_address' => $collectionAddress,
|
|
'delivery_address' => $deliveryAddress,
|
|
'parcel_dimensions' => [
|
|
'length' => $order->packing_length,
|
|
'width' => $order->packing_width,
|
|
'height' => $order->packing_height ?? 10,
|
|
'weight' => $order->packing_weight,
|
|
],
|
|
]);
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => "Bearer {$this->apiKey}",
|
|
])->post($ratesUrl, $payload);
|
|
|
|
Log::info('Rates API response received', [
|
|
'order_uuid' => $order->uuid,
|
|
'status' => $response->status(),
|
|
'successful' => $response->successful(),
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
$errorData = $response->json();
|
|
$errorMessage = $errorData['error']['message'] ?? $errorData['message'] ?? 'Unknown error';
|
|
|
|
Log::error('Rates API error response', [
|
|
'order_uuid' => $order->uuid,
|
|
'status' => $response->status(),
|
|
'error_message' => $errorMessage,
|
|
'full_response' => $response->json(),
|
|
]);
|
|
|
|
throw new \Exception("Failed to fetch rates: {$errorMessage}");
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
Log::info('Rates fetched successfully', [
|
|
'order_uuid' => $order->uuid,
|
|
'rate_count' => count($data['rates'] ?? []),
|
|
]);
|
|
|
|
return $data['rates'] ?? [];
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to get shipping rates', [
|
|
'order_uuid' => $order->uuid,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select the ECO (Economy) service level - should be the cheapest
|
|
*/
|
|
private function selectEcoRate(array $rates): ?array
|
|
{
|
|
foreach ($rates as $rate) {
|
|
if (isset($rate['service_level']) && $rate['service_level']['code'] === 'ECO') {
|
|
return $rate;
|
|
}
|
|
}
|
|
|
|
// If ECO not found, return the cheapest rate available
|
|
if (empty($rates)) {
|
|
return null;
|
|
}
|
|
|
|
usort($rates, function ($a, $b) {
|
|
return ($a['rate'] ?? PHP_INT_MAX) <=> ($b['rate'] ?? PHP_INT_MAX);
|
|
});
|
|
|
|
return $rates[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Build complete delivery address from order shipping fields
|
|
*/
|
|
private function buildDeliveryAddress(Order $order): array
|
|
{
|
|
// Combine unit number and street address
|
|
$streetAddress = $order->shipping_street_address;
|
|
if ($order->shipping_unit_number) {
|
|
$streetAddress = "{$order->shipping_unit_number}, {$streetAddress}";
|
|
}
|
|
|
|
return [
|
|
'type' => $order->shipping_type ?? 'residential',
|
|
'company' => $order->business_name ?? '',
|
|
'street_address' => $streetAddress,
|
|
'local_area' => $order->shipping_local_area ?? '',
|
|
'city' => $order->shipping_city ?? '',
|
|
'zone' => $order->shipping_zone ?? '',
|
|
'code' => $order->shipping_postcode ?? '',
|
|
'country' => $order->shipping_country ?? 'ZA',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Determine collection and delivery minimum dates
|
|
* If before noon: today
|
|
* If after noon: tomorrow
|
|
*/
|
|
private function getMinimumDates(): array
|
|
{
|
|
$now = Carbon::now();
|
|
$noon = Carbon::now()->setHour(12)->setMinute(0)->setSecond(0);
|
|
|
|
if ($now->isBefore($noon)) {
|
|
$date = $now->startOfDay();
|
|
} else {
|
|
$date = $now->addDay()->startOfDay();
|
|
}
|
|
|
|
return [$date, $date];
|
|
}
|
|
|
|
/**
|
|
* Create a shipment with Shiplogic API
|
|
*/
|
|
private function callCreateShipmentApi(
|
|
Order $order,
|
|
array $deliveryAddress,
|
|
array $selectedRate,
|
|
\DateTime $collectionMinDate,
|
|
\DateTime $deliveryMinDate,
|
|
): array {
|
|
if (! $this->isConfigured()) {
|
|
throw new \Exception('Courier API not configured');
|
|
}
|
|
Log::info('Preparing to create shipment via API', [
|
|
'order_uuid' => $order->uuid,
|
|
'order_number' => $order->order_number,
|
|
]);
|
|
// Validate required shipping info
|
|
if (! $order->customer_name || ! $order->shipping_street_address) {
|
|
throw new \Exception('Order missing required customer name or shipping address');
|
|
}
|
|
Log::info('Validated required shipping info', [
|
|
'order_uuid' => $order->uuid,
|
|
'customer_name' => $order->customer_name,
|
|
'shipping_street_address' => $order->shipping_street_address,
|
|
]);
|
|
if (! $order->customer_email && ! $order->customer_phone) {
|
|
throw new \Exception('Order must have at least email or phone number');
|
|
}
|
|
Log::info('Validated contact information', [
|
|
'order_uuid' => $order->uuid,
|
|
'customer_email' => $order->customer_email,
|
|
'customer_phone' => $order->customer_phone,
|
|
]);
|
|
try {
|
|
$collectionAddress = config('services.shiplogic.collection_address');
|
|
$collectionContact = config('services.shiplogic.collection_contact');
|
|
|
|
$payload = [
|
|
'collection_address' => $collectionAddress,
|
|
'collection_contact' => $collectionContact,
|
|
'delivery_address' => $deliveryAddress,
|
|
'delivery_contact' => [
|
|
'name' => $order->customer_name,
|
|
'email' => $order->customer_email ?? '',
|
|
'mobile_number' => $order->customer_phone ?? '',
|
|
],
|
|
'parcels' => [
|
|
[
|
|
'parcel_description' => $order->order_number,
|
|
'submitted_length_cm' => (float) $order->packing_length,
|
|
'submitted_width_cm' => (float) $order->packing_width,
|
|
'submitted_height_cm' => (float) ($order->packing_height ?? 10),
|
|
'submitted_weight_kg' => (float) $order->packing_weight,
|
|
],
|
|
],
|
|
'service_level_code' => $selectedRate['service_level']['code'],
|
|
'collection_min_date' => $collectionMinDate->format(DATE_ATOM),
|
|
'delivery_min_date' => $deliveryMinDate->format(DATE_ATOM),
|
|
'customer_reference' => $order->order_number,
|
|
'mute_notifications' => false,
|
|
];
|
|
|
|
$shipmentsUrl = "{$this->baseUrl}/shipments";
|
|
Log::info('Creating Shiplogic shipment', [
|
|
'order_uuid' => $order->uuid,
|
|
'order_number' => $order->order_number,
|
|
'customer' => $order->customer_name,
|
|
'delivery_address' => $deliveryAddress['street_address'],
|
|
'service_level' => $selectedRate['service_level']['code'],
|
|
'base_url' => $this->baseUrl,
|
|
'full_url' => $shipmentsUrl,
|
|
'api_key_set' => ! empty($this->apiKey),
|
|
'api_key_length' => strlen($this->apiKey ?? ''),
|
|
'payload' => $payload,
|
|
]);
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => "Bearer {$this->apiKey}",
|
|
])->post($shipmentsUrl, $payload);
|
|
|
|
Log::info('Shipment API response received', [
|
|
'order_uuid' => $order->uuid,
|
|
'status' => $response->status(),
|
|
'successful' => $response->successful(),
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
$errorData = $response->json();
|
|
$errorMessage = $errorData['error']['message'] ?? $errorData['message'] ?? 'Unknown error';
|
|
|
|
Log::error('Shipment API error response', [
|
|
'order_uuid' => $order->uuid,
|
|
'status' => $response->status(),
|
|
'error_message' => $errorMessage,
|
|
'full_response' => $response->json(),
|
|
]);
|
|
|
|
throw new \Exception("Courier API error: {$errorMessage}");
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
Log::info('Shipment created in API', [
|
|
'order_uuid' => $order->uuid,
|
|
'shipment_id' => $data['id'] ?? null,
|
|
'tracking_reference' => $data['short_tracking_reference'] ?? null,
|
|
]);
|
|
|
|
return [
|
|
'shipment_id' => $data['id'] ?? null,
|
|
'waybill_id' => $data['short_tracking_reference'] ?? $data['id'],
|
|
'tracking_number' => $data['short_tracking_reference'] ?? null,
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to call courier shipment API', [
|
|
'order_uuid' => $order->uuid,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch and store shipment label (waybill) PDF from Shiplogic
|
|
*/
|
|
private function fetchAndStoreWaybill(string $shipmentId, string $orderUuid): ?string
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$url = "{$this->baseUrl}/shipments/label?id={$shipmentId}";
|
|
Log::info('Fetching waybill PDF', [
|
|
'shipment_id' => $shipmentId,
|
|
'order_uuid' => $orderUuid,
|
|
'url' => $url,
|
|
]);
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => "Bearer {$this->apiKey}",
|
|
])->get($url);
|
|
|
|
Log::info('Waybill PDF response received', [
|
|
'shipment_id' => $shipmentId,
|
|
'status' => $response->status(),
|
|
'successful' => $response->successful(),
|
|
'content_type' => $response->header('Content-Type'),
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
// API returns a JSON with S3 URL, need to download the actual PDF
|
|
if (isset($data['url'])) {
|
|
Log::info('Got S3 URL for waybill PDF', [
|
|
'shipment_id' => $shipmentId,
|
|
's3_url' => $data['url'],
|
|
'filename' => $data['filename'] ?? 'unknown',
|
|
'file_size' => $data['file_size'] ?? 'unknown',
|
|
]);
|
|
|
|
// Download the actual PDF from S3
|
|
$pdfResponse = Http::get($data['url']);
|
|
|
|
if ($pdfResponse->successful()) {
|
|
$directory = "shipments/{$orderUuid}";
|
|
$path = "{$directory}/Shipment Label.pdf";
|
|
|
|
// Store the binary PDF content
|
|
$content = $pdfResponse->body();
|
|
Storage::disk('public')->put($path, $content);
|
|
|
|
Log::info('Waybill PDF stored successfully', [
|
|
'shipment_id' => $shipmentId,
|
|
'path' => $path,
|
|
'file_size' => strlen($content),
|
|
'exists' => Storage::disk('public')->exists($path),
|
|
]);
|
|
return $path;
|
|
} else {
|
|
Log::error('Failed to download waybill PDF from S3', [
|
|
'shipment_id' => $shipmentId,
|
|
's3_url' => $data['url'],
|
|
'status' => $pdfResponse->status(),
|
|
]);
|
|
return null;
|
|
}
|
|
} else {
|
|
Log::error('No S3 URL in waybill response', [
|
|
'shipment_id' => $shipmentId,
|
|
'response' => $data,
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Log::warning('Failed to fetch waybill from courier', [
|
|
'shipment_id' => $shipmentId,
|
|
'status' => $response->status(),
|
|
'response' => $response->json(),
|
|
]);
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
Log::error('Exception fetching waybill', [
|
|
'shipment_id' => $shipmentId,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch and store shipment sticker label PDF from Shiplogic
|
|
*/
|
|
private function fetchAndStoreSticker(string $shipmentId, string $orderUuid): ?string
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$url = "{$this->baseUrl}/shipments/label/stickers?id={$shipmentId}";
|
|
Log::info('Fetching sticker PDF', [
|
|
'shipment_id' => $shipmentId,
|
|
'order_uuid' => $orderUuid,
|
|
'url' => $url,
|
|
]);
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => "Bearer {$this->apiKey}",
|
|
])->get($url);
|
|
|
|
Log::info('Sticker PDF response received', [
|
|
'shipment_id' => $shipmentId,
|
|
'status' => $response->status(),
|
|
'successful' => $response->successful(),
|
|
'content_type' => $response->header('Content-Type'),
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
// API returns a JSON with S3 URL, need to download the actual PDF
|
|
if (isset($data['url'])) {
|
|
Log::info('Got S3 URL for sticker PDF', [
|
|
'shipment_id' => $shipmentId,
|
|
's3_url' => $data['url'],
|
|
'filename' => $data['filename'] ?? 'unknown',
|
|
'file_size' => $data['file_size'] ?? 'unknown',
|
|
]);
|
|
|
|
// Download the actual PDF from S3
|
|
$pdfResponse = Http::get($data['url']);
|
|
|
|
if ($pdfResponse->successful()) {
|
|
$directory = "shipments/{$orderUuid}";
|
|
$path = "{$directory}/Shipment Sticker.pdf";
|
|
|
|
// Store the binary PDF content
|
|
$content = $pdfResponse->body();
|
|
Storage::disk('public')->put($path, $content);
|
|
|
|
Log::info('Sticker PDF stored successfully', [
|
|
'shipment_id' => $shipmentId,
|
|
'path' => $path,
|
|
'file_size' => strlen($content),
|
|
'exists' => Storage::disk('public')->exists($path),
|
|
]);
|
|
return $path;
|
|
} else {
|
|
Log::error('Failed to download sticker PDF from S3', [
|
|
'shipment_id' => $shipmentId,
|
|
's3_url' => $data['url'],
|
|
'status' => $pdfResponse->status(),
|
|
]);
|
|
return null;
|
|
}
|
|
} else {
|
|
Log::error('No S3 URL in sticker response', [
|
|
'shipment_id' => $shipmentId,
|
|
'response' => $data,
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Log::warning('Failed to fetch sticker from courier', [
|
|
'shipment_id' => $shipmentId,
|
|
'status' => $response->status(),
|
|
'response' => $response->json(),
|
|
]);
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
Log::error('Exception fetching sticker', [
|
|
'shipment_id' => $shipmentId,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate packing gate: order must be packed with dimensions
|
|
*/
|
|
private function validatePacking(Order $order): bool
|
|
{
|
|
$valid = $order->packing_completed_at !== null &&
|
|
$order->packing_width > 0 &&
|
|
$order->packing_length > 0 &&
|
|
$order->packing_weight > 0;
|
|
|
|
if (! $valid) {
|
|
Log::warning('Packing validation failed', [
|
|
'order_uuid' => $order->uuid,
|
|
'packing_completed_at' => $order->packing_completed_at,
|
|
'width' => $order->packing_width,
|
|
'length' => $order->packing_length,
|
|
'weight' => $order->packing_weight,
|
|
]);
|
|
}
|
|
|
|
return $valid;
|
|
}
|
|
|
|
/**
|
|
* Validate payment and approval gate
|
|
*/
|
|
private function validatePayment(Order $order): bool
|
|
{
|
|
// Standard orders: must be fully paid
|
|
if ($order->is_custom_order === false) {
|
|
$valid = $order->payment_status === 'paid';
|
|
} else {
|
|
// Custom orders: proof must be approved and balance must be paid
|
|
// TODO: Add proof_approved and balance_status fields to CustomOrder model
|
|
$valid = true; // Placeholder
|
|
}
|
|
|
|
if (! $valid) {
|
|
Log::warning('Payment validation failed', [
|
|
'order_uuid' => $order->uuid,
|
|
'is_custom_order' => $order->is_custom_order,
|
|
'payment_status' => $order->payment_status,
|
|
]);
|
|
}
|
|
|
|
return $valid;
|
|
}
|
|
|
|
/**
|
|
* Check if courier is configured
|
|
*/
|
|
protected function isConfigured(): bool
|
|
{
|
|
return ! empty($this->apiKey);
|
|
}
|
|
|
|
/**
|
|
* Public method to re-download shipment PDFs for an existing shipment
|
|
*
|
|
* @param Order $order
|
|
* @return array{success: bool, sticker_path: ?string, waybill_path: ?string, message: string}
|
|
*/
|
|
public function redownloadShipmentPdfs(Order $order): array
|
|
{
|
|
if (! $order->courier_shipment_id) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'No shipment exists for this order',
|
|
'sticker_path' => null,
|
|
'waybill_path' => null,
|
|
];
|
|
}
|
|
|
|
try {
|
|
Log::info('Re-downloading shipment PDFs', [
|
|
'order_uuid' => $order->uuid,
|
|
'shipment_id' => $order->courier_shipment_id,
|
|
]);
|
|
|
|
$stickerPath = $this->fetchAndStoreSticker($order->courier_shipment_id, $order->uuid);
|
|
$waybillPath = $this->fetchAndStoreWaybill($order->courier_shipment_id, $order->uuid);
|
|
|
|
Log::info('Shipment PDFs re-downloaded successfully', [
|
|
'order_uuid' => $order->uuid,
|
|
'sticker_path' => $stickerPath,
|
|
'waybill_path' => $waybillPath,
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'PDFs re-downloaded successfully',
|
|
'sticker_path' => $stickerPath,
|
|
'waybill_path' => $waybillPath,
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to re-download shipment PDFs', [
|
|
'order_uuid' => $order->uuid,
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Failed to re-download PDFs: ' . $e->getMessage(),
|
|
'sticker_path' => null,
|
|
'waybill_path' => null,
|
|
];
|
|
}
|
|
}
|
|
}
|