Files
Additional/app/Http/Controllers/OpsController.php
T
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

357 lines
11 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class OpsController extends Controller
{
/**
* Display order details via QR token (state-machine driven UI)
*
* GET /ops/orders/{token}
*/
public function showOrder(Request $request, $token)
{
// Find order by QR token
$order = Order::where('qr_token', $token)->first();
if (! $order) {
Log::warning('QR token not found', ['token' => substr($token, 0, 8) . '...']);
return response()->view('ops.order-not-found', [], 404);
}
// Check user authorization - must be logged in ops user
// TODO: Add middleware to enforce this
if (! auth()->check() || ! auth()->user()->can('access-ops')) {
Log::warning('Unauthorized QR access attempt', [
'order_uuid' => $order->uuid,
'user_id' => auth()->id(),
]);
return response()->view('ops.unauthorized', [], 403);
}
Log::info('QR order accessed', [
'order_uuid' => $order->uuid,
'status' => $order->status,
'user_id' => auth()->id(),
]);
// Determine available actions based on order status
$availableActions = $this->getAvailableActions($order);
return view('ops.order-detail', [
'order' => $order,
'availableActions' => $availableActions,
'qrUrl' => route('ops.order.show', ['token' => $order->qr_token]),
]);
}
/**
* Confirm packing via QR form
*
* POST /ops/orders/{id}/pack
*/
public function confirmPacking(Request $request, Order $order)
{
// Validate input
$validated = $request->validate([
'weight' => ['required', 'numeric', 'min:0.1'],
'width' => ['required', 'numeric', 'min:1'],
'length' => ['required', 'numeric', 'min:1'],
'height' => ['required', 'numeric', 'min:1'],
]);
// Ensure order is in packing state
if ($order->status !== 'packing' && $order->status !== 'inspection') {
return response()->json([
'error' => 'Order cannot be packed in current state',
'current_status' => $order->status,
], 409);
}
// Save packing dimensions
$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(),
'status' => 'packing',
]);
Log::info('Order packed via QR', [
'order_uuid' => $order->uuid,
'packed_by' => auth()->user()->name,
'dimensions' => "{$validated['width']}x{$validated['length']}cm, {$validated['weight']}kg",
]);
// Emit event to trigger Slack notification and Trello update
\App\Events\OrderPacked::dispatch(
$order,
(float) $validated['width'],
(float) $validated['length'],
(float) $validated['height'],
(float) $validated['weight'],
auth()->id()
);
return response()->json([
'success' => true,
'message' => 'Order packed successfully',
'order' => [
'uuid' => $order->uuid,
'number' => $order->order_number,
'status' => $order->status,
],
]);
}
/**
* Mark inspection passed via QR
*
* POST /ops/orders/{id}/inspection-passed
*/
public function markInspectionPassed(Request $request, Order $order)
{
if ($order->status !== 'inspection') {
return response()->json([
'error' => 'Order is not in inspection state',
'current_status' => $order->status,
], 409);
}
// Update status
$order->update(['status' => 'packing']);
Log::info('Inspection passed via QR', [
'order_uuid' => $order->uuid,
'marked_by' => auth()->user()->name,
]);
// Emit event to trigger Slack/Trello updates
\App\Events\InspectionPassed::dispatch($order);
return response()->json([
'success' => true,
'message' => 'Inspection passed',
'order' => ['uuid' => $order->uuid, 'status' => $order->status],
]);
}
/**
* Flag inspection issue via QR
*
* POST /ops/orders/{id}/inspection-failed
*/
public function flagInspectionIssue(Request $request, Order $order)
{
$validated = $request->validate([
'issue_description' => ['required', 'string', 'max:500'],
]);
if ($order->status !== 'inspection') {
return response()->json([
'error' => 'Order is not in inspection state',
'current_status' => $order->status,
], 409);
}
// Update status and store issue
$order->update([
'status' => 'review_required',
'notes' => $validated['issue_description'],
]);
Log::warning('Inspection issue flagged via QR', [
'order_uuid' => $order->uuid,
'flagged_by' => auth()->user()->name,
'issue' => $validated['issue_description'],
]);
// Emit event for listeners to alert ops
\App\Events\InspectionFailed::dispatch($order, $validated['issue_description']);
return response()->json([
'success' => true,
'message' => 'Issue flagged - order moved to review',
'order' => ['uuid' => $order->uuid, 'status' => $order->status],
]);
}
/**
* Determine which actions are available based on order status
*/
private function getAvailableActions(Order $order): array
{
return match ($order->status) {
'inspection' => [
'markInspectionPassed' => true,
'flagInspectionIssue' => true,
],
'packing' => [
'confirmPacking' => true,
],
'ready_to_ship', 'awaiting_collection', 'in_transit' => [
'readOnly' => true,
],
default => []
};
}
/**
* Download QR sticker PDF for printing
*
* GET /ops/orders/{id}/sticker/download
*/
public function downloadSticker(Order $order)
{
// Authorize
if (! auth()->user()->can('access-ops')) {
abort(403, 'Unauthorized to access ops interface');
}
$stickerPath = "qr-stickers/{$order->uuid}.pdf";
if (! \Illuminate\Support\Facades\Storage::disk('public')->exists($stickerPath)) {
Log::warning('QR sticker PDF not found', [
'order_uuid' => $order->uuid,
'path' => $stickerPath,
]);
abort(404, 'QR sticker not found');
}
Log::info('QR sticker downloaded', [
'order_uuid' => $order->uuid,
'user_id' => auth()->id(),
'user_name' => auth()->user()->name,
]);
return \Illuminate\Support\Facades\Storage::disk('public')->download(
$stickerPath,
"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);
}
}