All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- 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
1379 lines
55 KiB
PHP
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.');
|
|
}
|
|
}
|