feat: Implement Slack, Trello, and Courier (ShipLogic) integration
- Add 14 domain events for order lifecycle (OrderCreated, OrderPacked, ShipmentCreated, ParcelDelivered, etc.)
- Create SlackNotifierService with channels for orders, design, production, shipping, ops-alerts
- Create TrelloService to create cards, move cards between lists, attach files, check items
- Create CourierService to integrate with ShipLogic API for shipment creation and document retrieval
- Create PackingController to explicitly capture packing dimensions and weight
- Create ShippingController with multi-layer guards: packing validation, payment/approval verification, idempotency
- Create TrelloWebhookController to handle incoming Trello webhooks as intent signals
- Create CourierWebhookController to handle Shiplogic status updates
- Create event listeners for Slack notifications and Trello card updates
- Create EventServiceProvider to register all events and listeners
- Add database migration for packing, courier, and Trello data columns
- Create config files for slack, trello, and courier integration
- Update .env with integration secrets placeholders
- Add routes for /orders/{id}/pack, /orders/{id}/ship, /api/webhooks/trello, /api/webhooks/courier
Key architectural decisions:
- Packing is explicit ops action (not automatic from status)
- Shipment creation only after: Trello intent + packing confirmed + payment/approval rules met
- Courier API failures keep order in Ready to Ship state (safe retry)
- Trello and Slack are mirrors of backend state, not decision makers
- All side effects flow through event listeners, maintaining separation of concerns
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
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/api');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shipment with Shiplogic
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function createShipment(string $orderId, float $width, float $length, float $weight): 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 {
|
||||
// Build shipment payload for Shiplogic
|
||||
$payload = [
|
||||
'parcel' => [
|
||||
'weight' => $weight,
|
||||
'height' => 10, // TODO: Update when height is captured separately
|
||||
'width' => $width,
|
||||
'length' => $length,
|
||||
],
|
||||
'destination' => [
|
||||
// TODO: Get from order's shipping address
|
||||
],
|
||||
'reference' => $orderId,
|
||||
];
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$this->apiKey}",
|
||||
])->post("{$this->baseUrl}/shipments", $payload);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$errorMessage = $response->json('error.message', 'Unknown error');
|
||||
throw new \Exception("Courier API error: {$errorMessage}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'shipment_id' => $data['id'] ?? null,
|
||||
'waybill_id' => $data['waybill_number'] ?? null,
|
||||
'tracking_number' => $data['tracking_number'] ?? null,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to create shipment with courier', [
|
||||
'order_id' => $orderId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
protected function isConfigured(): bool
|
||||
{
|
||||
return ! empty($this->apiKey);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user