feat: Complete Shiplogic integration with mobile-optimized ops workflow
**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
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InspectionFailed
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $reason = '',
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToAwaitingApproval
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToInspection
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToPrep
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToPrinting
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToReadyForPrint
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ class OpsController extends Controller
|
||||
$order->update([
|
||||
'packing_width' => $validated['width'],
|
||||
'packing_length' => $validated['length'],
|
||||
'packing_height' => $validated['height'],
|
||||
'packing_weight' => $validated['weight'],
|
||||
'packing_completed_at' => now(),
|
||||
'packed_by' => auth()->id(),
|
||||
@@ -175,7 +176,7 @@ class OpsController extends Controller
|
||||
]);
|
||||
|
||||
// Emit event for listeners to alert ops
|
||||
// TODO: Create InspectionFailed event
|
||||
\App\Events\InspectionFailed::dispatch($order, $validated['issue_description']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -238,4 +239,118 @@ class OpsController extends Controller
|
||||
"QR-{$order->order_number}.pdf"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-download shipment PDFs from Shiplogic API
|
||||
*
|
||||
* POST /ops/orders/{id}/redownload-pdfs
|
||||
*/
|
||||
public function redownloadShipmentPdfs(Order $order)
|
||||
{
|
||||
// Authorize
|
||||
if (! auth()->user()->can('access-ops')) {
|
||||
abort(403, 'Unauthorized to access ops interface');
|
||||
}
|
||||
|
||||
// Only allow for orders with shipments
|
||||
if (! $order->courier_shipment_id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No shipment exists for this order',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$courierService = new \App\Services\CourierService();
|
||||
$result = $courierService->redownloadShipmentPdfs($order);
|
||||
|
||||
Log::info('Shipment PDFs re-downloaded via ops', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'user_id' => auth()->id(),
|
||||
'user_name' => auth()->user()->name,
|
||||
'success' => $result['success'],
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $result['message'],
|
||||
'sticker_path' => $result['sticker_path'],
|
||||
'waybill_path' => $result['waybill_path'],
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $result['message'],
|
||||
], 500);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to re-download shipment PDFs via ops', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to re-download PDFs: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark order as ready for collection and move Trello card
|
||||
*
|
||||
* POST /ops/orders/{id}/ready-for-collection
|
||||
*/
|
||||
public function markReadyForCollection(Order $order)
|
||||
{
|
||||
// Authorize
|
||||
if (! auth()->user()->can('access-ops')) {
|
||||
abort(403, 'Unauthorized to access ops interface');
|
||||
}
|
||||
|
||||
// Only allow for orders with shipments in ready_to_ship status
|
||||
if (! $order->courier_shipment_id || $order->status !== 'ready_to_ship') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Order must have a shipment and be in Ready to Ship status',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update order status
|
||||
$order->update([
|
||||
'status' => 'awaiting_collection',
|
||||
'courier_status' => 'awaiting_collection',
|
||||
]);
|
||||
|
||||
// Move Trello card to Awaiting Collection
|
||||
if ($order->trello_card_id) {
|
||||
$trelloService = new \App\Services\TrelloService();
|
||||
$trelloService->moveCard($order->trello_card_id, 'Awaiting Collection');
|
||||
}
|
||||
|
||||
Log::info('Order marked ready for collection via ops', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'user_id' => auth()->id(),
|
||||
'user_name' => auth()->user()->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Order moved to Awaiting Collection',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to mark order ready for collection', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to mark ready for collection: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +117,14 @@ class OrderController extends Controller
|
||||
'customer_email' => 'required|email',
|
||||
'customer_phone' => 'required|string|max:20',
|
||||
'shipping_street_address' => 'required|string|max:255',
|
||||
'shipping_unit_number' => 'nullable|string|max:255',
|
||||
'shipping_local_area' => 'required|string|max:255',
|
||||
'shipping_city' => 'required|string|max:255',
|
||||
'shipping_zone' => 'required|string|max:255',
|
||||
'shipping_postcode' => 'required|string|max:20',
|
||||
'shipping_country' => 'required|string|max:2',
|
||||
'shipping_country' => 'required|string|max:255',
|
||||
'shipping_type' => 'required|in:residential,business',
|
||||
'business_name' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:500'
|
||||
]);
|
||||
|
||||
@@ -226,12 +228,14 @@ class OrderController extends Controller
|
||||
'customer_email' => $request->input('customer_email'),
|
||||
'customer_phone' => $request->input('customer_phone'),
|
||||
'shipping_street_address' => $request->input('shipping_street_address'),
|
||||
'shipping_unit_number' => $request->input('shipping_unit_number'),
|
||||
'shipping_local_area' => $request->input('shipping_local_area'),
|
||||
'shipping_city' => $request->input('shipping_city'),
|
||||
'shipping_zone' => $request->input('shipping_zone'),
|
||||
'shipping_postcode' => $request->input('shipping_postcode'),
|
||||
'shipping_country' => $request->input('shipping_country'),
|
||||
'shipping_type' => $request->input('shipping_type'),
|
||||
'business_name' => $request->input('business_name'),
|
||||
];
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
|
||||
@@ -47,6 +47,7 @@ class PackingController extends Controller
|
||||
$order->update([
|
||||
'packing_width' => $validated['width'],
|
||||
'packing_length' => $validated['length'],
|
||||
'packing_height' => $validated['height'],
|
||||
'packing_weight' => $validated['weight'],
|
||||
'packing_completed_at' => now(),
|
||||
'packed_by' => Auth::id(),
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ReadyToShipIntent;
|
||||
use App\Events\OrderMovedToPrep;
|
||||
use App\Events\OrderMovedToAwaitingApproval;
|
||||
use App\Events\OrderMovedToReadyForPrint;
|
||||
use App\Events\OrderMovedToPrinting;
|
||||
use App\Events\OrderMovedToInspection;
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -106,9 +111,26 @@ class TrelloWebhookController extends Controller
|
||||
|
||||
// Handle list-specific actions
|
||||
match ($listName) {
|
||||
'Prep' => $order->update(['status' => 'prep']),
|
||||
'Printing' => $order->update(['status' => 'printing']),
|
||||
'Inspection' => $order->update(['status' => 'inspection']),
|
||||
'Prep/Design' => (function () use ($order) {
|
||||
$order->update(['status' => 'prep']);
|
||||
OrderMovedToPrep::dispatch($order);
|
||||
})(),
|
||||
'Awaiting Customer Approval' => (function () use ($order) {
|
||||
$order->update(['status' => 'awaiting_approval']);
|
||||
OrderMovedToAwaitingApproval::dispatch($order);
|
||||
})(),
|
||||
'Ready for Print' => (function () use ($order) {
|
||||
$order->update(['status' => 'ready_for_print']);
|
||||
OrderMovedToReadyForPrint::dispatch($order);
|
||||
})(),
|
||||
'Printing' => (function () use ($order) {
|
||||
$order->update(['status' => 'printing']);
|
||||
OrderMovedToPrinting::dispatch($order);
|
||||
})(),
|
||||
'Inspection' => (function () use ($order) {
|
||||
$order->update(['status' => 'inspection']);
|
||||
OrderMovedToInspection::dispatch($order);
|
||||
})(),
|
||||
'Packing' => $order->update(['status' => 'packing']),
|
||||
'Ready to Ship' => (function () use ($order, $cardId) {
|
||||
$order->update(['status' => 'ready_to_ship']);
|
||||
|
||||
@@ -4,17 +4,21 @@ namespace App\Listeners;
|
||||
|
||||
use App\Events\ReadyToShipIntent;
|
||||
use App\Services\CourierService;
|
||||
use App\Services\TrelloService;
|
||||
use App\Events\ShipmentCreated;
|
||||
use App\Events\ShipmentCreationFailed;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CreateShipmentOnReadyToShip
|
||||
{
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct(protected CourierService $courierService)
|
||||
{
|
||||
public function __construct(
|
||||
protected CourierService $courierService,
|
||||
protected TrelloService $trelloService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +35,11 @@ class CreateShipmentOnReadyToShip
|
||||
try {
|
||||
$result = $this->courierService->createShipmentForOrder($order);
|
||||
|
||||
// Attach shipment PDFs to Trello card
|
||||
if ($order->trello_card_id) {
|
||||
$this->attachShipmentDocumentsToTrello($order, $result);
|
||||
}
|
||||
|
||||
// Emit event to trigger Slack/Trello updates
|
||||
ShipmentCreated::dispatch(
|
||||
$order,
|
||||
@@ -49,4 +58,48 @@ class CreateShipmentOnReadyToShip
|
||||
ShipmentCreationFailed::dispatch($order, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach shipment documents (label and sticker) to the Trello card
|
||||
*/
|
||||
private function attachShipmentDocumentsToTrello($order, array $result): void
|
||||
{
|
||||
if (! $result['sticker_path'] && ! $result['waybill_path']) {
|
||||
Log::warning('No shipment documents to attach', ['order_uuid' => $order->uuid]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Attach sticker PDF
|
||||
if ($result['sticker_path'] && Storage::disk('public')->exists($result['sticker_path'])) {
|
||||
$stickerUrl = asset('storage/' . $result['sticker_path']);
|
||||
$this->trelloService->attachFile($order->trello_card_id, 'Shipment Sticker.pdf', $stickerUrl);
|
||||
Log::info('Sticker attached to Trello card', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Attach waybill/label PDF
|
||||
if ($result['waybill_path'] && Storage::disk('public')->exists($result['waybill_path'])) {
|
||||
$waybillUrl = asset('storage/' . $result['waybill_path']);
|
||||
$this->trelloService->attachFile($order->trello_card_id, 'Shipment Label.pdf', $waybillUrl);
|
||||
Log::info('Waybill attached to Trello card', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('Shipment documents attached to Trello card', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to attach shipment documents to Trello', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class MoveCardToPackingOnInspectionPassed implements ShouldQueue
|
||||
|
||||
try {
|
||||
// Move card to Packing list
|
||||
$success = $this->trello->moveCard($order->trello_card_id, 'packing');
|
||||
$success = $this->trello->moveCard($order->trello_card_id, 'Packing');
|
||||
|
||||
if ($success) {
|
||||
Log::info('Trello card moved to Packing', [
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToInspection;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnCardMovedToInspection
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
) {}
|
||||
|
||||
public function handle(OrderMovedToInspection $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
$message = "🔍 Order Moved to Inspection\n" .
|
||||
"Order: #{$order->order_number}\n" .
|
||||
"Status: Ready for Quality Check";
|
||||
|
||||
// Notify Slack #production
|
||||
$this->slack->production($message);
|
||||
|
||||
Log::info('Card moved to inspection notification sent to production', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\InspectionFailed;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnInspectionFailed
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
) {}
|
||||
|
||||
public function handle(InspectionFailed $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
$message = "⚠️ Order Inspection Failed\n" .
|
||||
"Order: #{$order->order_number}\n" .
|
||||
"Reason: {$event->reason}\n" .
|
||||
"Status: Awaiting Review";
|
||||
|
||||
// Notify Slack #ops-alerts
|
||||
$this->slack->opsAlerts($message);
|
||||
|
||||
Log::info('Inspection failed notification sent to ops-alerts', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'reason' => $event->reason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\InspectionPassed;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnInspectionPassed
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
) {}
|
||||
|
||||
public function handle(InspectionPassed $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
$message = "✅ Order Inspection Passed\n" .
|
||||
"Order: #{$order->order_number}\n" .
|
||||
"Status: Ready for Packing";
|
||||
|
||||
// Notify Slack #production
|
||||
$this->slack->production($message);
|
||||
|
||||
Log::info('Inspection passed notification sent to production', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToAwaitingApproval;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToAwaitingApproval
|
||||
{
|
||||
public function handle(OrderMovedToAwaitingApproval $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.design_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order awaiting approval', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack design channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Awaiting Customer Approval',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '⏳ Order Awaiting Customer Approval',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\nAwaiting Approval",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order awaiting approval', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'design',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order awaiting approval', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToPrep;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToPrep
|
||||
{
|
||||
public function handle(OrderMovedToPrep $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.design_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order moved to prep', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack design channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Moved to Prep/Design',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '📋 Order Moved to Prep/Design',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\n{$order->status}",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order moved to prep', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'design',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order moved to prep', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToPrinting;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToPrinting
|
||||
{
|
||||
public function handle(OrderMovedToPrinting $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.production_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order printing started', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack production channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Printing Started',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '🖨️ Order Printing Started',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\nPrinting in Progress",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order printing started', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'production',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order printing started', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToReadyForPrint;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToReadyForPrint
|
||||
{
|
||||
public function handle(OrderMovedToReadyForPrint $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.production_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order ready for print', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack production channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Ready for Print',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '✅ Order Ready for Print',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\nReady for Print",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order ready for print', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'production',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order ready for print', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,25 @@ class NotifySlackOnOrderPacked
|
||||
|
||||
Log::info('Order packed notification sent', ['order_uuid' => $order->uuid]);
|
||||
|
||||
// Move Trello card to Packing list
|
||||
// Move Trello card to Ready to Ship and populate shipping dimensions as custom fields
|
||||
if ($order->trello_card_id) {
|
||||
$this->trello->moveCard($order->trello_card_id, 'Packing');
|
||||
// Move card to Ready to Ship list
|
||||
$this->trello->moveCard($order->trello_card_id, 'Ready to Ship');
|
||||
|
||||
// Populate custom fields with packing dimensions
|
||||
$this->trello->setNumberField($order->trello_card_id, 'ship_w', $event->width);
|
||||
$this->trello->setNumberField($order->trello_card_id, 'ship_h', $event->height);
|
||||
$this->trello->setNumberField($order->trello_card_id, 'ship_l', $event->length);
|
||||
$this->trello->setNumberField($order->trello_card_id, 'weight', $event->weight);
|
||||
|
||||
// Try to check off "Packed" item in checklist
|
||||
$this->trello->checkItem($order->trello_card_id, 'Packing', 'Packed');
|
||||
|
||||
Log::info('Trello card moved to Packing', ['order_uuid' => $order->uuid]);
|
||||
Log::info('Trello card moved to Ready to Ship and dimensions added', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'dimensions' => "{$event->width}x{$event->length}x{$event->height}cm",
|
||||
'weight' => "{$event->weight}kg",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,22 +28,7 @@ class NotifySlackOnShipmentCreated
|
||||
|
||||
Log::info('Shipment created notification sent', ['order_uuid' => $order->uuid]);
|
||||
|
||||
// Attach shipping documents to Trello card
|
||||
if ($order->trello_card_id) {
|
||||
if ($event->stickerPath && Storage::disk('public')->exists($event->stickerPath)) {
|
||||
$stickerUrl = Storage::disk('public')->url($event->stickerPath);
|
||||
$this->trello->attachFile($order->trello_card_id, 'Sticker.pdf', $stickerUrl);
|
||||
}
|
||||
|
||||
if ($event->waybillPath && Storage::disk('public')->exists($event->waybillPath)) {
|
||||
$waybillUrl = Storage::disk('public')->url($event->waybillPath);
|
||||
$this->trello->attachFile($order->trello_card_id, 'Waybill.pdf', $waybillUrl);
|
||||
}
|
||||
|
||||
// Move card to Awaiting Collection
|
||||
$this->trello->moveCard($order->trello_card_id, 'Awaiting Collection');
|
||||
|
||||
Log::info('Trello card updated with shipment details', ['order_uuid' => $order->uuid]);
|
||||
}
|
||||
// Note: PDFs are already attached to Trello card by CreateShipmentOnReadyToShip listener
|
||||
// This listener only handles Slack notification
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class CustomOrder extends Model
|
||||
'proof_approved_at',
|
||||
'packing_width',
|
||||
'packing_length',
|
||||
'packing_height',
|
||||
'packing_weight',
|
||||
'packing_completed_at',
|
||||
'packed_by',
|
||||
|
||||
@@ -27,12 +27,14 @@ class Order extends Model
|
||||
'customer_phone',
|
||||
'shipping_address',
|
||||
'shipping_street_address',
|
||||
'shipping_unit_number',
|
||||
'shipping_local_area',
|
||||
'shipping_city',
|
||||
'shipping_zone',
|
||||
'shipping_country',
|
||||
'shipping_postcode',
|
||||
'shipping_type',
|
||||
'business_name',
|
||||
'notes',
|
||||
'yoco_checkout_id',
|
||||
'yoco_redirect_url',
|
||||
@@ -40,12 +42,19 @@ class Order extends Model
|
||||
'yoco_payment_id',
|
||||
'packing_width',
|
||||
'packing_length',
|
||||
'packing_height',
|
||||
'packing_weight',
|
||||
'packing_completed_at',
|
||||
'packed_by',
|
||||
'courier_shipment_id',
|
||||
'courier_waybill_id',
|
||||
'courier_tracking_number',
|
||||
'courier_status',
|
||||
'courier_rate',
|
||||
'courier_service_level_code',
|
||||
'courier_service_level_id',
|
||||
'courier_collection_min_date',
|
||||
'courier_delivery_min_date',
|
||||
'delivered_at',
|
||||
'delivery_failure_reason',
|
||||
'trello_card_id',
|
||||
@@ -59,6 +68,8 @@ class Order extends Model
|
||||
'packing_completed_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
'qr_generated_at' => 'datetime',
|
||||
'courier_collection_min_date' => 'datetime',
|
||||
'courier_delivery_min_date' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -5,7 +5,13 @@ namespace App\Providers;
|
||||
use App\Events\BalancePaid;
|
||||
use App\Events\DepositPaid;
|
||||
use App\Events\InspectionPassed;
|
||||
use App\Events\InspectionFailed;
|
||||
use App\Events\OrderCreated;
|
||||
use App\Events\OrderMovedToPrep;
|
||||
use App\Events\OrderMovedToAwaitingApproval;
|
||||
use App\Events\OrderMovedToReadyForPrint;
|
||||
use App\Events\OrderMovedToPrinting;
|
||||
use App\Events\OrderMovedToInspection;
|
||||
use App\Events\OrderPacked;
|
||||
use App\Events\ParcelCollected;
|
||||
use App\Events\ParcelDelivered;
|
||||
@@ -21,12 +27,19 @@ use App\Listeners\CreateShipmentOnReadyToShip;
|
||||
use App\Listeners\GenerateQrCodeOnOrderCreated;
|
||||
use App\Listeners\MoveCardToPackingOnInspectionPassed;
|
||||
use App\Listeners\NotifySlackOnOrderCreated;
|
||||
use App\Listeners\NotifySlackOnOrderMovedToPrep;
|
||||
use App\Listeners\NotifySlackOnOrderMovedToAwaitingApproval;
|
||||
use App\Listeners\NotifySlackOnOrderMovedToReadyForPrint;
|
||||
use App\Listeners\NotifySlackOnOrderMovedToPrinting;
|
||||
use App\Listeners\NotifySlackOnOrderPacked;
|
||||
use App\Listeners\NotifySlackOnParcelCollected;
|
||||
use App\Listeners\NotifySlackOnParcelDelivered;
|
||||
use App\Listeners\NotifySlackOnParcelFailedDelivery;
|
||||
use App\Listeners\NotifySlackOnShipmentCreated;
|
||||
use App\Listeners\NotifySlackOnShipmentCreationFailed;
|
||||
use App\Listeners\NotifySlackOnInspectionPassed;
|
||||
use App\Listeners\NotifySlackOnInspectionFailed;
|
||||
use App\Listeners\NotifySlackOnCardMovedToInspection;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
@@ -45,6 +58,20 @@ class EventServiceProvider extends ServiceProvider
|
||||
GenerateQrCodeOnOrderCreated::class,
|
||||
],
|
||||
|
||||
// Trello card move events
|
||||
OrderMovedToPrep::class => [
|
||||
NotifySlackOnOrderMovedToPrep::class,
|
||||
],
|
||||
OrderMovedToAwaitingApproval::class => [
|
||||
NotifySlackOnOrderMovedToAwaitingApproval::class,
|
||||
],
|
||||
OrderMovedToReadyForPrint::class => [
|
||||
NotifySlackOnOrderMovedToReadyForPrint::class,
|
||||
],
|
||||
OrderMovedToPrinting::class => [
|
||||
NotifySlackOnOrderMovedToPrinting::class,
|
||||
],
|
||||
|
||||
// Packing events
|
||||
OrderPacked::class => [
|
||||
NotifySlackOnOrderPacked::class,
|
||||
@@ -53,6 +80,13 @@ class EventServiceProvider extends ServiceProvider
|
||||
// Inspection events
|
||||
InspectionPassed::class => [
|
||||
MoveCardToPackingOnInspectionPassed::class,
|
||||
NotifySlackOnInspectionPassed::class,
|
||||
],
|
||||
InspectionFailed::class => [
|
||||
NotifySlackOnInspectionFailed::class,
|
||||
],
|
||||
OrderMovedToInspection::class => [
|
||||
NotifySlackOnCardMovedToInspection::class,
|
||||
],
|
||||
|
||||
// Ready to Ship intent (from Trello webhook)
|
||||
@@ -60,8 +94,6 @@ class EventServiceProvider extends ServiceProvider
|
||||
CreateShipmentOnReadyToShip::class,
|
||||
],
|
||||
|
||||
//
|
||||
|
||||
// Shipment events
|
||||
ShipmentCreated::class => [
|
||||
NotifySlackOnShipmentCreated::class,
|
||||
|
||||
+555
-156
@@ -6,6 +6,7 @@ use App\Models\Order;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CourierService
|
||||
{
|
||||
@@ -15,11 +16,11 @@ class CourierService
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('courier.api_key');
|
||||
$this->baseUrl = config('courier.api_base_url', 'https://api.shiplogic.com/api');
|
||||
$this->baseUrl = config('courier.api_base_url', 'https://api.shiplogic.com');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create shipment with full validation and database updates
|
||||
* 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}
|
||||
@@ -33,52 +34,103 @@ class CourierService
|
||||
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'
|
||||
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 {
|
||||
// Call API to create shipment
|
||||
// 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->id,
|
||||
$order->packing_width,
|
||||
$order->packing_length,
|
||||
$order->packing_weight,
|
||||
$order,
|
||||
$deliveryAddress,
|
||||
$selectedRate,
|
||||
$collectionMinDate,
|
||||
$deliveryMinDate,
|
||||
);
|
||||
|
||||
// Save shipment details to database
|
||||
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'],
|
||||
]);
|
||||
|
||||
// Fetch shipping documents
|
||||
$stickerPath = $this->fetchSticker($shipmentData['shipment_id'], $order->id);
|
||||
$waybillPath = $this->fetchWaybill($shipmentData['shipment_id'], $order->id);
|
||||
// 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,
|
||||
@@ -95,104 +147,96 @@ class CourierService
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shipment with Shiplogic API
|
||||
*
|
||||
* @param string $orderId
|
||||
* @param float $width Width in cm
|
||||
* @param float $length Length in cm
|
||||
* @param float $weight Weight in kg
|
||||
* @return array{shipment_id: string, waybill_id: string, tracking_number: string}
|
||||
* @throws \Exception
|
||||
* Get available shipping rates for an order
|
||||
*/
|
||||
private function callCreateShipmentApi(string $orderId, float $width, float $length, float $weight): array
|
||||
private function getRates(Order $order): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new \Exception('Courier API not configured');
|
||||
}
|
||||
|
||||
if ($width <= 0 || $length <= 0 || $weight <= 0) {
|
||||
throw new \Exception('Invalid dimensions or weight: all must be greater than 0');
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch order to get customer and shipping details
|
||||
$order = Order::findOrFail($orderId);
|
||||
|
||||
// Validate required shipping info
|
||||
if (! $order->customer_name || ! $order->shipping_street_address) {
|
||||
throw new \Exception('Order missing required customer name or shipping address');
|
||||
}
|
||||
|
||||
if (! $order->customer_email && ! $order->customer_phone) {
|
||||
throw new \Exception('Order must have at least email or phone number');
|
||||
}
|
||||
|
||||
// Build shipment payload for Shiplogic
|
||||
$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' => [
|
||||
'street' => 'Two Tales Designs', // TODO: Get from AppSetting
|
||||
'city' => 'Cape Town',
|
||||
'postcode' => '8000',
|
||||
'country' => 'ZA',
|
||||
],
|
||||
'collection_contact' => [
|
||||
'email' => config('mail.from.address'),
|
||||
'mobile_number' => '+27000000000', // TODO: Get from AppSetting
|
||||
],
|
||||
'delivery_address' => [
|
||||
'type' => $order->shipping_type ?? 'residential',
|
||||
'street_address' => $order->shipping_street_address,
|
||||
'local_area' => $order->shipping_local_area,
|
||||
'city' => $order->shipping_city,
|
||||
'zone' => $order->shipping_zone,
|
||||
'code' => $order->shipping_postcode,
|
||||
'country' => $order->shipping_country ?? 'ZA',
|
||||
],
|
||||
'delivery_contact' => [
|
||||
'name' => $order->customer_name,
|
||||
'email' => $order->customer_email,
|
||||
'mobile_number' => $order->customer_phone,
|
||||
],
|
||||
'collection_address' => $collectionAddress,
|
||||
'delivery_address' => $deliveryAddress,
|
||||
'parcels' => [
|
||||
[
|
||||
'weight' => $weight,
|
||||
'height' => 10, // TODO: Update when height is captured separately
|
||||
'width' => $width,
|
||||
'length' => $length,
|
||||
'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_id' => $this->getServiceLevelId(), // Standard delivery
|
||||
'customer_reference' => $order->order_number,
|
||||
'mute_notifications' => false,
|
||||
];
|
||||
Log::info('Prepared rates request payload', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
Log::info('Creating Shiplogic shipment', [
|
||||
'order_id' => $orderId,
|
||||
'order_number' => $order->order_number,
|
||||
'customer' => $order->customer_name,
|
||||
'delivery_address' => $street,
|
||||
$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("{$this->baseUrl}/shipments", $payload);
|
||||
])->post($ratesUrl, $payload);
|
||||
|
||||
Log::info('Rates API response received', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'status' => $response->status(),
|
||||
'successful' => $response->successful(),
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$errorMessage = $response->json('error.message', $response->json('message', 'Unknown error'));
|
||||
throw new \Exception("Courier API error: {$errorMessage}");
|
||||
$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();
|
||||
|
||||
return [
|
||||
'shipment_id' => $data['id'] ?? null,
|
||||
'waybill_id' => $data['waybill_number'] ?? null,
|
||||
'tracking_number' => $data['tracking_number'] ?? null,
|
||||
];
|
||||
Log::info('Rates fetched successfully', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'rate_count' => count($data['rates'] ?? []),
|
||||
]);
|
||||
|
||||
return $data['rates'] ?? [];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to call courier API', [
|
||||
'order_id' => $orderId,
|
||||
Log::error('Failed to get shipping rates', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
@@ -200,12 +244,375 @@ class CourierService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service level ID for standard delivery
|
||||
* TODO: Move to AppSetting and make configurable
|
||||
* Select the ECO (Economy) service level - should be the cheapest
|
||||
*/
|
||||
private function getServiceLevelId(): int
|
||||
private function selectEcoRate(array $rates): ?array
|
||||
{
|
||||
return 1; // Standard service level
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,68 +663,6 @@ class CourierService
|
||||
return $valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch shipping label/sticker PDF from Shiplogic
|
||||
*/
|
||||
public function fetchSticker(string $shipmentId, string $orderId): ?string
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$this->apiKey}",
|
||||
])->get("{$this->baseUrl}/shipments/{$shipmentId}/sticker");
|
||||
|
||||
if ($response->successful()) {
|
||||
$path = "shipments/{$orderId}/sticker.pdf";
|
||||
Storage::disk('public')->put($path, $response->body());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
Log::warning('Failed to fetch sticker from courier', ['shipment_id' => $shipmentId]);
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Exception fetching sticker', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch waybill PDF from Shiplogic
|
||||
*/
|
||||
public function fetchWaybill(string $shipmentId, string $orderId): ?string
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$this->apiKey}",
|
||||
])->get("{$this->baseUrl}/shipments/{$shipmentId}/label");
|
||||
|
||||
if ($response->successful()) {
|
||||
$path = "shipments/{$orderId}/waybill.pdf";
|
||||
Storage::disk('public')->put($path, $response->body());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
Log::warning('Failed to fetch waybill from courier', ['shipment_id' => $shipmentId]);
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Exception fetching waybill', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if courier is configured
|
||||
*/
|
||||
@@ -325,4 +670,58 @@ class CourierService
|
||||
{
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,20 @@ class TrelloService
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to a card
|
||||
*/
|
||||
public function addComment(string $cardId, string $text): bool
|
||||
{
|
||||
$response = Http::post("{$this->baseUrl}/cards/{$cardId}/actions/comments", [
|
||||
'text' => $text,
|
||||
'key' => $this->apiKey,
|
||||
'token' => $this->apiToken,
|
||||
]);
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark checklist item complete
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Testing;
|
||||
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Mock HTTP Client for testing Shiplogic API interactions
|
||||
*
|
||||
* Usage in tests:
|
||||
* ShiplogicMockClient::setup();
|
||||
* // Now all HTTP requests to shiplogic will return mock responses
|
||||
*/
|
||||
class ShiplogicMockClient
|
||||
{
|
||||
/**
|
||||
* Setup mock HTTP responses for Shiplogic API
|
||||
*/
|
||||
public static function setup(): void
|
||||
{
|
||||
Http::fake([
|
||||
'shiplogic.*/rates' => Http::response(self::ratesResponse(), 200),
|
||||
'shiplogic.*/shipments' => Http::response(self::shipmentsResponse(), 201),
|
||||
'shiplogic.*/shipments/label' => Http::response(self::pdfResponse(), 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
]),
|
||||
'shiplogic.*/shipments/label/stickers' => Http::response(self::pdfResponse(), 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock response for /rates endpoint
|
||||
*/
|
||||
public static function ratesResponse(): array
|
||||
{
|
||||
return [
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440001',
|
||||
'company_shipment_rates' => [
|
||||
[
|
||||
'id' => '9f67ff00-7a82-4d81-9481-4c5d8c8f1a00',
|
||||
'company_code' => 'FEDEX',
|
||||
'company_name' => 'FedEx',
|
||||
'service_level' => [
|
||||
'id' => '123456789',
|
||||
'code' => 'FEDEX_INTERNATIONAL_PRIORITY_EXPRESS',
|
||||
'name' => 'International Priority Express',
|
||||
'description' => 'Fastest service',
|
||||
],
|
||||
'rate' => 125.50,
|
||||
'currency' => 'GBP',
|
||||
'transit_days' => '1-2',
|
||||
'delivery_guarantee_date' => '2026-01-05',
|
||||
],
|
||||
[
|
||||
'id' => '9f67ff00-7a82-4d81-9481-4c5d8c8f1a01',
|
||||
'company_code' => 'FEDEX',
|
||||
'company_name' => 'FedEx',
|
||||
'service_level' => [
|
||||
'id' => '123456790',
|
||||
'code' => 'FEDEX_INTERNATIONAL_ECONOMY',
|
||||
'name' => 'International Economy',
|
||||
'description' => 'Economy service (ECO)',
|
||||
],
|
||||
'rate' => 45.75,
|
||||
'currency' => 'GBP',
|
||||
'transit_days' => '5-7',
|
||||
'delivery_guarantee_date' => '2026-01-09',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock response for /shipments endpoint
|
||||
*/
|
||||
public static function shipmentsResponse(): array
|
||||
{
|
||||
return [
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440002',
|
||||
'short_tracking_reference' => 'SHP123456789',
|
||||
'tracking_reference' => 'SHP-123456789-ABC',
|
||||
'customer_reference' => 'ORDER-12345',
|
||||
'company_code' => 'FEDEX',
|
||||
'service_level' => [
|
||||
'code' => 'FEDEX_INTERNATIONAL_ECONOMY',
|
||||
'name' => 'International Economy',
|
||||
],
|
||||
'collection_min_date' => '2026-01-04',
|
||||
'delivery_min_date' => '2026-01-09',
|
||||
'status' => 'created',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock PDF response
|
||||
*/
|
||||
public static function pdfResponse(): string
|
||||
{
|
||||
// Minimal valid PDF
|
||||
return "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /MediaBox [0 0 612 792] /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n5 0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Mock PDF) Tj\nET\nendstream\nendobj\nxref\n0 6\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\n0000000273 00000 n\n0000000352 00000 n\ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n446\n%%EOF";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user