Files
hub/app/Http/Controllers/OrderController.php
kelly 2c1f7d093f
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat: enterprise accounting UI harmonization for orders/invoices
- Add 3-column header layout (Buyer | Seller | Document Info) to:
  - Order create page (new)
  - Invoice create page (updated)
  - Order show page (updated with units/cases, item comments)
  - Invoice show page (updated with seller info, units/cases)
  - Quote show page (updated with seller info, units/cases)

- Add seller-initiated order creation:
  - New /orders/create route and view
  - Orders track created_by (buyer/seller)
  - New Order button on orders index

- Add ping pong order flow feature:
  - ping_pong_enabled on businesses table
  - is_ping_pong toggle per order
  - Admin toggle in Business > Suite Settings

- Add item comments per line item:
  - item_comment field on order_items, crm_invoice_items, crm_quote_items
  - Inline edit UI on order show page

- UI improvements:
  - Units/cases display (X UNITS / Y CASES)
  - Live totals in document headers
  - Consistent styling across all document types
2025-12-17 19:01:13 -07:00

1379 lines
55 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\DeliveryWindow;
use App\Models\Manifest;
use App\Models\Order;
use App\Services\DeliveryWindowService;
use App\Services\ManifestService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class OrderController extends Controller
{
public function __construct(
private DeliveryWindowService $deliveryWindowService
) {}
/**
* Display list of orders for sellers.
* Shows all orders including new, in-progress, completed, rejected, and cancelled.
*/
public function index(\App\Models\Business $business, Request $request): View
{
$query = Order::with(['business', 'user', 'items.product', 'invoice', 'manifest'])
->whereHas('items.product', function ($query) use ($business) {
$query->forBusiness($business);
})
->whereIn('status', [
'new',
'accepted',
'in_progress',
'ready_for_manifest',
'ready_for_delivery',
'approved_for_delivery',
'out_for_delivery',
'delivered',
'completed',
'rejected',
'cancelled',
])
->latest();
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter by workorder completion
if ($request->filled('workorder_filter')) {
switch ($request->workorder_filter) {
case 'not_started':
$query->where('workorder_status', 0);
break;
case 'in_progress':
$query->where('workorder_status', '>', 0)
->where('workorder_status', '<', 100);
break;
case 'completed':
$query->where('workorder_status', 100);
break;
}
}
// Search by order number or business name
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('order_number', 'ILIKE', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%");
});
});
}
$orders = $query->paginate(20)->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $orders->map(fn ($o) => [
'order_number' => $o->order_number,
'name' => $o->order_number.' - '.$o->business->name,
'customer' => $o->business->name,
'status' => $o->status,
])->values()->toArray(),
]);
}
return view('seller.orders.index', compact('orders', 'business'));
}
/**
* Show the form for creating a new order (seller-initiated).
*/
public function create(\App\Models\Business $business): View
{
// Get all buyer businesses for the customer dropdown
$buyers = \App\Models\Business::where('is_active', true)
->whereIn('business_type', ['buyer', 'both'])
->with(['locations' => function ($query) {
$query->where('is_active', true)->orderBy('is_primary', 'desc')->orderBy('name');
}])
->orderBy('name')
->get();
// Get recently ordered products (last 30 days, top 10 most common)
$recentProducts = \App\Models\Product::forBusiness($business)
->whereHas('orderItems', function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
})
->with(['brand', 'images'])
->withCount(['orderItems' => function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
}])
->orderByDesc('order_items_count')
->take(10)
->get()
->map(function ($product) use ($business) {
// Calculate inventory from InventoryItem model
$totalOnHand = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_on_hand');
$totalAllocated = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_allocated');
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'brand_name' => $product->brand?->name,
'wholesale_price' => $product->wholesale_price,
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
'type' => $product->type,
];
});
return view('seller.orders.create', compact('business', 'buyers', 'recentProducts'));
}
/**
* Store a newly created order (seller-initiated).
*/
public function store(\App\Models\Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'buyer_business_id' => 'required|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
'contact_id' => 'nullable|exists:contacts,id',
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_amount' => 'nullable|numeric|min:0',
'items.*.discount_type' => 'nullable|in:fixed,percent',
'items.*.notes' => 'nullable|string|max:500',
'items.*.batch_id' => 'nullable|exists:batches,id',
]);
try {
// Create the order
$order = Order::create([
'business_id' => $validated['buyer_business_id'],
'location_id' => $validated['location_id'] ?? null,
'contact_id' => $validated['contact_id'] ?? null,
'user_id' => auth()->id(),
'status' => 'new',
'created_by' => 'seller',
'payment_terms' => $validated['payment_terms'],
'notes' => $validated['notes'] ?? null,
]);
// Add line items
$subtotal = 0;
foreach ($validated['items'] as $item) {
$product = \App\Models\Product::findOrFail($item['product_id']);
$lineSubtotal = $item['quantity'] * $item['unit_price'];
$discountAmount = 0;
if (! empty($item['discount_amount']) && $item['discount_amount'] > 0) {
if (($item['discount_type'] ?? 'percent') === 'percent') {
$discountAmount = $lineSubtotal * ($item['discount_amount'] / 100);
} else {
$discountAmount = $item['discount_amount'];
}
}
$lineTotal = $lineSubtotal - $discountAmount;
$subtotal += $lineTotal;
$order->items()->create([
'product_id' => $item['product_id'],
'batch_id' => $item['batch_id'] ?? null,
'quantity' => $item['quantity'],
'price' => $item['unit_price'],
'discount_amount' => $discountAmount,
'total' => $lineTotal,
'notes' => $item['notes'] ?? null,
]);
}
// Update order totals
$order->update([
'subtotal' => $subtotal,
'total' => $subtotal, // Tax can be added later
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order created successfully!');
} catch (\Exception $e) {
return back()
->withInput()
->with('error', 'Failed to create order: '.$e->getMessage());
}
}
/**
* Display order detail with workorder/picking ticket functionality.
*/
public function show(\App\Models\Business $business, Order $order): View
{
$order->load([
'business',
'user',
'location',
'items.product.brand',
'audits' => function ($query) {
$query->with(['user', 'auditable'])->orderBy('created_at', 'desc');
},
'pendingCancellationRequest.requestedBy',
'cancellationRequests',
'cancellationRequests.audits' => function ($query) {
$query->with(['user', 'auditable'])->orderBy('created_at', 'desc');
},
'cancellationRequests.requestedBy',
'cancellationRequests.reviewedBy',
]);
return view('seller.orders.show', compact('order', 'business'));
}
/**
* Approve a cancellation request (seller action).
*/
public function approveCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest)
{
if (! $cancellationRequest->isPending()) {
return back()->with('error', 'This cancellation request has already been reviewed.');
}
if ($cancellationRequest->order_id !== $order->id) {
abort(404);
}
$cancellationRequest->approve(auth()->user());
return back()->with('success', "Cancellation request approved. Order {$order->order_number} has been cancelled.");
}
/**
* Deny a cancellation request (seller action).
*/
public function denyCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest, Request $request)
{
if (! $cancellationRequest->isPending()) {
return back()->with('error', 'This cancellation request has already been reviewed.');
}
if ($cancellationRequest->order_id !== $order->id) {
abort(404);
}
$validated = $request->validate([
'notes' => 'required|string|max:1000',
]);
$cancellationRequest->deny(auth()->user(), $validated['notes']);
return back()->with('success', 'Cancellation request denied.');
}
/**
* Accept a new order (seller accepting buyer's order).
*/
public function accept(\App\Models\Business $business, Order $order)
{
if ($order->status !== 'new' || $order->created_by !== 'buyer') {
return back()->with('error', 'This order cannot be accepted.');
}
// Use the model's accept() method which generates picking ticket number and sends notifications
$order->accept();
return back()->with('success', 'Order accepted successfully.');
}
/**
* Reject a new buyer order (seller declining to fulfill).
*/
public function reject(\App\Models\Business $business, Order $order, Request $request)
{
if (! $order->canBeRejected()) {
return back()->with('error', 'This order cannot be rejected at this stage.');
}
$validated = $request->validate([
'reason' => 'required|string|max:1000',
]);
$order->reject($validated['reason']);
return back()->with('success', "Order {$order->order_number} has been rejected.");
}
/**
* Cancel an order (seller-initiated).
*/
public function cancel(\App\Models\Business $business, Order $order, Request $request)
{
if (! $order->canBeCancelledBySeller()) {
return back()->with('error', 'This order cannot be cancelled at this stage.');
}
$validated = $request->validate([
'reason' => 'required|string|max:1000',
]);
$order->cancelBySeller($validated['reason']);
return back()->with('success', "Order {$order->order_number} has been cancelled.");
}
/**
* Update item comment for an order line item.
*/
public function updateItemComment(\App\Models\Business $business, Order $order, \App\Models\OrderItem $orderItem, Request $request): RedirectResponse
{
// Verify the item belongs to this order
if ($orderItem->order_id !== $order->id) {
abort(404);
}
$validated = $request->validate([
'item_comment' => 'nullable|string|max:2000',
]);
$orderItem->update([
'item_comment' => $validated['item_comment'],
]);
return back()->with('success', 'Item comment updated.');
}
/**
* Toggle ping pong mode for an order.
*/
public function togglePingPong(\App\Models\Business $business, Order $order): RedirectResponse
{
$order->update([
'is_ping_pong' => ! $order->is_ping_pong,
]);
$status = $order->is_ping_pong ? 'enabled' : 'disabled';
return back()->with('success', "Ping Pong flow {$status} for this order.");
}
/**
* Approve order for delivery (after buyer selects delivery method).
*/
public function approveForDelivery(\App\Models\Business $business, Order $order)
{
try {
$order->approveForDelivery();
return back()->with('success', 'Order approved for delivery. You can now schedule delivery/pickup.');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Show picking ticket interface for warehouse/lab staff.
* Mobile-friendly interface for updating picked quantities.
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K
*/
public function pick(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket): View|RedirectResponse
{
// Handle both old (Order) and new (PickingTicket) systems
if ($pickingTicket instanceof \App\Models\PickingTicket) {
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Load relationships for the ticket
$ticket->load(['items.orderItem.product.brand', 'department']);
return view('seller.orders.pick', compact('order', 'ticket', 'business'));
}
// Old system: Order model
$order = $pickingTicket;
// Only allow picking for accepted or in_progress orders
if (! in_array($order->status, ['accepted', 'in_progress'])) {
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'This order is not available for picking.');
}
$order->load(['business', 'user', 'location', 'items.product.brand']);
return view('seller.orders.pick', compact('order', 'business'));
}
/**
* Start picking a ticket - marks ticket as in_progress and logs who started it.
*/
public function startPick(\App\Models\Business $business, \App\Models\PickingTicket $pickingTicket): RedirectResponse
{
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Only allow starting if ticket is pending
if ($ticket->status !== 'pending') {
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
->with('error', 'This picking ticket has already been started.');
}
// Start the ticket and track who started it
$ticket->update([
'status' => 'in_progress',
'started_at' => now(),
'picker_id' => auth()->id(), // Track who started picking
]);
// Update order status to in_progress if it's still accepted
if ($order->status === 'accepted') {
$order->startPicking();
}
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
->with('success', 'Picking started! You can now begin picking items.');
}
/**
* Mark workorder as complete and auto-generate invoice.
* Allows partial fulfillment - invoice will reflect actual picked quantities.
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/complete
*/
public function complete(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
{
// Handle new PickingTicket system
if ($pickingTicket instanceof \App\Models\PickingTicket) {
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Mark this ticket as complete
$ticket->complete();
// PickingTicket->complete() handles:
// - Setting ticket status to 'completed'
// - Checking if all tickets are complete
// - Advancing order to ready_for_delivery if all tickets done
// The order status flow is now: accepted -> in_progress -> ready_for_delivery
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Picking ticket completed successfully!');
}
// Handle old single picking ticket system (Order model)
$order = $pickingTicket;
// Calculate final workorder status based on picked quantities
$order->updatePickingStatus();
$order->refresh();
// NOTE: Do NOT auto-advance to ready_for_delivery
// Seller must manually click "Mark Order Ready for Buyer Review" button
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Picking ticket completed! You can now mark the order ready for buyer review.');
}
/**
* Re-open a completed picking ticket to allow editing.
*/
public function reopen(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
{
// Handle new PickingTicket system
if ($pickingTicket instanceof \App\Models\PickingTicket) {
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Only allow re-opening if seller hasn't marked order ready for buyer review yet
// Once seller clicks "Mark Order Ready for Buyer Review", picking is locked
if (! in_array($order->status, ['accepted', 'in_progress'])) {
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
->with('error', 'Cannot re-open ticket - seller has already marked this order ready for buyer review.');
}
// Re-open the ticket
$ticket->update([
'status' => 'in_progress',
'completed_at' => null,
]);
// If work order was marked as complete, recalculate status
if ($ticket->fulfillmentWorkOrder && $ticket->fulfillmentWorkOrder->status === 'completed') {
$workOrderService = app(\App\Services\FulfillmentWorkOrderService::class);
$workOrderService->recalculateWorkOrderStatus($ticket->fulfillmentWorkOrder);
}
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
->with('success', 'Picking ticket re-opened successfully. You can now make changes.');
}
// Handle old system
$order = $pickingTicket;
return redirect()->route('seller.business.pick', [$business->slug, $order->picking_ticket_number])
->with('error', 'Re-opening tickets is only supported for the new picking ticket system.');
}
/**
* Display picking ticket as PDF in browser.
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/pdf
*/
public function downloadPickingTicketPdf(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
{
// Handle both old (Order) and new (PickingTicket) systems
if ($pickingTicket instanceof \App\Models\PickingTicket) {
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Load relationships for the ticket
$ticket->load(['items.orderItem.product.brand', 'department']);
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'ticket', 'business'));
return $pdf->stream('picking-ticket-'.$ticket->ticket_number.'.pdf');
}
// Old system: Order model
$order = $pickingTicket;
$order->load(['business', 'user', 'location', 'items.product.brand']);
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'business'));
return $pdf->stream('picking-ticket-'.$order->picking_ticket_number.'.pdf');
}
/**
* Show manifest creation form.
*/
public function createManifest(\App\Models\Business $business, Order $order): View|RedirectResponse
{
// Check if manifest already exists first - redirect if it does
if ($order->manifest) {
return redirect()->route('seller.business.orders.manifest.show', [$business->slug, $order]);
}
// Only allow manifest creation for orders in ready_for_manifest status
if ($order->status !== 'ready_for_manifest') {
abort(403, 'Cannot create manifest for this order at this stage.');
}
$order->load(['business', 'location']);
// Load active drivers and vehicles for this business
$drivers = \App\Models\Driver::forBusiness($business)
->where('is_active', true)
->orderBy('first_name')
->get();
$vehicles = \App\Models\Vehicle::forBusiness($business)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.orders.manifest.create', compact('order', 'drivers', 'vehicles', 'business'));
}
/**
* Generate manifest from form submission.
*/
public function storeManifest(\App\Models\Business $business, Order $order, Request $request, ManifestService $manifestService)
{
// Only allow manifest creation for orders in ready_for_manifest status
if ($order->status !== 'ready_for_manifest') {
return back()->with('error', 'Cannot create manifest for this order at this stage.');
}
// Check if manifest already exists
if ($order->manifest) {
return redirect()->route('seller.business.orders.manifest.show', [$business->slug, $order])
->with('info', 'Manifest already exists for this order.');
}
$validated = $request->validate([
// Driver selection or manual entry
'driver_id' => 'nullable|exists:drivers,id',
'driver_first_name' => 'nullable|string|max:255',
'driver_last_name' => 'nullable|string|max:255',
'driver_license' => 'nullable|string|max:255',
'driver_phone' => 'nullable|string|max:15',
'driver_email' => 'nullable|email|max:255',
'transporter_id' => 'nullable|string|max:255',
'save_driver' => 'nullable|boolean',
// Vehicle selection or manual entry
'vehicle_id' => 'nullable|exists:vehicles,id',
'vehicle_name' => 'nullable|string|max:255',
'vehicle_make' => 'nullable|string|max:255',
'vehicle_model' => 'nullable|string|max:255',
'vehicle_year' => 'nullable|integer|min:1900|max:'.(date('Y') + 1),
'vehicle_plate' => 'nullable|string|max:255',
'vehicle_color' => 'nullable|string|max:255',
'vehicle_vin' => 'nullable|string|max:17',
'save_vehicle' => 'nullable|boolean',
// Schedule and notes
'scheduled_delivery_date' => 'nullable|date|after_or_equal:today',
'delivery_time_window' => 'nullable|string|max:255',
'delivery_notes' => 'nullable|string|max:1000',
'scheduled_departure' => 'nullable|date',
'estimated_arrival' => 'nullable|date',
'special_instructions' => 'nullable|string|max:1000',
'notes' => 'nullable|string|max:1000',
]);
// Handle driver selection or creation
if ($request->filled('driver_id')) {
// Use existing driver
$driver = \App\Models\Driver::find($validated['driver_id']);
$validated['driver_id'] = $driver->id;
$validated['driver_first_name'] = $driver->first_name;
$validated['driver_last_name'] = $driver->last_name;
$validated['driver_license'] = $driver->license_number;
$validated['driver_phone'] = $driver->phone;
$validated['driver_email'] = $driver->email;
$validated['transporter_id'] = $driver->transporter_id;
} elseif ($request->filled('save_driver') && $request->filled('driver_first_name')) {
// Create new driver and link to manifest
$driver = \App\Models\Driver::create([
'business_id' => $business->id,
'first_name' => $validated['driver_first_name'],
'last_name' => $validated['driver_last_name'],
'license_number' => $validated['driver_license'],
'phone' => $validated['driver_phone'],
'email' => $validated['driver_email'],
'transporter_id' => $validated['transporter_id'],
'is_active' => true,
'created_by' => Auth::id(),
]);
$validated['driver_id'] = $driver->id;
} else {
// Manual entry only, no driver link
$validated['driver_id'] = null;
}
// Handle vehicle selection or creation
if ($request->filled('vehicle_id')) {
// Use existing vehicle
$vehicle = \App\Models\Vehicle::find($validated['vehicle_id']);
$validated['vehicle_id'] = $vehicle->id;
$validated['vehicle_name'] = $vehicle->name;
$validated['vehicle_make'] = $vehicle->make;
$validated['vehicle_model'] = $vehicle->model;
$validated['vehicle_year'] = $vehicle->year;
$validated['vehicle_plate'] = $vehicle->plate;
$validated['vehicle_color'] = $vehicle->color;
$validated['vehicle_vin'] = $vehicle->vin;
} elseif ($request->filled('save_vehicle') && $request->filled('vehicle_name')) {
// Create new vehicle and link to manifest
$vehicle = \App\Models\Vehicle::create([
'business_id' => $business->id,
'name' => $validated['vehicle_name'],
'make' => $validated['vehicle_make'],
'model' => $validated['vehicle_model'],
'year' => $validated['vehicle_year'],
'plate' => $validated['vehicle_plate'],
'color' => $validated['vehicle_color'],
'vin' => $validated['vehicle_vin'],
'is_active' => true,
'created_by' => Auth::id(),
]);
$validated['vehicle_id'] = $vehicle->id;
} else {
// Manual entry only, no vehicle link
$validated['vehicle_id'] = null;
}
// Generate manifest
$manifest = $manifestService->generateFromOrder($order, $validated);
// Handle delivery scheduling if date is provided
if ($request->filled('scheduled_delivery_date')) {
$deliveryDate = \Carbon\Carbon::parse($validated['scheduled_delivery_date']);
$driverFirstName = $validated['driver_first_name'] ?? null;
$driverLastInitial = $validated['driver_last_name'] ? strtoupper(substr($validated['driver_last_name'], 0, 1)) : null;
$manifest->scheduleDelivery(
$deliveryDate,
$validated['delivery_time_window'] ?? null,
$driverFirstName,
$driverLastInitial,
$validated['delivery_notes'] ?? null,
Auth::id()
);
}
$successMessage = "Manifest {$manifest->manifest_number} has been generated successfully.";
if ($request->filled('scheduled_delivery_date') && $manifest->delivery_token) {
$successMessage .= ' Delivery has been scheduled and driver access link generated.';
}
return redirect()->route('seller.business.orders.manifest.show', [$business->slug, $order])
->with('success', $successMessage);
}
/**
* Display manifest details.
*/
public function showManifest(\App\Models\Business $business, Order $order): View|RedirectResponse
{
$manifest = $order->manifest;
if (! $manifest) {
return redirect()->route('seller.business.orders.manifest.create', [$business->slug, $order]);
}
$manifest->load([
'order.items.product.brand',
'order.items.product.labs',
'sellerCompany',
'buyerCompany',
'deliveryLocation',
]);
// Get active drivers and vehicles for the edit modal
$drivers = \App\Models\Driver::forBusiness($business)
->where('is_active', true)
->orderBy('first_name')
->get();
$vehicles = \App\Models\Vehicle::forBusiness($business)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.orders.manifest.show', compact('manifest', 'order', 'drivers', 'vehicles', 'business'));
}
/**
* Download manifest PDF.
*/
public function downloadManifestPdf(\App\Models\Business $business, Order $order, ManifestService $manifestService): Response
{
$manifest = $order->manifest;
if (! $manifest) {
abort(404, 'Manifest not found');
}
// Regenerate PDF if it doesn't exist
if (! $manifest->pdf_path || ! Storage::disk('local')->exists($manifest->pdf_path)) {
$manifestService->regeneratePdf($manifest);
$manifest->refresh();
}
$pdf = Storage::disk('local')->get($manifest->pdf_path);
return response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$manifest->manifest_number.'.pdf"',
]);
}
/**
* Update manifest information (only while pending).
*/
public function updateManifest(\App\Models\Business $business, Order $order, Request $request, ManifestService $manifestService)
{
$manifest = $order->manifest;
if (! $manifest) {
return back()->with('error', 'Manifest not found.');
}
if (! in_array($manifest->status, ['pending', 'scheduled'])) {
return back()->with('error', 'Cannot update manifest that is in transit, delivered, or cancelled.');
}
$validated = $request->validate([
// Driver selection or manual entry
'driver_id' => 'nullable|exists:drivers,id',
'driver_first_name' => 'nullable|string|max:255',
'driver_last_name' => 'nullable|string|max:255',
'driver_license' => 'nullable|string|max:255',
'driver_phone' => 'nullable|string|max:15',
'driver_email' => 'nullable|email|max:255',
'transporter_id' => 'nullable|string|max:255',
'save_driver' => 'nullable|boolean',
// Vehicle selection or manual entry
'vehicle_id' => 'nullable|exists:vehicles,id',
'vehicle_name' => 'nullable|string|max:255',
'vehicle_make' => 'nullable|string|max:255',
'vehicle_model' => 'nullable|string|max:255',
'vehicle_year' => 'nullable|integer|min:1900|max:'.(date('Y') + 1),
'vehicle_plate' => 'nullable|string|max:255',
'vehicle_color' => 'nullable|string|max:255',
'vehicle_vin' => 'nullable|string|max:17',
'save_vehicle' => 'nullable|boolean',
// Schedule and notes
'scheduled_delivery_date' => 'nullable|date|after_or_equal:today',
'delivery_time_window' => 'nullable|string|max:255',
'delivery_notes' => 'nullable|string|max:1000',
'scheduled_departure' => 'nullable|date',
'estimated_arrival' => 'nullable|date',
'special_instructions' => 'nullable|string|max:1000',
'notes' => 'nullable|string|max:1000',
]);
// Handle driver selection or creation
if ($request->filled('driver_id')) {
// Use existing driver
$driver = \App\Models\Driver::find($validated['driver_id']);
$validated['driver_id'] = $driver->id;
$validated['driver_first_name'] = $driver->first_name;
$validated['driver_last_name'] = $driver->last_name;
$validated['driver_license'] = $driver->license_number;
$validated['driver_phone'] = $driver->phone;
$validated['driver_email'] = $driver->email;
$validated['transporter_id'] = $driver->transporter_id;
} elseif ($request->filled('save_driver') && $request->filled('driver_first_name')) {
// Create new driver and link to manifest
$driver = \App\Models\Driver::create([
'business_id' => $business->id,
'first_name' => $validated['driver_first_name'],
'last_name' => $validated['driver_last_name'],
'license_number' => $validated['driver_license'],
'phone' => $validated['driver_phone'],
'email' => $validated['driver_email'],
'transporter_id' => $validated['transporter_id'],
'is_active' => true,
'created_by' => Auth::id(),
]);
$validated['driver_id'] = $driver->id;
} else {
// Manual entry only, no driver link
$validated['driver_id'] = null;
}
// Handle vehicle selection or creation
if ($request->filled('vehicle_id')) {
// Use existing vehicle
$vehicle = \App\Models\Vehicle::find($validated['vehicle_id']);
$validated['vehicle_id'] = $vehicle->id;
$validated['vehicle_name'] = $vehicle->name;
$validated['vehicle_make'] = $vehicle->make;
$validated['vehicle_model'] = $vehicle->model;
$validated['vehicle_year'] = $vehicle->year;
$validated['vehicle_plate'] = $vehicle->plate;
$validated['vehicle_color'] = $vehicle->color;
$validated['vehicle_vin'] = $vehicle->vin;
} elseif ($request->filled('save_vehicle') && $request->filled('vehicle_name')) {
// Create new vehicle and link to manifest
$vehicle = \App\Models\Vehicle::create([
'business_id' => $business->id,
'name' => $validated['vehicle_name'],
'make' => $validated['vehicle_make'],
'model' => $validated['vehicle_model'],
'year' => $validated['vehicle_year'],
'plate' => $validated['vehicle_plate'],
'color' => $validated['vehicle_color'],
'vin' => $validated['vehicle_vin'],
'is_active' => true,
'created_by' => Auth::id(),
]);
$validated['vehicle_id'] = $vehicle->id;
} else {
// Manual entry only, no vehicle link
$validated['vehicle_id'] = null;
}
// Handle delivery scheduling
if ($request->filled('scheduled_delivery_date')) {
// If a delivery date is provided, schedule the delivery
$deliveryDate = \Carbon\Carbon::parse($validated['scheduled_delivery_date']);
// Get driver info for verification (if driver is selected)
$driverFirstName = $validated['driver_first_name'] ?? null;
$driverLastInitial = $validated['driver_last_name'] ? strtoupper(substr($validated['driver_last_name'], 0, 1)) : null;
$manifest->scheduleDelivery(
$deliveryDate,
$validated['delivery_time_window'] ?? null,
$driverFirstName,
$driverLastInitial,
$validated['delivery_notes'] ?? null,
Auth::id()
);
}
$manifestService->updateManifest($manifest, $validated);
$successMessage = 'Manifest updated successfully.';
if ($request->filled('scheduled_delivery_date') && $manifest->delivery_token) {
$successMessage .= ' Delivery has been scheduled and driver access link generated.';
}
return back()->with('success', $successMessage);
}
/**
* Schedule delivery for a manifest.
*/
public function scheduleDelivery(\App\Models\Business $business, Order $order, Request $request)
{
$manifest = $order->manifest;
if (! $manifest) {
return back()->with('error', 'Manifest not found.');
}
if ($manifest->status !== 'pending') {
return back()->with('error', 'Can only schedule delivery for pending manifests.');
}
$validated = $request->validate([
'scheduled_delivery_date' => 'required|date|after_or_equal:today',
'delivery_time_window' => 'nullable|string|max:255',
'delivery_notes' => 'nullable|string|max:1000',
]);
// Get driver info from manifest's driver relationship if available
$driverFirstName = null;
$driverLastInitial = null;
if ($manifest->driver) {
$driverFirstName = $manifest->driver->getFirstName();
$driverLastInitial = $manifest->driver->getLastInitial();
}
// Schedule the delivery
$manifest->scheduleDelivery(
deliveryDate: \Carbon\Carbon::parse($validated['scheduled_delivery_date']),
timeWindow: $validated['delivery_time_window'] ?? null,
driverFirstName: $driverFirstName,
driverLastInitial: $driverLastInitial,
notes: $validated['delivery_notes'] ?? null,
scheduledBy: auth()->id()
);
// Generate delivery URL
$deliveryUrl = route('seller.delivery.verify', ['token' => $manifest->delivery_token]);
return back()->with([
'success' => 'Delivery scheduled successfully!',
'delivery_url' => $deliveryUrl,
]);
}
/**
* Update pickup date for an order
*/
public function updatePickupDate(\App\Models\Business $business, Order $order, Request $request)
{
// Ensure order can be accessed by this business (seller)
$order->loadMissing('items.product.brand');
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow updates for pickup orders at ready_for_delivery or approved_for_delivery status
if (! in_array($order->status, ['ready_for_delivery', 'approved_for_delivery'])) {
abort(422, 'Pickup date can only be set when order is ready for pickup');
}
if (! $order->isPickup()) {
abort(422, 'Pickup date can only be set for pickup orders');
}
$validated = $request->validate([
'pickup_date' => 'required|date|after_or_equal:today',
]);
$order->update([
'pickup_date' => $validated['pickup_date'],
]);
// Return JSON for AJAX requests
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Pickup date updated successfully',
'pickup_date' => $order->pickup_date->format('l, F j, Y'),
]);
}
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Pickup date updated successfully');
}
/**
* Mark order as ready for delivery (seller action).
* Only available when all picking tickets are completed.
*/
public function markReadyForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
{
// Verify business owns this order
$isSellerOrder = $order->items()->whereHas('product.brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->exists();
if (! $isSellerOrder) {
abort(403, 'Unauthorized access to this order');
}
// Only allow when order is accepted or in_progress
if (! in_array($order->status, ['accepted', 'in_progress'])) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order cannot be marked as ready for delivery from current status');
}
// Verify all items have been picked (workorder at 100% OR all picking tickets completed)
// Note: We check picking tickets first because there may be short-picks where workorder < 100%
// but the warehouse has completed all tickets (meaning they picked everything available)
if (! $order->allPickingTicketsCompleted() && $order->workorder_status < 100) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'All order items must be picked before marking order ready for delivery');
}
// Mark order as ready for delivery
$success = $order->markReadyForDelivery();
if ($success) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order marked as ready for delivery. Buyer has been notified.');
}
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Failed to mark order as ready for delivery');
}
/**
* Get available delivery windows for a specific date (for sellers).
*/
public function getAvailableDeliveryWindows(\App\Models\Business $business, Order $order, Request $request): \Illuminate\Http\JsonResponse
{
// Ensure order is for seller's business (load relationship to avoid lazy loading)
$order->loadMissing('items.product.brand');
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
$date = $request->query('date');
if (! $date) {
return response()->json(['error' => 'Date parameter required'], 400);
}
try {
$selectedDate = \Carbon\Carbon::parse($date);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid date format'], 400);
}
$dayOfWeek = $selectedDate->dayOfWeek;
// Fetch active delivery windows for the seller's business on this day
$windows = \App\Models\DeliveryWindow::where('business_id', $business->id)
->where('day_of_week', $dayOfWeek)
->where('is_active', true)
->orderBy('start_time')
->get()
->map(fn ($window) => [
'id' => $window->id,
'day_name' => $window->day_name,
'time_range' => $window->time_range,
]);
return response()->json(['windows' => $windows]);
}
/**
* Update order's delivery window (seller action).
*/
public function updateDeliveryWindow(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
{
// Ensure order is for seller's business (load relationship to avoid lazy loading)
$order->loadMissing('items.product.brand');
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow updates for delivery orders at approved_for_delivery status
if ($order->status !== 'approved_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Delivery window can only be set after buyer has approved the order for delivery');
}
// Only delivery orders need delivery windows
if (! $order->isDelivery()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Delivery window can only be set for delivery orders');
}
$validated = $request->validate([
'delivery_window_id' => 'required|exists:delivery_windows,id',
'delivery_window_date' => 'required|date|after_or_equal:today',
]);
$window = DeliveryWindow::findOrFail($validated['delivery_window_id']);
// Ensure window belongs to seller's business
if ($window->business_id !== $business->id) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Delivery window does not belong to your business');
}
$date = Carbon::parse($validated['delivery_window_date']);
// Validate using service
if (! $this->deliveryWindowService->validateWindowSelection($window, $date)) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Invalid delivery window selection');
}
$this->deliveryWindowService->updateOrderWindow($order, $window, $date);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Delivery window updated successfully');
}
/**
* Mark order as out for delivery (for delivery orders at approved_for_delivery status).
*/
public function markOutForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
{
// Ensure order is for seller's business (load relationship to avoid lazy loading)
$order->loadMissing('items.product.brand');
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow for delivery orders at approved_for_delivery status
if ($order->status !== 'approved_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be approved for delivery before marking as out for delivery');
}
if (! $order->isDelivery()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'This action is only available for delivery orders');
}
// Require delivery window to be set
if (! $order->deliveryWindow || ! $order->delivery_window_date) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Please schedule a delivery window before marking order as out for delivery');
}
// Update order status
$order->update([
'status' => 'out_for_delivery',
'out_for_delivery_at' => now(),
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order marked as out for delivery');
}
/**
* Confirm pickup complete (for pickup orders at approved_for_delivery status).
*/
public function confirmPickup(\App\Models\Business $business, Order $order): RedirectResponse
{
// Ensure order is for seller's business (load relationship to avoid lazy loading)
$order->loadMissing('items.product.brand');
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow for pickup orders at approved_for_delivery status
if ($order->status !== 'approved_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be approved for delivery before confirming pickup');
}
if (! $order->isPickup()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'This action is only available for pickup orders');
}
// Require pickup date to be set
if (! $order->pickup_date) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Please set a pickup date before confirming pickup completion');
}
// Update order status to delivered (pickup complete)
$order->update([
'status' => 'delivered',
'delivered_at' => now(),
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Pickup confirmed! Order marked as delivered.');
}
/**
* Confirm delivery complete (for delivery orders).
*/
public function confirmDelivery(\App\Models\Business $business, Order $order): RedirectResponse
{
// Ensure order is for seller's business (load relationship to avoid lazy loading)
$order->loadMissing('items.product.brand');
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow for delivery orders at out_for_delivery status
if ($order->status !== 'out_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be out for delivery before confirming delivery completion');
}
if (! $order->isDelivery()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'This action is only available for delivery orders');
}
// Update order status to delivered
$order->update([
'status' => 'delivered',
'delivered_at' => now(),
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Delivery confirmed! Order marked as delivered. You can now finalize the order.');
}
/**
* Finalize order after delivery - confirm actual delivered quantities and complete the order.
*/
public function finalizeOrder(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
{
// Ensure order is for seller's business (load relationship to avoid lazy loading)
$order->loadMissing('items.product.brand');
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow finalization for delivered orders
if ($order->status !== 'delivered') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be delivered before it can be finalized');
}
// Validate the request
$validated = $request->validate([
'delivery_notes' => 'nullable|string|max:5000',
'items' => 'required|array',
'items.*.id' => 'required|exists:order_items,id',
'items.*.delivered_qty' => 'required|numeric|min:0',
]);
\DB::transaction(function () use ($order, $validated) {
foreach ($validated['items'] as $itemData) {
$orderItem = $order->items()->findOrFail($itemData['id']);
$deliveredQty = (float) $itemData['delivered_qty'];
$pickedQty = (float) $orderItem->picked_qty;
// Calculate rejected quantity (items that were picked but not delivered)
$rejectedQty = $pickedQty - $deliveredQty;
// Update the order item with delivered quantity and acceptance data
// delivered_qty = what seller confirmed was delivered
// accepted_qty = same as delivered_qty (what will be invoiced)
// rejected_qty = what was picked but not delivered/accepted
$orderItem->update([
'delivered_qty' => $deliveredQty,
'accepted_qty' => $deliveredQty,
'rejected_qty' => $rejectedQty,
]);
// Return rejected items to inventory if any
if ($rejectedQty > 0 && $orderItem->batch_id && $orderItem->batch) {
$orderItem->batch->increment('quantity_available', $rejectedQty);
}
}
// Update order with finalization details
$order->update([
'delivery_notes' => $validated['delivery_notes'],
'finalized_at' => now(),
'finalized_by_user_id' => Auth::id(),
'status' => 'completed',
]);
// Recalculate line totals for each item based on delivered quantities
$newSubtotal = 0;
foreach ($order->items as $item) {
$deliveredQty = $item->delivered_qty ?? $item->picked_qty;
$lineTotal = $deliveredQty * $item->unit_price;
$item->update(['line_total' => $lineTotal]);
$newSubtotal += $lineTotal;
}
$order->update([
'subtotal' => $newSubtotal,
'total' => $newSubtotal + ($order->tax ?? 0) + ($order->delivery_fee ?? 0),
]);
// Refresh order to get updated items with delivered_qty
$order->refresh();
// Generate final invoice based on delivered quantities
$invoiceService = app(\App\Services\InvoiceService::class);
$invoiceService->generateFromOrder($order);
});
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order finalized successfully. Final invoice generated.');
}
}