Files
HickLab3D_Dashboard/app/Http/Controllers/ShippingController.php
T
HickmeisterandClaude Sonnet 5.5 2fe4fbd91e
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Cache integration API reads and refresh them on a schedule
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:01:32 +01:00

246 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\EtsyConnection;
use App\Services\Etsy;
use App\Services\IntegrationSettings;
use App\Services\Parcel2Go;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Throwable;
class ShippingController extends Controller
{
public function index(Request $request, Parcel2Go $parcel2Go, Etsy $etsy, IntegrationSettings $settings): View
{
abort_unless($request->user()->role === 'admin', 403);
$pendingOrders = [];
$etsyNotice = null;
try {
$pendingOrders = $this->pendingOrders($etsy);
if ($pendingOrders === [] && (! $etsy->configured() || ! EtsyConnection::query()->exists())) {
$etsyNotice = 'Connect your Etsy shop to see pending orders.';
}
} catch (Throwable $exception) {
report($exception);
$etsyNotice = 'Etsy pending orders are temporarily unavailable.';
}
$account = [];
$balance = [];
$orders = [];
$profileNotice = null;
$integrationError = null;
$historyAvailable = false;
try {
$parcel2Go->verifyCredentials();
try {
$orders = $parcel2Go->fetch('me/orders/detail');
$historyAvailable = true;
} catch (Throwable $exception) {
$profileNotice = $parcel2Go->profileConnected()
? 'Parcel2Go account access needs attention. Reconnect the account in Settings.'
: ($exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true)
? 'Parcel2Go denied shipment history for the saved Client Credentials client. Check that its Client ID and secret match the client used by your previous dashboard.'
: 'Parcel2Go shipment history is temporarily unavailable.');
}
try {
$account = $parcel2Go->fetch('me/detail');
} catch (Throwable $exception) {
report($exception);
}
try {
$balance = $parcel2Go->fetch('me/prepay/balance');
} catch (Throwable $exception) {
report($exception);
}
} catch (Throwable $exception) {
report($exception);
$integrationError = 'Parcel2Go is unavailable. Check API credentials and connectivity.';
}
return view('shipping', [
'account' => $account, 'balance' => $balance, 'orders' => $orders,
'historyAvailable' => $historyAvailable, 'profileNotice' => $profileNotice,
'integrationError' => $integrationError, 'pendingOrders' => $pendingOrders,
'etsyNotice' => $etsyNotice,
'collectionPostcode' => $settings->for('parcel2go')['collection_postcode'] ?? '',
'selectedReceipt' => collect($pendingOrders)->first(fn (array $receipt): bool => (string) ($receipt['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id')),
'quotes' => $request->session()->get('shipping_quotes', []),
'collectionDates' => $request->session()->get('shipping_collection_dates', []),
'selectedService' => $request->session()->get('shipping_selected_service'),
'bookingResult' => $request->session()->get('shipping_booking_result'),
'bookingPending' => $request->session()->get('shipping_booking_pending', false),
]);
}
public function quote(Request $request, Parcel2Go $parcel2Go, Etsy $etsy, IntegrationSettings $settings): RedirectResponse
{
abort_unless($request->user()->role === 'admin', 403);
if ($request->session()->get('shipping_booking_pending')) {
return redirect()->route('shipping')->withErrors(['collection' => 'A booking outcome is uncertain. Check your Parcel2Go account before trying again.']);
}
$details = $request->validate([
'receipt_id' => ['required', 'integer', 'min:1'],
'weight' => ['required', 'numeric', 'gt:0', 'lte:30'],
'length' => ['required', 'numeric', 'gt:0', 'lte:200'],
'width' => ['required', 'numeric', 'gt:0', 'lte:200'],
'height' => ['required', 'numeric', 'gt:0', 'lte:200'],
]);
try {
$receipt = collect($this->pendingOrders($etsy, true))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $details['receipt_id']);
if (! $receipt) {
return redirect()->route('shipping')->withErrors(['quote' => 'This Etsy order is no longer pending. Refresh the order list.']);
}
$collectionPostcode = $settings->for('parcel2go')['collection_postcode'] ?? null;
if (blank($collectionPostcode) || blank($receipt['zip'] ?? null) || ($receipt['country_iso'] ?? 'GB') !== 'GB' || ($receipt['grandtotal']['currency_code'] ?? 'GBP') !== 'GBP') {
return redirect()->route('shipping')->withErrors(['quote' => 'A UK collection postcode and a UK Etsy delivery address priced in GBP are required.']);
}
$total = $receipt['grandtotal'] ?? [];
$value = (float) ($total['amount'] ?? 0) / max(1, (int) ($total['divisor'] ?? 1));
$details = array_merge($details, [
'collection_postcode' => $collectionPostcode,
'delivery_postcode' => $receipt['zip'],
'value' => min(10000, max(0, $value)),
]);
$quotes = $parcel2Go->quote($details)['Quotes'] ?? [];
$request->session()->put('shipping_quote', $details);
$request->session()->put('shipping_receipt_id', $receipt['receipt_id']);
$request->session()->put('shipping_quotes', $quotes);
$request->session()->forget(['shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location']);
return redirect()->route('shipping')->with('status', count($quotes).' services available.');
} catch (Throwable $exception) {
report($exception);
return redirect()->route('shipping')->withErrors(['quote' => 'Could not quote this Etsy order. Check its delivery address and parcel details.']);
}
}
public function collectionDates(Request $request, Parcel2Go $parcel2Go): RedirectResponse
{
abort_unless($request->user()->role === 'admin', 403);
$data = $request->validate([
'service' => ['required', 'string', 'max:200'],
'sender_street' => ['required', 'string', 'max:200'],
'sender_town' => ['required', 'string', 'max:100'],
]);
$details = $request->session()->get('shipping_quote');
$quote = collect($request->session()->get('shipping_quotes', []))->first(fn ($quote) => ($quote['Service']['Slug'] ?? null) === $data['service'] && ($quote['Service']['CollectionType'] ?? null) === 'Collection');
if (! $details || ! $quote) {
return redirect()->route('shipping')->withErrors(['service' => 'Select a collection service from a current quote.']);
}
try {
$dates = $parcel2Go->collectionDates($data['service'], $data['sender_street'], $data['sender_town'], $details['collection_postcode'])['CollectionDates'] ?? [];
$request->session()->put('shipping_collection_dates', $dates);
$request->session()->put('shipping_selected_service', $data['service']);
$request->session()->put('shipping_sender_location', $data);
return redirect()->route('shipping')->with('status', 'Choose a collection date and enter the addresses.');
} catch (Throwable $exception) {
report($exception);
return redirect()->route('shipping')->withErrors(['collection' => 'Parcel2Go could not return collection dates for this service.']);
}
}
public function bookCollection(Request $request, Parcel2Go $parcel2Go, Etsy $etsy): RedirectResponse
{
abort_unless($request->user()->role === 'admin', 403);
if ($request->session()->get('shipping_booking_pending')) {
return redirect()->route('shipping')->withErrors(['collection' => 'A booking outcome is uncertain. Check your Parcel2Go account before trying again.']);
}
$booking = $request->validate([
'collection_date' => ['required', 'date', 'after:now'],
'sender_name' => ['required', 'string', 'max:100'],
'sender_email' => ['required', 'email', 'max:150'],
'sender_phone' => ['required', 'string', 'max:30'],
'sender_property' => ['required', 'string', 'max:100'],
'sender_street' => ['required', 'string', 'max:200'],
'sender_town' => ['required', 'string', 'max:100'],
'recipient_name' => ['required', 'string', 'max:100'],
'recipient_email' => ['required', 'email', 'max:150'],
'recipient_phone' => ['required', 'string', 'max:30'],
'recipient_property' => ['required', 'string', 'max:100'],
'recipient_street' => ['required', 'string', 'max:200'],
'recipient_town' => ['required', 'string', 'max:100'],
'contents' => ['required', 'string', 'max:200'],
]);
$details = $request->session()->get('shipping_quote');
$service = $request->session()->get('shipping_selected_service');
$dates = $request->session()->get('shipping_collection_dates', []);
$senderLocation = $request->session()->get('shipping_sender_location', []);
$selectedQuote = collect($request->session()->get('shipping_quotes', []))
->first(fn ($quote) => ($quote['Service']['Slug'] ?? null) === $service && ($quote['Service']['CollectionType'] ?? null) === 'Collection');
if (! $details || ! $service || ! collect($dates)->contains(fn ($date) => ($date['CollectionDate'] ?? null) === $booking['collection_date'])
|| ! $selectedQuote || ! empty($selectedQuote['RequiresCustoms']) || ! empty($selectedQuote['RequiresCommercialInvoice'])
|| ($senderLocation['sender_street'] ?? null) !== $booking['sender_street'] || ($senderLocation['sender_town'] ?? null) !== $booking['sender_town']) {
return redirect()->route('shipping')->withErrors(['collection' => 'Choose an available collection date for the quoted address.']);
}
try {
$receipt = collect($this->pendingOrders($etsy, true))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id'));
if (! $receipt || ($receipt['zip'] ?? null) !== $details['delivery_postcode']) {
return redirect()->route('shipping')->withErrors(['collection' => 'The Etsy order or delivery postcode changed. Request a fresh quote.']);
}
} catch (Throwable $exception) {
report($exception);
return redirect()->route('shipping')->withErrors(['collection' => 'Could not verify the Etsy order before booking. Try again.']);
}
try {
$request->session()->put('shipping_booking_pending', true);
$result = $parcel2Go->bookCollection($details, $service, $booking);
if (empty($result['OrderId'])) {
throw new \RuntimeException('Parcel2Go did not return an order reference.');
}
$request->session()->forget(['shipping_quote', 'shipping_receipt_id', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']);
return redirect()->route('shipping')->with('shipping_booking_result', $result)->with('status', 'Collection order created. Complete payment with Parcel2Go.');
} catch (Throwable $exception) {
report($exception);
return redirect()->route('shipping')->withErrors(['collection' => 'Booking outcome is uncertain. Check your Parcel2Go account before trying again.']);
}
}
public function resetBooking(Request $request): RedirectResponse
{
abort_unless($request->user()->role === 'admin', 403);
$request->validate(['checked_account' => ['accepted']]);
$request->session()->forget(['shipping_quote', 'shipping_receipt_id', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']);
return redirect()->route('shipping')->with('status', 'Booking workflow reset. Start with a fresh quote.');
}
public function label(Request $request, Parcel2Go $parcel2Go, string $orderLineId): Response
{
abort_unless($request->user()->role === 'admin', 403);
abort_unless(ctype_digit($orderLineId), 404);
$orders = $parcel2Go->fetch('me/orders/detail')['Orders'] ?? [];
$order = collect($orders)->first(fn ($order) => (string) ($order['OrderLineId'] ?? '') === $orderLineId);
abort_unless($order && ! empty($order['Hash']), 404);
$pdf = $parcel2Go->labelPdf($orderLineId, $order['Hash']);
return response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="parcel2go-'.$orderLineId.'.pdf"',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<int, array<string, mixed>> */
private function pendingOrders(Etsy $etsy, bool $fresh = false): array
{
$connection = EtsyConnection::first();
return $connection && $etsy->configured() ? ($etsy->receipts($connection, $fresh)['results'] ?? []) : [];
}
}