Files
twotalesanimation 2a10f9af38 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
2026-01-03 16:13:20 +02:00

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);
}
}