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
248 lines
6.9 KiB
PHP
248 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use DateTimeInterface;
|
|
|
|
class TrelloService
|
|
{
|
|
protected string $apiKey;
|
|
protected string $apiToken;
|
|
protected string $boardId;
|
|
protected string $baseUrl = 'https://api.trello.com/1';
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiKey = config('trello.api_key');
|
|
$this->apiToken = config('trello.api_token');
|
|
$this->boardId = config('trello.board_id');
|
|
}
|
|
|
|
/* =====================================================
|
|
| Public API
|
|
===================================================== */
|
|
|
|
/**
|
|
* Create a new Trello card and populate base fields
|
|
*/
|
|
public function createCard(
|
|
string $orderId,
|
|
string $orderNumber,
|
|
string $orderType = 'standard',
|
|
?string $startingListId = null
|
|
): ?string {
|
|
if (! $this->isConfigured()) {
|
|
Log::warning('Trello not configured, skipping card creation');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$listId = $startingListId ?? $this->getStartingListId($orderType);
|
|
|
|
$response = Http::post("{$this->baseUrl}/cards", [
|
|
'name' => "Order #{$orderNumber}",
|
|
'desc' => "Order ID: {$orderId}\nType: {$orderType}",
|
|
'idList' => $listId,
|
|
'key' => $this->apiKey,
|
|
'token' => $this->apiToken,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to create Trello card', [
|
|
'response' => $response->body(),
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
$cardId = $response->json('id');
|
|
|
|
if ($cardId) {
|
|
$this->populateInitialFields($cardId, $orderId);
|
|
}
|
|
|
|
return $cardId;
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('Exception creating Trello card', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move card to another list
|
|
*/
|
|
public function moveCard(string $cardId, string $listName): bool
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
return false;
|
|
}
|
|
|
|
$listId = $this->getListIdByName($listName);
|
|
|
|
if (! $listId) {
|
|
Log::warning('Trello list not found', ['list' => $listName]);
|
|
return false;
|
|
}
|
|
|
|
$response = Http::put("{$this->baseUrl}/cards/{$cardId}", [
|
|
'idList' => $listId,
|
|
'key' => $this->apiKey,
|
|
'token' => $this->apiToken,
|
|
]);
|
|
|
|
return $response->successful();
|
|
}
|
|
|
|
/**
|
|
* Attach a URL/file to a card
|
|
*/
|
|
public function attachFile(string $cardId, string $name, string $url): bool
|
|
{
|
|
$response = Http::post("{$this->baseUrl}/cards/{$cardId}/attachments", [
|
|
'name' => $name,
|
|
'url' => $url,
|
|
'key' => $this->apiKey,
|
|
'token' => $this->apiToken,
|
|
]);
|
|
|
|
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
|
|
*/
|
|
public function checkItem(string $cardId, string $checklistName, string $itemName): bool
|
|
{
|
|
$card = Http::get("{$this->baseUrl}/cards/{$cardId}", [
|
|
'checklists' => 'open',
|
|
'key' => $this->apiKey,
|
|
'token' => $this->apiToken,
|
|
])->json();
|
|
|
|
$checklist = collect($card['checklists'] ?? [])
|
|
->firstWhere('name', $checklistName);
|
|
|
|
if (! $checklist) {
|
|
return false;
|
|
}
|
|
|
|
$item = collect($checklist['checkItems'] ?? [])
|
|
->firstWhere('name', $itemName);
|
|
|
|
if (! $item) {
|
|
return false;
|
|
}
|
|
|
|
$response = Http::put(
|
|
"{$this->baseUrl}/checklists/{$checklist['id']}/checkItems/{$item['id']}",
|
|
[
|
|
'state' => 'complete',
|
|
'key' => $this->apiKey,
|
|
'token' => $this->apiToken,
|
|
]
|
|
);
|
|
|
|
return $response->successful();
|
|
}
|
|
|
|
/* =====================================================
|
|
| Custom Fields
|
|
===================================================== */
|
|
|
|
public function setTextField(string $cardId, string $fieldKey, string $value): bool
|
|
{
|
|
return $this->setCustomField($cardId, $fieldKey, ['text' => $value]);
|
|
}
|
|
|
|
public function setNumberField(string $cardId, string $fieldKey, float|int $value): bool
|
|
{
|
|
return $this->setCustomField($cardId, $fieldKey, ['number' => (string) $value]);
|
|
}
|
|
|
|
public function setDateField(string $cardId, string $fieldKey, DateTimeInterface $date): bool
|
|
{
|
|
return $this->setCustomField($cardId, $fieldKey, [
|
|
'date' => $date->format(DATE_ATOM),
|
|
]);
|
|
}
|
|
|
|
protected function setCustomField(string $cardId, string $fieldKey, array $value): bool
|
|
{
|
|
$fieldId = config("trello.custom_fields.{$fieldKey}");
|
|
|
|
if (! $fieldId) {
|
|
Log::warning('Trello custom field not configured', ['field' => $fieldKey]);
|
|
return false;
|
|
}
|
|
|
|
$response = Http::put(
|
|
"{$this->baseUrl}/cards/{$cardId}/customField/{$fieldId}/item",
|
|
[
|
|
'value' => $value,
|
|
'key' => $this->apiKey,
|
|
'token' => $this->apiToken,
|
|
]
|
|
);
|
|
|
|
return $response->successful();
|
|
}
|
|
|
|
/* =====================================================
|
|
| Internals
|
|
===================================================== */
|
|
|
|
protected function populateInitialFields(string $cardId, string $orderId): void
|
|
{
|
|
$this->setTextField($cardId, 'order_id', $orderId);
|
|
$this->setDateField($cardId, 'date_created', now());
|
|
}
|
|
|
|
protected function getStartingListId(string $orderType): string
|
|
{
|
|
return $orderType === 'custom'
|
|
? config('trello.lists.custom.new_custom_order')
|
|
: config('trello.lists.standard.new_order');
|
|
}
|
|
|
|
protected function getListIdByName(string $listName): ?string
|
|
{
|
|
$lists = array_merge(
|
|
config('trello.lists.standard', []),
|
|
config('trello.lists.custom', [])
|
|
);
|
|
|
|
foreach ($lists as $key => $id) {
|
|
if (strtolower(str_replace('_', ' ', $key)) === strtolower($listName)) {
|
|
return $id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function isConfigured(): bool
|
|
{
|
|
return ! empty($this->apiKey)
|
|
&& ! empty($this->apiToken)
|
|
&& ! empty($this->boardId);
|
|
}
|
|
}
|