Cart stock updation added

This commit is contained in:
Sidharth Prabhu
2026-04-18 21:13:34 +05:30
parent 8a41d650a2
commit c36b4e4de0
12 changed files with 233 additions and 88 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,3 @@
# ordering-site/*
# ordering_site/
ordering-site/*
ordering_site/
counter-frontend/

View File

@@ -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<List<Map<String, Object>>> 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<Map<String, Object>> placeOrder(@RequestBody Order order) {
// Link items to the order for bidirectional relationship
if (order.getItems() != null) {
@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<Map<String, Object>> stockConflicts = new ArrayList<>();
requestConflicts.remove(); // Clear before use
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<String, Object> 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()) {
System.out.println("🛒 RECEIVED ITEM: " + item.getProductName() + " | StallID: " + item.getStallId() + " | StallName: " + item.getStallName());
item.setOrder(order);
}
}
// 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
// 4. Final Save
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());
});
}
}
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"
));
}
System.out.println("Placed Daily Order: " + savedOrder.getId() + " -> Display ID: #" + displayId);
Map<String, Object> 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");
return ResponseEntity.ok(response);
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
List<Map<String, Object>> 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}")

View File

@@ -24,4 +24,7 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
List<String> 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);
}

View File

@@ -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() {
<AuthProvider>
<FoodProvider>
<CartProvider>
<StockAlert />
<Router>
<Routes>
<Route path="/login" element={<LoginScreen />} />

View File

@@ -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;
}

View File

@@ -13,10 +13,11 @@ const ItemCard: React.FC<ItemCardProps> = ({ 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 (
<div
className={`item-card ${isLast ? 'last' : ''} ${item.stock === 0 ? 'out-of-stock' : ''}`}
className={`item-card ${isLast ? 'last' : ''} ${item.stock === 0 ? 'out-of-stock' : ''} ${isLimitReached ? 'limit-reached' : ''}`}
onClick={() => navigate(`/item/${item.id}`)}
>
<div className="item-info">
@@ -26,6 +27,7 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast }) => {
</div>
{item.isPopular && <span className="bestseller-badge">Popular</span>}
{item.stock === 0 && <span className="out-of-stock-badge">Out of Stock</span>}
{isLimitReached && <span className="limit-badge">Only {item.stock} left</span>}
</div>
<h3 className="item-name">{item.name}</h3>
@@ -53,10 +55,13 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast }) => {
{item.stock === 0 ? 'SOLD OUT' : 'ADD'}
</button>
) : (
<div className="quantity-controls">
<div className={`quantity-controls ${isLimitReached ? 'at-limit' : ''}`}>
<button onClick={() => updateQuantity(item.id, -1)}></button>
<span className="quantity">{quantity}</span>
<button onClick={() => addToCart(item)}>+</button>
<button
onClick={() => addToCart(item)}
className={isLimitReached ? 'disabled' : ''}
>+</button>
</div>
)}
</div>

View File

@@ -10,6 +10,8 @@ interface CartContextType {
getItemQuantity: (itemId: string) => number;
totalItems: number;
totalPrice: number;
stockError: string | null;
clearStockError: () => void;
}
const CartContext = createContext<CartContextType | undefined>(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<string | null>(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}

View File

@@ -24,8 +24,8 @@ export const FoodProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (

View File

@@ -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,12 +19,16 @@ 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<any[]>([]);
const [showConflictModal, setShowConflictModal] = useState(false);
const handlePlaceOrder = async () => {
if (!user) return;
setIsProcessing(true);
@@ -42,10 +48,6 @@ const CheckoutScreen: React.FC = () => {
}))
};
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`, {
method: 'POST',
@@ -53,8 +55,6 @@ 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();
@@ -65,15 +65,14 @@ const CheckoutScreen: React.FC = () => {
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 {
console.error('Order Logic Error:', data.message || data);
alert(data.message || 'Failed to place order');
}
} else {
const text = await response.text();
console.error('Order Server Error (Non-JSON):', text);
alert('Server Error. Check console for details.');
}
} catch (error) {
console.error('Order error:', error);
alert('Error connecting to server. Please try again.');
@@ -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 (
<div className="container checkout-page">
<Header title="Checkout" showCart={false} />
@@ -141,6 +158,17 @@ const CheckoutScreen: React.FC = () => {
{isProcessing ? 'Processing...' : `Pay ₹${totalPrice.toFixed(2)} & Place Order`}
</button>
</div>
<AnimatePresence>
{showConflictModal && (
<StockConflictModal
conflicts={stockConflicts}
onRemoveItem={handleRemoveConflictItem}
onAdjustQuantity={handleAdjustConflictQuantity}
onClose={() => navigate('/cart')}
/>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -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;
}

View File

@@ -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 <div className="container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>Loading...</div>;
@@ -69,10 +71,17 @@ const ItemDetailScreen: React.FC = () => {
<div className="info-row">
<span className="info-label">Availability</span>
<span className={`info-value ${item.stock && item.stock > 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'}
</span>
</div>
</div>
{isLimitReached && (
<div className="limit-reached-info">
<AlertCircle size={18} />
<span>You have reached the maximum available quantity ({item.stock}) for this item.</span>
</div>
)}
</div>
</main>
@@ -98,7 +107,7 @@ const ItemDetailScreen: React.FC = () => {
<span className="quantity">{quantity}</span>
<button
onClick={() => addToCart(item)}
disabled={item.stock !== undefined && quantity >= item.stock}
disabled={isLimitReached}
>+</button>
</div>
)}

View File

@@ -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++) {
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);
}
} else {
missing.push(orderItem.productName);
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
}
});