From c36b4e4de096de0e1afb434d35ebda603e4c6623 Mon Sep 17 00:00:00 2001 From: Sidharth Prabhu Date: Sat, 18 Apr 2026 21:13:34 +0530 Subject: [PATCH] Cart stock updation added --- .gitignore | 4 +- .../sales/controller/OrderController.java | 108 +++++++++++------- .../sales/repository/ProductRepository.java | 3 + ordering_site/src/App.tsx | 2 + ordering_site/src/components/ItemCard.css | 21 ++++ ordering_site/src/components/ItemCard.tsx | 11 +- ordering_site/src/contexts/CartContext.tsx | 27 ++++- ordering_site/src/contexts/FoodContext.tsx | 15 ++- ordering_site/src/pages/CheckoutScreen.tsx | 76 ++++++++---- ordering_site/src/pages/ItemDetailScreen.css | 19 +++ ordering_site/src/pages/ItemDetailScreen.tsx | 13 ++- ordering_site/src/pages/MyOrdersScreen.tsx | 22 ++-- 12 files changed, 233 insertions(+), 88 deletions(-) diff --git a/.gitignore b/.gitignore index 5ddcb523..94d8f6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -# ordering-site/* -# ordering_site/ +ordering-site/* +ordering_site/ counter-frontend/ \ No newline at end of file diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java b/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java index e69f583a..ccb7993c 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java @@ -34,6 +34,9 @@ public class OrderController { @Autowired private com.rit.canteen.sales.service.OrderArchiverService orderArchiverService; + // Use ThreadLocal to safely store conflicts for the current request context + private static final ThreadLocal>> requestConflicts = new ThreadLocal<>(); + @GetMapping("/all") public ResponseEntity getAllOrders( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, @@ -113,54 +116,79 @@ public class OrderController { } @PostMapping - public ResponseEntity> placeOrder(@RequestBody Order order) { - // Link items to the order for bidirectional relationship - if (order.getItems() != null) { - for (OrderItem item : order.getItems()) { - System.out.println("🛒 RECEIVED ITEM: " + item.getProductName() + " | StallID: " + item.getStallId() + " | StallName: " + item.getStallName()); - item.setOrder(order); - } + @org.springframework.transaction.annotation.Transactional + public ResponseEntity placeOrder(@RequestBody Order order) { + // 1. Pre-validation and linking + if (order.getItems() == null || order.getItems().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items")); } + + // 2. Atomic Stock Check & Update + List> stockConflicts = new ArrayList<>(); + requestConflicts.remove(); // Clear before use - // Use the actual creation time or current time for counting - LocalDateTime now = LocalDateTime.now(); - order.setCreatedAt(now); - - // Calculate start of current day to find how many orders placed today - LocalDateTime startOfDay = now.toLocalDate().atStartOfDay(); - long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay); - - // Generate formatted display ID (#001, #002...) resetting daily - String displayId = String.format("%03d", todaysOrderCount + 1); - order.setDisplayOrderId(displayId); - - // Save the complete order first - Order savedOrder = orderRepository.save(order); - - // --- Reduct Stock Logic --- - if (savedOrder.getItems() != null) { - for (OrderItem item : savedOrder.getItems()) { - Long productId = item.getProductId(); - if (productId != null) { - productRepository.findById(productId).ifPresent(product -> { - int currentStock = product.getStock() != null ? product.getStock() : 0; - product.setStock(currentStock - item.getQuantity()); - productRepository.save(product); - System.out.println("Updating Stock for " + product.getName() + ": " + currentStock + " -> " + product.getStock()); - }); + for (OrderItem item : order.getItems()) { + Long productId = item.getProductId(); + if (productId != null) { + int updatedRows = productRepository.decrementStock(productId, item.getQuantity()); + + if (updatedRows == 0) { + com.rit.canteen.sales.model.Product p = productRepository.findById(productId).orElse(null); + int left = (p != null && p.getStock() != null) ? p.getStock() : 0; + + Map conflict = new HashMap<>(); + conflict.put("productId", productId); + conflict.put("productName", item.getProductName()); + conflict.put("requested", item.getQuantity()); + conflict.put("available", left); + stockConflicts.add(conflict); } } } + + if (!stockConflicts.isEmpty()) { + requestConflicts.set(stockConflicts); + throw new RuntimeException("CONCURRENCY_STOCK_FAILURE"); + } + + // 3. Complete Order Details + for (OrderItem item : order.getItems()) { + item.setOrder(order); + } - System.out.println("Placed Daily Order: " + savedOrder.getId() + " -> Display ID: #" + displayId); + LocalDateTime now = LocalDateTime.now(); + order.setCreatedAt(now); + LocalDateTime startOfDay = now.toLocalDate().atStartOfDay(); + long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay); + String displayId = String.format("%03d", todaysOrderCount + 1); + order.setDisplayOrderId(displayId); - Map response = new HashMap<>(); - response.put("success", true); - response.put("orderNumber", savedOrder.getOrderNumber()); // Secure ID for QR - response.put("displayOrderId", savedOrder.getDisplayOrderId()); // Sequential ID (#001) - response.put("message", "Order placed successfully"); + // 4. Final Save + Order savedOrder = orderRepository.save(order); - return ResponseEntity.ok(response); + System.out.println("Placed Order: " + savedOrder.getId() + " -> Display ID: #" + displayId); + + return ResponseEntity.ok(Map.of( + "success", true, + "orderNumber", savedOrder.getOrderNumber(), + "displayOrderId", savedOrder.getDisplayOrderId(), + "message", "Order placed successfully" + )); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException e) { + if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) { + List> conflicts = requestConflicts.get(); + requestConflicts.remove(); + return ResponseEntity.status(400).body(Map.of( + "success", false, + "errorType", "STOCK_ERROR", + "message", "Some items in your cart are no longer available in the requested quantity.", + "conflicts", conflicts != null ? conflicts : new ArrayList<>() + )); + } + return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Internal Server Error")); } @GetMapping("/user/{userId}") diff --git a/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java b/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java index 4913db1b..bdccf45f 100644 --- a/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java +++ b/backend/src/main/java/com/rit/canteen/sales/repository/ProductRepository.java @@ -24,4 +24,7 @@ public interface ProductRepository extends JpaRepository { List findDistinctCategories(); boolean existsByNameAndCategory(String name, String category); + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query("UPDATE Product p SET p.stock = p.stock - :quantity WHERE p.id = :id AND p.stock >= :quantity") + int decrementStock(@org.springframework.data.repository.query.Param("id") Long id, @org.springframework.data.repository.query.Param("quantity") int quantity); } diff --git a/ordering_site/src/App.tsx b/ordering_site/src/App.tsx index ddc43bc3..439f48f6 100644 --- a/ordering_site/src/App.tsx +++ b/ordering_site/src/App.tsx @@ -14,6 +14,7 @@ import LoginScreen from './pages/LoginScreen'; import ProfileScreen from './pages/ProfileScreen'; import ChangePinScreen from './pages/ChangePinScreen'; import StallDetailScreen from './pages/StallDetailScreen'; +import StockAlert from './components/StockAlert'; import './App.css'; function App() { @@ -21,6 +22,7 @@ function App() { + } /> diff --git a/ordering_site/src/components/ItemCard.css b/ordering_site/src/components/ItemCard.css index 1faa1b08..0091c8bb 100644 --- a/ordering_site/src/components/ItemCard.css +++ b/ordering_site/src/components/ItemCard.css @@ -214,3 +214,24 @@ min-width: 16px; text-align: center; } + +.limit-badge { + background-color: #fef2f2; + color: #ef4444; + font-size: 0.65rem; + font-weight: 700; + padding: 1px 6px; + border-radius: 20px; + white-space: nowrap; + border: 1px solid #fee2e2; +} + +.at-limit button:last-child { + opacity: 0.3; + cursor: not-allowed; + background-color: #f1f5f9; +} + +.item-card.limit-reached { + border-left: 3px solid #ef4444; +} diff --git a/ordering_site/src/components/ItemCard.tsx b/ordering_site/src/components/ItemCard.tsx index e856b300..89883867 100644 --- a/ordering_site/src/components/ItemCard.tsx +++ b/ordering_site/src/components/ItemCard.tsx @@ -13,10 +13,11 @@ const ItemCard: React.FC = ({ item, isLast }) => { const navigate = useNavigate(); const { addToCart, updateQuantity, getItemQuantity } = useCart(); const quantity = getItemQuantity(item.id); + const isLimitReached = item.stock !== undefined && quantity >= item.stock && item.stock > 0; return (
navigate(`/item/${item.id}`)} >
@@ -26,6 +27,7 @@ const ItemCard: React.FC = ({ item, isLast }) => {
{item.isPopular && Popular} {item.stock === 0 && Out of Stock} + {isLimitReached && Only {item.stock} left}

{item.name}

@@ -53,10 +55,13 @@ const ItemCard: React.FC = ({ item, isLast }) => { {item.stock === 0 ? 'SOLD OUT' : 'ADD'} ) : ( -
+
{quantity} - +
)}
diff --git a/ordering_site/src/contexts/CartContext.tsx b/ordering_site/src/contexts/CartContext.tsx index ffbd56aa..6cd81f95 100644 --- a/ordering_site/src/contexts/CartContext.tsx +++ b/ordering_site/src/contexts/CartContext.tsx @@ -10,6 +10,8 @@ interface CartContextType { getItemQuantity: (itemId: string) => number; totalItems: number; totalPrice: number; + stockError: string | null; + clearStockError: () => void; } const CartContext = createContext(undefined); @@ -19,14 +21,25 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children const savedCart = localStorage.getItem('cart'); return savedCart ? JSON.parse(savedCart) : []; }); + const [stockError, setStockError] = useState(null); useEffect(() => { localStorage.setItem('cart', JSON.stringify(cart)); }, [cart]); + const clearStockError = () => setStockError(null); + const addToCart = (item: FoodItem) => { setCart((prevCart) => { const existingIndex = prevCart.findIndex((i) => i.id === item.id); + + // Stock check + const currentQty = existingIndex !== -1 ? prevCart[existingIndex].quantity : 0; + if (item.stock !== undefined && currentQty >= item.stock) { + setStockError(`Only ${item.stock} left for ${item.name}`); + return prevCart; + } + if (existingIndex !== -1) { const newCart = [...prevCart]; newCart[existingIndex] = { @@ -47,9 +60,17 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children setCart((prevCart) => { const index = prevCart.findIndex((item) => item.id === itemId); if (index !== -1) { - const newCart = [...prevCart]; - const newQty = newCart[index].quantity + delta; + const item = prevCart[index]; + const newQty = item.quantity + delta; + + // Stock check for increment + if (delta > 0 && item.stock !== undefined && item.quantity >= item.stock) { + setStockError(`Only ${item.stock} left for ${item.name}`); + return prevCart; + } + if (newQty > 0) { + const newCart = [...prevCart]; newCart[index] = { ...newCart[index], quantity: newQty }; return newCart; } else { @@ -84,6 +105,8 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children getItemQuantity, totalItems, totalPrice, + stockError, + clearStockError, }} > {children} diff --git a/ordering_site/src/contexts/FoodContext.tsx b/ordering_site/src/contexts/FoodContext.tsx index 6f581a44..1b9fa24f 100644 --- a/ordering_site/src/contexts/FoodContext.tsx +++ b/ordering_site/src/contexts/FoodContext.tsx @@ -24,8 +24,8 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const fetchData = async () => { - setIsLoading(true); + const fetchData = async (silent = false) => { + if (!silent) setIsLoading(true); setError(null); try { const [baseItemsRes, productsRes, stallsRes] = await Promise.all([ @@ -119,15 +119,22 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children setFoodItems(mappedFoodItems); setStalls(mappedStalls); } catch (err: any) { - setError(err.message); + if (!silent) setError(err.message); console.error('Error fetching food data:', err); } finally { - setIsLoading(false); + if (!silent) setIsLoading(false); } }; useEffect(() => { fetchData(); + + // Silent background polling every 10 seconds to sync stock + const pollInterval = setInterval(() => { + fetchData(true); + }, 10000); + + return () => clearInterval(pollInterval); }, []); return ( diff --git a/ordering_site/src/pages/CheckoutScreen.tsx b/ordering_site/src/pages/CheckoutScreen.tsx index e2fdb9ca..04532cd3 100644 --- a/ordering_site/src/pages/CheckoutScreen.tsx +++ b/ordering_site/src/pages/CheckoutScreen.tsx @@ -1,7 +1,9 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Smartphone } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; import Header from '../components/Header'; +import StockConflictModal from '../components/StockConflictModal'; import { useCart } from '../contexts/CartContext'; import { useAuth } from '../contexts/AuthContext'; import { useFood } from '../contexts/FoodContext'; @@ -17,11 +19,15 @@ const UPI_APPS = [ const CheckoutScreen: React.FC = () => { const navigate = useNavigate(); - const { cart, totalPrice, clearCart } = useCart(); + const { cart, totalPrice, clearCart, removeFromCart, updateQuantity } = useCart(); const { user } = useAuth(); const { refreshData } = useFood(); const [selectedApp, setSelectedApp] = useState('gpay'); const [isProcessing, setIsProcessing] = useState(false); + + // Conflict state + const [stockConflicts, setStockConflicts] = useState([]); + const [showConflictModal, setShowConflictModal] = useState(false); const handlePlaceOrder = async () => { if (!user) return; @@ -41,10 +47,6 @@ const CheckoutScreen: React.FC = () => { stallName: item.stallName || null })) }; - - console.warn('❗ DIAGNOSTIC: PRE-ORDER PAYLOAD CHECK'); - console.table(orderData.items.map(i => ({ Name: i.productName, StallID: i.stallId, StallName: i.stallName }))); - console.log('Full Payload:', JSON.stringify(orderData, null, 2)); try { const response = await fetch(`http://${window.location.hostname}:8080/api/orders`, { @@ -53,26 +55,23 @@ const CheckoutScreen: React.FC = () => { body: JSON.stringify(orderData), }); - const contentType = response.headers.get("content-type"); - if (contentType && contentType.indexOf("application/json") !== -1) { - const data = await response.json(); - if (data.success) { - await refreshData(); - clearCart(); - navigate('/success', { - state: { - orderNumber: data.orderNumber, - displayOrderId: data.displayOrderId - } - }); - } else { - console.error('Order Logic Error:', data.message || data); - alert(data.message || 'Failed to place order'); - } + const data = await response.json(); + if (data.success) { + await refreshData(); + clearCart(); + navigate('/success', { + state: { + orderNumber: data.orderNumber, + displayOrderId: data.displayOrderId + } + }); + } else if (data.errorType === 'STOCK_ERROR') { + console.error('Final Step Stock Conflict:', data.conflicts); + setStockConflicts(data.conflicts || []); + setShowConflictModal(true); + await refreshData(true); // Sync background stock } else { - const text = await response.text(); - console.error('Order Server Error (Non-JSON):', text); - alert('Server Error. Check console for details.'); + alert(data.message || 'Failed to place order'); } } catch (error) { console.error('Order error:', error); @@ -82,6 +81,24 @@ const CheckoutScreen: React.FC = () => { } }; + const handleRemoveConflictItem = (productId: number) => { + removeFromCart(productId.toString()); + const remaining = stockConflicts.filter(c => c.productId !== productId); + setStockConflicts(remaining); + if (remaining.length === 0) setShowConflictModal(false); + }; + + const handleAdjustConflictQuantity = (productId: number, newQty: number) => { + const item = cart.find(i => i.id === productId.toString()); + if (item) { + const delta = newQty - item.quantity; + updateQuantity(productId.toString(), delta); + } + const remaining = stockConflicts.filter(c => c.productId !== productId); + setStockConflicts(remaining); + if (remaining.length === 0) setShowConflictModal(false); + }; + return (
@@ -141,6 +158,17 @@ const CheckoutScreen: React.FC = () => { {isProcessing ? 'Processing...' : `Pay ₹${totalPrice.toFixed(2)} & Place Order`}
+ + + {showConflictModal && ( + navigate('/cart')} + /> + )} + ); }; diff --git a/ordering_site/src/pages/ItemDetailScreen.css b/ordering_site/src/pages/ItemDetailScreen.css index 9cd33bba..e3e4a971 100644 --- a/ordering_site/src/pages/ItemDetailScreen.css +++ b/ordering_site/src/pages/ItemDetailScreen.css @@ -259,3 +259,22 @@ font-size: 0.9rem; } } + +.limit-reached-info { + background-color: #fef2f2; + border: 1px solid #fee2e2; + padding: 12px; + border-radius: 12px; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 8px; + color: #ef4444; + font-size: 0.85rem; + font-weight: 700; +} + +.footer-quantity-controls button:disabled { + opacity: 0.3; + cursor: not-allowed; +} diff --git a/ordering_site/src/pages/ItemDetailScreen.tsx b/ordering_site/src/pages/ItemDetailScreen.tsx index a37db2bf..31578695 100644 --- a/ordering_site/src/pages/ItemDetailScreen.tsx +++ b/ordering_site/src/pages/ItemDetailScreen.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useParams } from 'react-router-dom'; +import { AlertCircle } from 'lucide-react'; import Header from '../components/Header'; import { useCart } from '../contexts/CartContext'; import { useFood } from '../contexts/FoodContext'; @@ -13,6 +14,7 @@ const ItemDetailScreen: React.FC = () => { const item = foodItems.find((i) => i.id === itemId); const quantity = item ? getItemQuantity(item.id) : 0; + const isLimitReached = item && item.stock !== undefined && quantity >= item.stock && item.stock > 0; if (isLoading && foodItems.length === 0) { return
Loading...
; @@ -69,10 +71,17 @@ const ItemDetailScreen: React.FC = () => {
Availability 0 ? 'green' : 'red'}`}> - {item.stock && item.stock > 0 ? 'In Stock' : 'Out of Stock'} + {item.stock && item.stock > 0 ? `In Stock (${item.stock} left)` : 'Out of Stock'}
+ + {isLimitReached && ( +
+ + You have reached the maximum available quantity ({item.stock}) for this item. +
+ )} @@ -98,7 +107,7 @@ const ItemDetailScreen: React.FC = () => { {quantity} )} diff --git a/ordering_site/src/pages/MyOrdersScreen.tsx b/ordering_site/src/pages/MyOrdersScreen.tsx index 93ba2c5c..93357c27 100644 --- a/ordering_site/src/pages/MyOrdersScreen.tsx +++ b/ordering_site/src/pages/MyOrdersScreen.tsx @@ -65,21 +65,21 @@ const MyOrdersScreen: React.FC = () => { const available: FoodItem[] = []; order.items.forEach(orderItem => { - // Find the current food item by name (since orders store names) - // Robustness: matching by name is fallback if IDs aren't directly available in OrderItem DTO const currentItem = foodItems.find(fi => fi.name === orderItem.productName); - if (currentItem) { - if (currentItem.stock && currentItem.stock > 0) { - // Add multiple times based on original quantity - for (let i = 0; i < orderItem.quantity; i++) { - available.push(currentItem); - } - } else { - missing.push(orderItem.productName); + if (currentItem && currentItem.stock !== undefined && currentItem.stock > 0) { + // Only add up to available stock + const canAdd = Math.min(orderItem.quantity, currentItem.stock); + + for (let i = 0; i < canAdd; i++) { + available.push(currentItem); + } + + if (canAdd < orderItem.quantity) { + missing.push(`${orderItem.productName} (only ${canAdd} of ${orderItem.quantity} available)`); } } else { - missing.push(orderItem.productName); // Item no longer exists in catalog + missing.push(orderItem.productName); // Item no longer exists or out of stock } });