From 9bf05a5effdbcc0ad4fdb9c1e1651808d74812d8 Mon Sep 17 00:00:00 2001 From: sayuru-akash <48414692+sayuru-akash@users.noreply.github.com> Date: Mon, 9 Feb 2026 03:27:54 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Optimize=20OrderController=20stock?= =?UTF-8?q?=20revert=20logic=20(N+1=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/Http/Controllers/OrderController.php | 19 +- replacement_code.php | 20 + temp_controller.php | 659 ++++++++++++++++++++ tests/Feature/OrderStockPerformanceTest.php | 125 ++++ 4 files changed, 818 insertions(+), 5 deletions(-) create mode 100644 replacement_code.php create mode 100644 temp_controller.php create mode 100644 tests/Feature/OrderStockPerformanceTest.php diff --git a/app/Http/Controllers/OrderController.php b/app/Http/Controllers/OrderController.php index fd54ceb..63cf275 100644 --- a/app/Http/Controllers/OrderController.php +++ b/app/Http/Controllers/OrderController.php @@ -426,16 +426,25 @@ public function update(Request $request, Order $order) DB::beginTransaction(); try { // 1. Revert Stock for OLD items + $variantQuantities = []; foreach ($order->items as $item) { if ($item->product_variant_id) { - $variant = ProductVariant::find($item->product_variant_id); - if ($variant) { - $variant->increment('quantity', $item->quantity); + $variantId = $item->product_variant_id; + if (!isset($variantQuantities[$variantId])) { + $variantQuantities[$variantId] = 0; + } + $variantQuantities[$variantId] += $item->quantity; + } + } + + if (!empty($variantQuantities)) { + $variants = ProductVariant::without('unit')->whereIn('id', array_keys($variantQuantities))->get(); + foreach ($variants as $variant) { + if (isset($variantQuantities[$variant->id])) { + $variant->increment('quantity', $variantQuantities[$variant->id]); } } } - - // 2. Clear OLD items $order->items()->delete(); // 3. Update Customer & Order Details diff --git a/replacement_code.php b/replacement_code.php new file mode 100644 index 0000000..542f100 --- /dev/null +++ b/replacement_code.php @@ -0,0 +1,20 @@ + // 1. Revert Stock for OLD items + $variantQuantities = []; + foreach ($order->items as $item) { + if ($item->product_variant_id) { + $variantId = $item->product_variant_id; + if (!isset($variantQuantities[$variantId])) { + $variantQuantities[$variantId] = 0; + } + $variantQuantities[$variantId] += $item->quantity; + } + } + + if (!empty($variantQuantities)) { + $variants = ProductVariant::without('unit')->whereIn('id', array_keys($variantQuantities))->get(); + foreach ($variants as $variant) { + if (isset($variantQuantities[$variant->id])) { + $variant->increment('quantity', $variantQuantities[$variant->id]); + } + } + } diff --git a/temp_controller.php b/temp_controller.php new file mode 100644 index 0000000..ded9086 --- /dev/null +++ b/temp_controller.php @@ -0,0 +1,659 @@ +filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('order_number', 'like', "%{$search}%") + ->orWhereHas('customer', function ($subQ) use ($search) { + $subQ->where('name', 'like', "%{$search}%") + ->orWhere('mobile', 'like', "%{$search}%"); + }) + ->orWhere('customer_name', 'like', "%{$search}%") + ->orWhere('customer_phone', 'like', "%{$search}%"); + }); + } + + // 2. Filter by Order Status + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + // 3. Filter by Call Status + if ($request->filled('call_status')) { + $query->where('call_status', $request->call_status); + } + + // 4. Filter by Courier + if ($request->filled('courier_id')) { + $query->where('courier_id', $request->courier_id); + } + + // 5. Filter by Date Range + if ($request->filled('date_from')) { + $query->whereDate('order_date', '>=', $request->date_from); + } + if ($request->filled('date_to')) { + $query->whereDate('order_date', '<=', $request->date_to); + } + + // 6. Payment Method + if ($request->filled('payment_method')) { + $query->where('payment_method', $request->payment_method); + } + + $orders = $query->latest()->paginate(20)->withQueryString(); + $couriers = Courier::all(); + + return view('orders.index', compact('orders', 'couriers')); + } + + /** + * Show general order create form. + */ + public function create() + { + // Predict next order number for UI + $dateStr = date('Ymd'); + $latestOrder = Order::whereDate('created_at', today())->latest()->first(); + $sequence = 1; + if ($latestOrder) { + $parts = explode('-', $latestOrder->order_number); + if (count($parts) === 3 && $parts[1] === $dateStr) { + $sequence = intval($parts[2]) + 1; + } + } + $nextOrderNumber = 'ORD-' . $dateStr . '-' . str_pad($sequence, 4, '0', STR_PAD_LEFT); + + $couriers = Courier::all(); + $slData = config('locations.sri_lanka'); + + return view('orders.create', compact('nextOrderNumber', 'couriers', 'slData')); + } + + /** + * API: Search Products for Order Form + */ + public function searchProducts(Request $request) + { + $query = $request->get('q'); + + $products = Product::with(['variants.unit']) + ->where('name', 'like', "%{$query}%") + ->orWhere('description', 'like', "%{$query}%") // Optional: Search description too + ->limit(20) + ->get(); + + // Flatten variants for easier frontend consumption + $results = []; + foreach ($products as $product) { + foreach ($product->variants as $variant) { + // Determine display name (e.g., "Product Name - XL / Red") + $variantName = $product->name; + if ($variant->unit && $variant->unit_value) { + $variantName .= " (" . $variant->unit_value . " " . $variant->unit->short_name . ")"; + } + + $results[] = [ + 'id' => $variant->id, + 'name' => $variantName, + 'image' => $variant->image ?? $product->image, + 'sku' => $variant->sku, + 'selling_price' => $variant->selling_price, + 'limit_price' => $variant->limit_price, + 'stock' => $variant->quantity, + 'unit' => $variant->unit->short_name ?? '', + ]; + } + } + + return response()->json($results); + } + + /** + * API: Search Resellers for Order Form + */ + public function searchResellers(Request $request) + { + $query = $request->get('q'); + + $resellers = Reseller::where('name', 'like', "%{$query}%") + ->orWhere('business_name', 'like', "%{$query}%") + ->orWhere('mobile', 'like', "%{$query}%") + ->limit(20) + ->get(['id', 'name', 'business_name', 'mobile']); + + return response()->json($resellers); + } + + /** + * Store a new order. + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'order_type' => 'required|in:reseller,direct', + 'order_date' => 'required|date', + 'reseller_id' => 'required_if:order_type,reseller|nullable|exists:resellers,id', + + // Customer Details + 'customer.name' => 'required|string|max:255', + 'customer.mobile' => 'required|string|max:20', + 'customer.landline' => 'nullable|string|max:20', + 'customer.address' => 'required|string', + 'customer.city' => 'nullable|string', // Optional if we just store address string + + // Products + 'items' => 'required|array|min:1', + 'items.*.id' => 'required|exists:product_variants,id', + 'items.*.quantity' => 'required|integer|min:1', + 'items.*.selling_price' => 'required|numeric|min:0', + + // Fulfillment & Address + 'courier_id' => 'nullable|exists:couriers,id', + 'courier_charge' => 'nullable|numeric|min:0', + 'payment_method' => 'nullable|string', + 'call_status' => 'nullable|string', + 'sales_note' => 'nullable|string', + 'order_status' => 'nullable|string', + 'customer.city' => 'nullable|string', + 'customer.district' => 'nullable|string', + 'customer.province' => 'nullable|string', + ]); + + DB::beginTransaction(); + try { + // 1. Create or Update Customer + // Strategy: Search by mobile number, if exists update, else create + // Note: User logic "customer we specify" implies we can create new on the fly. + $customer = Customer::updateOrCreate( + ['mobile' => $validated['customer']['mobile']], + [ + 'name' => $validated['customer']['name'], + 'landline' => $validated['customer']['landline'] ?? null, + 'address' => $validated['customer']['address'], + 'city' => $validated['customer']['city'] ?? null, // Assuming mixed use or text field + // Add other fields if strictly required by schema (e.g. country default) + 'country' => 'Sri Lanka', // Default + ] + ); + + // 2. Create Order + $orderNumber = $this->generateOrderNumber(); + + $order = new Order(); + $order->order_number = $orderNumber; + $order->order_date = $validated['order_date']; + $order->order_type = $validated['order_type']; + $order->user_id = Auth::id(); // Admin creating the order + $order->reseller_id = $validated['order_type'] === 'reseller' ? $validated['reseller_id'] : null; + $order->customer_id = $customer->id; + + // Fallback legacy fields (optional, but good for redundancy if migrated) + $order->customer_name = $customer->name; + $order->customer_phone = $customer->mobile; + $order->customer_address = $customer->address; + + $order->status = $validated['order_status'] ?? 'pending'; + + // New Fields + $order->courier_id = $validated['courier_id'] ?? null; + $order->courier_charge = $validated['courier_charge'] ?? 0; + $order->payment_method = $validated['payment_method'] ?? 'COD'; + $order->call_status = $validated['call_status'] ?? 'pending'; + $order->sales_note = $validated['sales_note'] ?? null; + + // Capture Address Snapshot + $order->customer_city = $validated['customer']['city'] ?? null; + $order->customer_district = $validated['customer']['district'] ?? null; + $order->customer_province = $validated['customer']['province'] ?? null; + + $order->save(); + + $totalAmount = 0; + $totalCost = 0; + $totalCommission = 0; + + // 3. Process Items + foreach ($validated['items'] as $itemData) { + $variant = ProductVariant::with('product')->find($itemData['id']); + + // Stock Validation (Optional: Validation rule could handle this, but explicit check is safer) + if ($variant->quantity < $itemData['quantity']) { + throw new \Exception("Insufficient stock for {$variant->product->name} (SKU: {$variant->sku})"); + } + + // Limit Price Validation + if ($itemData['selling_price'] < $variant->limit_price) { + throw new \Exception("Selling price for {$variant->product->name} (SKU: {$variant->sku}) cannot be lower than limit price ({$variant->limit_price})"); + } + + $qty = $itemData['quantity']; + $unitPrice = $itemData['selling_price']; + $basePrice = $variant->limit_price; // Assuming limit_price IS the base/cost price for commission calc + $subtotal = $unitPrice * $qty; + + $itemCost = $basePrice * $qty; + $itemCommission = ($unitPrice - $basePrice) * $qty; + + // Create Order Item + OrderItem::create([ + 'order_id' => $order->id, + 'product_variant_id' => $variant->id, + 'product_name' => $variant->product->name, // Snapshot + 'sku' => $variant->sku, + 'quantity' => $qty, + 'unit_price' => $unitPrice, + 'base_price' => $basePrice, + 'total_price' => $subtotal, + 'subtotal' => $subtotal, + ]); + + // Deduct Stock + $variant->decrement('quantity', $qty); + + // Accumulate Totals + $totalAmount += $subtotal; + $totalCost += $itemCost; + + // Commission only applies for Reseller orders + if ($order->order_type === 'reseller') { + $totalCommission += $itemCommission; + } + } + + // 4. Update Order Totals + $order->total_amount = $totalAmount + $order->courier_charge; + $order->total_cost = $totalCost; + $order->total_commission = $totalCommission; + $order->save(); + + // 5. Log Action + $this->logAction($order->id, 'created', 'Order created successfully.'); + + DB::commit(); + return response()->json([ + 'success' => true, + 'message' => 'Order created successfully!', + 'redirect' => route('orders.index'), + 'order_number' => $orderNumber + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => $e->getMessage() + ], 422); + } + } + + /** + * Generate unique order number. + */ + private function generateOrderNumber() + { + $dateStr = date('Ymd'); + $latestOrder = Order::whereDate('created_at', today())->latest()->first(); + + $sequence = 1; + if ($latestOrder) { + $parts = explode('-', $latestOrder->order_number); + if (count($parts) === 3 && $parts[1] === $dateStr) { + $sequence = intval($parts[2]) + 1; + } + } + + do { + $number = 'ORD-' . $dateStr . '-' . str_pad($sequence, 4, '0', STR_PAD_LEFT); + $exists = Order::where('order_number', $number)->exists(); + if ($exists) { + $sequence++; + } + } while ($exists); + + return $number; + } + + /** + * Log action helper. + */ + private function logAction($orderId, $action, $description = null) + { + OrderLog::create([ + 'order_id' => $orderId, + 'user_id' => Auth::id(), + 'action' => $action, + 'description' => $description, + ]); + } + /** + * Display the specified order (Invoice View). + */ + public function show(Order $order) + { + $order->load(['items.variant', 'customer', 'reseller', 'user']); + return view('orders.show', compact('order')); + } + + /** + * Download the order as PDF. + */ + public function downloadPdf(Order $order) + { + $order->load(['items.variant', 'customer', 'reseller', 'user']); + $pdf = Pdf::loadView('orders.pdf', compact('order')); + return $pdf->download('invoice-' . $order->order_number . '.pdf'); + } + + /** + * Show the form for editing the specified order. + */ + public function edit(Order $order) + { + $order->load(['items.variant', 'customer', 'reseller']); + $couriers = Courier::all(); + $slData = config('locations.sri_lanka'); + + return view('orders.edit', [ + 'order' => $order, + 'orderFull' => $order, + 'couriers' => $couriers, + 'slData' => $slData + ]); + } + + /** + * Update the specified order in storage. + */ + public function update(Request $request, Order $order) + { + $validated = $request->validate([ + 'order_type' => 'required|in:reseller,direct', + 'order_date' => 'required|date', + 'reseller_id' => 'required_if:order_type,reseller|nullable|exists:resellers,id', + + // Customer Details + 'customer.name' => 'required|string|max:255', + 'customer.mobile' => 'required|string|max:20', + 'customer.landline' => 'nullable|string|max:20', + 'customer.address' => 'required|string', + 'customer.city' => 'nullable|string', + + // Products + 'items' => 'required|array|min:1', + 'items.*.id' => 'required|exists:product_variants,id', + 'items.*.quantity' => 'required|integer|min:1', + 'items.*.selling_price' => 'required|numeric|min:0', + + // Fulfillment & Address + 'courier_id' => 'nullable|exists:couriers,id', + 'courier_charge' => 'nullable|numeric|min:0', + 'payment_method' => 'nullable|string', + 'call_status' => 'nullable|string', + 'sales_note' => 'nullable|string', + 'order_status' => 'nullable|string', + 'customer.city' => 'nullable|string', + 'customer.district' => 'nullable|string', + 'customer.province' => 'nullable|string', + ]); + + DB::beginTransaction(); + try { + // 1. Revert Stock for OLD items + foreach ($order->items as $item) { + // 1. Revert Stock for OLD items + $variantQuantities = []; + foreach ($order->items as $item) { + if ($item->product_variant_id) { + $variantId = $item->product_variant_id; + if (!isset($variantQuantities[$variantId])) { + $variantQuantities[$variantId] = 0; + } + $variantQuantities[$variantId] += $item->quantity; + } + } + + if (!empty($variantQuantities)) { + $variants = ProductVariant::without('unit')->whereIn('id', array_keys($variantQuantities))->get(); + foreach ($variants as $variant) { + if (isset($variantQuantities[$variant->id])) { + $variant->increment('quantity', $variantQuantities[$variant->id]); + } + } + } + $order->items()->delete(); + + // 3. Update Customer & Order Details + $customer = Customer::updateOrCreate( + ['mobile' => $validated['customer']['mobile']], + [ + 'name' => $validated['customer']['name'], + 'landline' => $validated['customer']['landline'] ?? null, + 'address' => $validated['customer']['address'], + 'city' => $validated['customer']['city'] ?? null, + 'country' => 'Sri Lanka', + ] + ); + + $order->order_date = $validated['order_date']; + $order->order_type = $validated['order_type']; + $order->reseller_id = $validated['order_type'] === 'reseller' ? $validated['reseller_id'] : null; + $order->customer_id = $customer->id; + // Legacy fields update + $order->customer_name = $customer->name; + $order->customer_phone = $customer->mobile; + $order->customer_address = $customer->address; + + // Create/Update Logic for New Fields + $order->status = $validated['order_status'] ?? $order->status; + $order->courier_id = $validated['courier_id'] ?? null; + $order->courier_charge = $validated['courier_charge'] ?? 0; + $order->payment_method = $validated['payment_method'] ?? 'COD'; + $order->call_status = $validated['call_status'] ?? 'pending'; + $order->sales_note = $validated['sales_note'] ?? null; + // Capture Address Snapshot + $order->customer_city = $validated['customer']['city'] ?? null; + $order->customer_district = $validated['customer']['district'] ?? null; + $order->customer_province = $validated['customer']['province'] ?? null; + + $order->save(); + + $totalAmount = 0; + $totalCost = 0; + $totalCommission = 0; + + // 4. Process NEW Items + foreach ($validated['items'] as $itemData) { + $variant = ProductVariant::with('product')->find($itemData['id']); + + // Stock Check + if ($variant->quantity < $itemData['quantity']) { + throw new \Exception("Insufficient stock for {$variant->product->name} (SKU: {$variant->sku})"); + } + + // Limit Price Check + if ($itemData['selling_price'] < $variant->limit_price) { + throw new \Exception("Selling price for {$variant->product->name} (SKU: {$variant->sku}) cannot be lower than limit price ({$variant->limit_price})"); + } + + $qty = $itemData['quantity']; + $unitPrice = $itemData['selling_price']; + $basePrice = $variant->limit_price; + $subtotal = $unitPrice * $qty; + + $itemCost = $basePrice * $qty; + $itemCommission = ($unitPrice - $basePrice) * $qty; + + // Create Item + OrderItem::create([ + 'order_id' => $order->id, + 'product_variant_id' => $variant->id, + 'product_name' => $variant->product->name, + 'sku' => $variant->sku, + 'quantity' => $qty, + 'unit_price' => $unitPrice, + 'base_price' => $basePrice, + 'total_price' => $subtotal, + 'subtotal' => $subtotal, + ]); + + // Deduct Stock + $variant->decrement('quantity', $qty); + + // Accumulate totals + $totalAmount += $subtotal; + $totalCost += $itemCost; + + if ($order->order_type === 'reseller') { + $totalCommission += $itemCommission; + } + } + + // 5. Update Order Totals + $order->total_amount = $totalAmount + $order->courier_charge; + $order->total_cost = $totalCost; + $order->total_commission = $totalCommission; + $order->save(); + + DB::commit(); + return response()->json([ + 'success' => true, + 'message' => 'Order updated successfully!', + 'redirect' => route('orders.index'), + ]); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => $e->getMessage() + ], 422); + } + } + + /** + * Remove the specified order from storage. + */ + public function destroy(Order $order) + { + DB::beginTransaction(); + try { + // Restore Stock + foreach ($order->items as $item) { + if ($item->product_variant_id) { + $variant = ProductVariant::find($item->product_variant_id); + if ($variant) { + $variant->increment('quantity', $item->quantity); + } + } + } + + // Delete Order (Cascades items) + $order->delete(); + + DB::commit(); + return redirect()->route('orders.index')->with('success', 'Order deleted successfully and stock restored.'); + } catch (\Exception $e) { + DB::rollBack(); + return redirect()->back()->with('error', 'Failed to delete order: ' . $e->getMessage()); + } + } + /** + * Display the Call List for orders. + */ + public function callList(Request $request) + { + $query = Order::with(['customer', 'reseller', 'items']); + + // Default to 'pending' call status if not specified, + // OR user might want to see all. Let's start with all but maybe sort by pending. + // Actually, user said "Focused and filtering on call status". + + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('order_number', 'like', "%{$search}%") + ->orWhereHas('customer', function ($subQ) use ($search) { + $subQ->where('name', 'like', "%{$search}%") + ->orWhere('mobile', 'like', "%{$search}%"); + }); + }); + } + + if ($request->filled('call_status')) { + $query->where('call_status', $request->call_status); + } + + if ($request->filled('date_from')) { + $query->whereDate('order_date', '>=', $request->date_from); + } + if ($request->filled('date_to')) { + $query->whereDate('order_date', '<=', $request->date_to); + } + + $orders = $query->latest()->paginate(20)->withQueryString(); + + return view('orders.call_list', compact('orders')); + } + + /** + * Update Order or Call Status via AJAX. + */ + public function updateStatus(Request $request, $id) + { + $order = Order::findOrFail($id); + + $validated = $request->validate([ + 'status' => 'nullable|in:pending,hold,confirm,shipped,delivered,cancelled', + 'call_status' => 'nullable|in:pending,confirm,cancel', + 'sales_note' => 'nullable|string', + ]); + + if (array_key_exists('status', $validated)) { + $order->status = $validated['status']; + } + + if (array_key_exists('call_status', $validated)) { + $order->call_status = $validated['call_status']; + } + + if (array_key_exists('sales_note', $validated)) { + $order->sales_note = $validated['sales_note']; + } + + $order->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Status updated successfully!', + 'call_status' => $order->call_status, + 'status' => $order->status + ]); + } +} diff --git a/tests/Feature/OrderStockPerformanceTest.php b/tests/Feature/OrderStockPerformanceTest.php new file mode 100644 index 0000000..d406a1c --- /dev/null +++ b/tests/Feature/OrderStockPerformanceTest.php @@ -0,0 +1,125 @@ +create(); + $this->actingAs($user); + + $customer = Customer::create([ + 'name' => 'John Doe', + 'mobile' => '0712345678', + 'address' => '123 Main St', + ]); + + $courier = Courier::create(['name' => 'Test Courier']); + + $product = Product::create(['name' => 'Test Product']); + $unit = Unit::create(['name' => 'Test Unit', 'short_name' => 'TU']); + + $variants = []; + for ($i = 0; $i < 5; $i++) { + $variants[] = ProductVariant::create([ + 'product_id' => $product->id, + 'unit_id' => $unit->id, + 'unit_value' => 1, + 'sku' => 'SKU-' . $i, + 'quantity' => 100, + 'selling_price' => 100, + 'limit_price' => 50, + 'alert_quantity' => 10, + ]); + } + + $order = Order::forceCreate([ + 'order_date' => now(), + 'customer_id' => $customer->id, + 'customer_name' => $customer->name, + 'customer_phone' => $customer->mobile, + 'customer_address' => $customer->address, + 'status' => 'Pending', + 'order_number' => 'ORD-123', + ]); + + // Add 10 items (repeating variants) + foreach ($variants as $index => $variant) { + // Add twice to simulate multiple items for same variant + OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'product_variant_id' => $variant->id, + 'product_name' => 'Test Product', + 'sku' => 'SKU-0', + 'quantity' => 2, + 'unit_price' => 100, + 'total_price' => 200, + ]); + OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'product_variant_id' => $variant->id, + 'product_name' => 'Test Product', + 'sku' => 'SKU-0', + 'quantity' => 3, + 'unit_price' => 100, + 'total_price' => 300, + ]); + } + // Total 10 items. 5 unique variants. + + // Prepare payload for update (can be same items or new, we just want to trigger the revert logic) + // We will just keep one item in the new order to make payload simple + $payload = [ + 'order_type' => 'direct', + 'order_date' => now()->toDateString(), + 'customer' => [ + 'name' => 'John Doe Updated', + 'mobile' => '0712345678', + 'address' => '123 Main St', + ], + 'items' => [ + [ + 'id' => $variants[0]->id, + 'quantity' => 5, + 'selling_price' => 100, + ] + ], + 'courier_id' => $courier->id, + ]; + + // 2. Measure Queries + DB::enableQueryLog(); + + $response = $this->putJson(route('orders.update', $order->id), $payload); + + $queries = DB::getQueryLog(); + $queryCount = count($queries); + + // Debug output + // foreach ($queries as $q) { + // echo "\n" . $q['query']; + // } + + $response->assertStatus(200); + + echo "\nTotal Queries: " . $queryCount . "\n"; + } +}