Memperbaiki logika alur sistem

This commit is contained in:
rhanarmt 2026-04-30 15:57:38 +07:00
parent e08e9c91e9
commit 343e540332
6 changed files with 602 additions and 20 deletions

View File

@ -42,6 +42,22 @@ CREATE TABLE IF NOT EXISTS products (
KEY idx_category (category) KEY idx_category (category)
); );
-- 4. TABLE: stock_usage_history
-- Menyimpan riwayat pemakaian stok saat produksi
CREATE TABLE IF NOT EXISTS stock_usage_history (
id INT PRIMARY KEY AUTO_INCREMENT,
recipe_name VARCHAR(100),
production_quantity INT,
product_id INT NOT NULL,
product_name VARCHAR(100) NOT NULL,
quantity_used FLOAT NOT NULL,
unit VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
KEY idx_product_id (product_id),
KEY idx_recipe_name (recipe_name)
);
-- ===================================================== -- =====================================================
-- INSERT DATA: RESEP -- INSERT DATA: RESEP
-- ===================================================== -- =====================================================

View File

@ -6,12 +6,13 @@ class PredictionController extends ChangeNotifier {
int productionQuantity = 0; int productionQuantity = 0;
bool isCalculated = false; bool isCalculated = false;
bool isLoading = true; bool isLoading = true;
bool isSubmitting = false;
List<Map<String, dynamic>> recipes = []; List<Map<String, dynamic>> recipes = [];
Map<String, Map<String, dynamic>> recipeIngredients = {}; Map<String, Map<String, dynamic>> recipeIngredients = {};
Map<String, bool> ingredientSelections = {}; Map<String, bool> ingredientSelections = {};
final Map<String, int> currentStock = { final Map<String, double> currentStock = {
'Tepung Terigu 1kg': 45000, 'Tepung Terigu 1kg': 45000,
'Telur 1kg': 12, 'Telur 1kg': 12,
'Gula Pasir 1kg': 28000, 'Gula Pasir 1kg': 28000,
@ -21,6 +22,10 @@ class PredictionController extends ChangeNotifier {
'Keju Parut 250gr': 3000, 'Keju Parut 250gr': 3000,
'Baking Powder': 60000, 'Baking Powder': 60000,
}; };
final Map<String, int> productIds = {};
final Map<String, String> productUnits = {};
static const double eggGramPerButir = 50;
Future<String?> loadRecipes() async { Future<String?> loadRecipes() async {
isLoading = true; isLoading = true;
@ -28,6 +33,7 @@ class PredictionController extends ChangeNotifier {
try { try {
final fetchedRecipes = await MLService.getRecipes(); final fetchedRecipes = await MLService.getRecipes();
final fetchedProducts = await MLService.getProducts();
recipes = fetchedRecipes; recipes = fetchedRecipes;
recipeIngredients = {}; recipeIngredients = {};
@ -47,6 +53,7 @@ class PredictionController extends ChangeNotifier {
} }
} }
_applyProductStocks(fetchedProducts);
_initializeIngredientSelections(); _initializeIngredientSelections();
return null; return null;
@ -58,6 +65,44 @@ class PredictionController extends ChangeNotifier {
} }
} }
Future<void> refreshStock() async {
try {
final fetchedProducts = await MLService.getProducts();
_applyProductStocks(fetchedProducts);
} catch (_) {
// Ignore refresh errors; keep existing stock
} finally {
notifyListeners();
}
}
void _applyProductStocks(List<Map<String, dynamic>> products) {
if (products.isEmpty) return;
currentStock.clear();
productIds.clear();
productUnits.clear();
for (final product in products) {
final name =
(product['name'] ?? product['product_name'] ?? '').toString();
if (name.isEmpty) continue;
final id = product['id'] ?? 0;
final unit = (product['unit'] ?? '').toString();
final stockRaw = product['current_stock'] ?? 0;
final stock = stockRaw is num ? stockRaw.toDouble() : 0.0;
currentStock[name] = stock;
if (id is int) {
productIds[name] = id;
}
if (unit.isNotEmpty) {
productUnits[name] = unit;
}
}
}
void setSelectedRecipe(String? value) { void setSelectedRecipe(String? value) {
selectedRecipe = value; selectedRecipe = value;
isCalculated = false; isCalculated = false;
@ -107,31 +152,36 @@ class PredictionController extends ChangeNotifier {
}; };
} }
Map<String, int> get requiredIngredients { Map<String, double> get requiredIngredients {
if (selectedRecipe == null || productionQuantity == 0) { if (selectedRecipe == null || productionQuantity == 0) {
return {}; return {};
} }
final ingredients = recipeIngredients[selectedRecipe] ?? {}; final ingredients = recipeIngredients[selectedRecipe] ?? {};
final required = <String, int>{}; final required = <String, double>{};
ingredients.forEach((productName, details) { ingredients.forEach((productName, details) {
if (!isIngredientSelected(productName)) return; if (!isIngredientSelected(productName)) return;
final quantity = (details['quantity'] as num).toInt(); final quantity = (details['quantity'] as num).toDouble();
required[productName] = quantity * productionQuantity; required[productName] = quantity * productionQuantity;
}); });
return required; return required;
} }
Map<String, int> get insufficientStock { Map<String, double> get insufficientStock {
final required = requiredIngredients; final required = requiredIngredients;
final insufficient = <String, int>{}; final insufficient = <String, double>{};
required.forEach((ingredient, neededAmount) { required.forEach((ingredient, neededAmount) {
final requiredInStockUnit = _convertToStockUnit(
ingredient: ingredient,
amount: neededAmount,
fromUnit: getIngredientUnit(ingredient),
);
final available = currentStock[ingredient] ?? 0; final available = currentStock[ingredient] ?? 0;
if (available < neededAmount) { if (available < requiredInStockUnit) {
insufficient[ingredient] = neededAmount - available; insufficient[ingredient] = requiredInStockUnit - available;
} }
}); });
@ -149,8 +199,27 @@ class PredictionController extends ChangeNotifier {
return ingData['unit'] as String? ?? 'gr'; return ingData['unit'] as String? ?? 'gr';
} }
Color getStatusColor(String ingredient) { String getStockUnit(String ingredient) {
final unit = productUnits[ingredient];
if (unit != null && unit.isNotEmpty) return unit;
return getIngredientUnit(ingredient);
}
double getRequiredInStockUnit(String ingredient) {
final required = requiredIngredients[ingredient] ?? 0; final required = requiredIngredients[ingredient] ?? 0;
return _convertToStockUnit(
ingredient: ingredient,
amount: required,
fromUnit: getIngredientUnit(ingredient),
);
}
Color getStatusColor(String ingredient) {
final required = _convertToStockUnit(
ingredient: ingredient,
amount: requiredIngredients[ingredient] ?? 0,
fromUnit: getIngredientUnit(ingredient),
);
final available = currentStock[ingredient] ?? 0; final available = currentStock[ingredient] ?? 0;
return available >= required return available >= required
? const Color(0xFF10B981) ? const Color(0xFF10B981)
@ -160,4 +229,144 @@ class PredictionController extends ChangeNotifier {
String cleanIngredientName(String ingredient) { String cleanIngredientName(String ingredient) {
return ingredient.replaceAll(RegExp(r' \d+(kg|gr)'), '').trim(); return ingredient.replaceAll(RegExp(r' \d+(kg|gr)'), '').trim();
} }
String formatQuantity(double value) {
if (value % 1 == 0) {
return value.toInt().toString();
}
return value
.toStringAsFixed(2)
.replaceAll(RegExp(r'0+$'), '')
.replaceAll(RegExp(r'\.$'), '');
}
double _convertToStockUnit({
required String ingredient,
required double amount,
required String fromUnit,
}) {
final stockUnit = getStockUnit(ingredient).toLowerCase();
final unit = fromUnit.toLowerCase();
if (unit == stockUnit) return amount;
if (unit == 'gr' && stockUnit == 'kg') return amount / 1000;
if (unit == 'kg' && stockUnit == 'gr') return amount * 1000;
if (unit == 'butir' && stockUnit == 'kg') {
return (amount * eggGramPerButir) / 1000;
}
if (unit == 'butir' && stockUnit == 'gr') {
return amount * eggGramPerButir;
}
if (unit == 'kg' && stockUnit == 'butir') {
return (amount * 1000) / eggGramPerButir;
}
if (unit == 'gr' && stockUnit == 'butir') {
return amount / eggGramPerButir;
}
return amount;
}
double _applyRoundingForStock(double amountInStockUnit, String stockUnit) {
final unit = stockUnit.toLowerCase();
if (unit != 'kg' && unit != 'gr') return amountInStockUnit;
final grams = unit == 'kg' ? amountInStockUnit * 1000 : amountInStockUnit;
if (grams <= 0) return 0;
double roundedKg;
if (grams < 500) {
roundedKg = 0.5;
} else if (grams <= 1000) {
roundedKg = 1.0;
} else {
roundedKg = (grams / 1000).ceilToDouble();
}
return unit == 'kg' ? roundedKg : roundedKg * 1000;
}
Map<String, double> get roundedUsage {
final required = requiredIngredients;
final rounded = <String, double>{};
required.forEach((ingredient, neededAmount) {
if (!isIngredientSelected(ingredient)) return;
final stockUnit = getStockUnit(ingredient);
final requiredInStockUnit = _convertToStockUnit(
ingredient: ingredient,
amount: neededAmount,
fromUnit: getIngredientUnit(ingredient),
);
rounded[ingredient] = _applyRoundingForStock(
requiredInStockUnit,
stockUnit,
);
});
return rounded;
}
Future<Map<String, dynamic>> submitProduction() async {
if (selectedRecipe == null || !isCalculated) {
return {'status': 'error', 'message': 'Hitung kebutuhan terlebih dahulu'};
}
await refreshStock();
if (insufficientStock.isNotEmpty) {
return {
'status': 'error',
'message': 'Stok masih kurang, silakan update stok dulu',
};
}
final rounded = roundedUsage;
if (rounded.isEmpty) {
return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'};
}
isSubmitting = true;
notifyListeners();
try {
final items =
rounded.entries.map((entry) {
final ingredient = entry.key;
final quantity = entry.value;
return {
'product_id': productIds[ingredient],
'product_name': ingredient,
'quantity': quantity,
'unit': getStockUnit(ingredient),
};
}).toList();
final result = await MLService.consumeStock(
items: items,
recipeName: selectedRecipe,
productionQuantity: productionQuantity,
);
if (result['status'] == 'success') {
for (final entry in rounded.entries) {
final ingredient = entry.key;
final quantity = entry.value;
final available = currentStock[ingredient] ?? 0;
currentStock[ingredient] = (available - quantity).clamp(
0,
double.infinity,
);
}
}
return result;
} catch (e) {
return {'status': 'error', 'message': 'Gagal submit: $e'};
} finally {
isSubmitting = false;
notifyListeners();
}
}
} }

View File

@ -26,6 +26,21 @@ class _PredictionScreenState extends State<PredictionScreen> {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
} }
Future<void> _submitProduction() async {
final result = await _controller.submitProduction();
if (!mounted) return;
if (result['status'] == 'success') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ Stok berhasil diperbarui')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['message'] ?? 'Gagal submit')),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
@ -368,7 +383,11 @@ class _PredictionScreenState extends State<PredictionScreen> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: onPressed:
_controller.canCalculate _controller.canCalculate
? _controller.calculate ? () async {
await _controller
.refreshStock();
_controller.calculate();
}
: null, : null,
icon: const Icon( icon: const Icon(
Icons.calculate, Icons.calculate,
@ -587,21 +606,29 @@ class _PredictionScreenState extends State<PredictionScreen> {
_controller.getIngredientUnit( _controller.getIngredientUnit(
ingredient, ingredient,
); );
final stockUnit = _controller
.getStockUnit(ingredient);
final quantityPerUnit = final quantityPerUnit =
(details['quantity'] as num) (details['quantity'] as num)
.toInt(); .toDouble();
final neededAmount = final neededAmount =
isSelected isSelected
? quantityPerUnit * ? quantityPerUnit *
_controller _controller
.productionQuantity .productionQuantity
: 0; : 0.0;
final availableAmount = final availableAmount =
_controller _controller
.currentStock[ingredient] ?? .currentStock[ingredient] ??
0; 0.0;
final requiredInStockUnit =
_controller
.getRequiredInStockUnit(
ingredient,
);
final isSufficient = final isSufficient =
availableAmount >= neededAmount; availableAmount >=
requiredInStockUnit;
final statusColor = final statusColor =
isSelected isSelected
? (isSufficient ? (isSufficient
@ -693,7 +720,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
Text( Text(
'$neededAmount $unit', '${_controller.formatQuantity(neededAmount)} $unit',
style: const TextStyle( style: const TextStyle(
fontSize: fontSize:
12, 12,
@ -723,7 +750,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
Text( Text(
'$availableAmount $unit', '${_controller.formatQuantity(availableAmount)} $stockUnit',
style: const TextStyle( style: const TextStyle(
fontSize: fontSize:
12, 12,
@ -799,6 +826,128 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_controller.roundedUsage.isNotEmpty) ...[
const Text(
'Ringkasan Pengurangan Stok',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children:
_controller.roundedUsage.entries.map((
entry,
) {
final ingredient = entry.key;
final amount = entry.value;
final stockUnit = _controller
.getStockUnit(ingredient);
return Padding(
padding:
const EdgeInsets.symmetric(
vertical: 6,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
Expanded(
child: Text(
_controller
.cleanIngredientName(
ingredient,
),
style: const TextStyle(
fontSize: 12,
fontWeight:
FontWeight.w600,
color: Color(
0xFF1F2937,
),
),
),
),
Text(
'-${_controller.formatQuantity(amount)} $stockUnit',
style: const TextStyle(
fontSize: 12,
fontWeight:
FontWeight.w700,
color: Color(0xFFDC2626),
),
),
],
),
);
}).toList(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed:
_controller.isSubmitting
? null
: _submitProduction,
icon:
_controller.isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<
Color
>(Colors.white),
),
)
: const Icon(Icons.check_circle),
label: Text(
_controller.isSubmitting
? 'Memproses...'
: 'Submit Produksi',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(
0xFF10B981,
),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
10,
),
),
),
),
),
const SizedBox(height: 16),
],
if (!_controller.isStockSufficient) ...[ if (!_controller.isStockSufficient) ...[
const Text( const Text(
'Rekomendasi Penambahan Stok', 'Rekomendasi Penambahan Stok',
@ -833,7 +982,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
final ingredient = entry.key; final ingredient = entry.key;
final deficitAmount = entry.value; final deficitAmount = entry.value;
final unit = _controller final unit = _controller
.getIngredientUnit(ingredient); .getStockUnit(ingredient);
return Padding( return Padding(
padding: padding:
@ -877,7 +1026,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
child: Text( child: Text(
'+$deficitAmount $unit', '+${_controller.formatQuantity(deficitAmount)} $unit',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: fontWeight:

View File

@ -5,7 +5,7 @@ class MLService {
// API URL - Change based on environment // API URL - Change based on environment
// Untuk emulator Android: 10.0.2.2 // Untuk emulator Android: 10.0.2.2
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local // Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
static const String baseUrl = 'http://192.168.1.62:5000 '; static const String baseUrl = 'http://192.168.1.62:5000';
static const int timeoutSeconds = 30; static const int timeoutSeconds = 30;
@ -189,6 +189,42 @@ class MLService {
// DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS // DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS
// ======================================================================== // ========================================================================
/// Consume stock (batch) after production
static Future<Map<String, dynamic>> consumeStock({
required List<Map<String, dynamic>> items,
String? recipeName,
int? productionQuantity,
}) async {
try {
final data = {
'items': items,
if (recipeName != null) 'recipe_name': recipeName,
if (productionQuantity != null)
'production_quantity': productionQuantity,
};
final response = await http
.post(
Uri.parse('$baseUrl/stock/consume'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(data),
)
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
return {
'status': 'error',
'message': 'Server error: ${response.statusCode}',
};
}
} catch (e) {
print('Consume stock error: $e');
return {'status': 'error', 'message': 'Connection error: $e'};
}
}
/// Get all products from database /// Get all products from database
static Future<List<Map<String, dynamic>>> getProducts() async { static Future<List<Map<String, dynamic>>> getProducts() async {
try { try {

View File

@ -42,6 +42,23 @@ def get_db_connection():
logger.error(f"Database connection error: {e}") logger.error(f"Database connection error: {e}")
return None return None
def table_exists(connection, table_name: str) -> bool:
try:
cursor = connection.cursor()
cursor.execute("SHOW TABLES LIKE %s", (table_name,))
exists = cursor.fetchone() is not None
cursor.close()
return exists
except Error:
return False
def get_product_name_column(connection) -> str:
cursor = connection.cursor()
cursor.execute("SHOW COLUMNS FROM products LIKE 'name'")
has_name = cursor.fetchone() is not None
cursor.close()
return 'name' if has_name else 'product_name'
# ============================================================================ # ============================================================================
# LOAD MODELS AT STARTUP # LOAD MODELS AT STARTUP
# ============================================================================ # ============================================================================
@ -238,7 +255,8 @@ def info():
'GET /products': 'Get all products', 'GET /products': 'Get all products',
'POST /transactions': 'Save transaction (legacy)', 'POST /transactions': 'Save transaction (legacy)',
'GET /transactions': 'Get transaction history', 'GET /transactions': 'Get transaction history',
'POST /transaksi': 'Save transaction and update stock automatically' 'POST /transaksi': 'Save transaction and update stock automatically',
'POST /stock/consume': 'Consume stock after production'
}, },
'required_features': feature_columns 'required_features': feature_columns
}), 200 }), 200
@ -418,6 +436,147 @@ def create_product():
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/stock/consume', methods=['POST'])
def consume_stock():
"""
Consume stock after production.
Body: {
"recipe_name": "Donat",
"production_quantity": 10,
"items": [
{"product_id": 1, "product_name": "Tepung Terigu 1kg", "quantity": 1, "unit": "kg"}
],
"allow_partial": false
}
"""
try:
data = request.json or {}
items = data.get('items', [])
allow_partial = bool(data.get('allow_partial', False))
if not isinstance(items, list) or not items:
return jsonify({
'status': 'error',
'message': 'Items wajib diisi'
}), 400
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
name_column = get_product_name_column(connection)
history_enabled = table_exists(connection, 'stock_usage_history')
cursor = connection.cursor(dictionary=True)
connection.start_transaction()
errors = []
valid_items = []
for item in items:
product_id = item.get('product_id')
product_name = item.get('product_name')
quantity = item.get('quantity')
if not isinstance(quantity, (int, float)) or quantity <= 0:
errors.append({
'item': item,
'message': 'Quantity harus lebih dari 0'
})
continue
if product_id:
cursor.execute(
f"SELECT id, {name_column} as name, current_stock, unit FROM products WHERE id = %s",
(product_id,)
)
else:
cursor.execute(
f"SELECT id, {name_column} as name, current_stock, unit FROM products WHERE {name_column} = %s",
(product_name,)
)
product = cursor.fetchone()
if not product:
errors.append({
'item': item,
'message': 'Produk tidak ditemukan'
})
continue
if product['current_stock'] < quantity:
errors.append({
'item': item,
'message': 'Stok tidak cukup',
'available': product['current_stock']
})
continue
valid_items.append({
'product': product,
'quantity': quantity,
'unit': item.get('unit') or product.get('unit'),
})
if errors and not allow_partial:
connection.rollback()
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': 'Ada item gagal diproses',
'errors': errors
}), 400
results = []
for entry in valid_items:
product = entry['product']
quantity = entry['quantity']
cursor.execute(
"UPDATE products SET current_stock = current_stock - %s WHERE id = %s",
(quantity, product['id'])
)
if history_enabled:
cursor.execute(
"""
INSERT INTO stock_usage_history
(recipe_name, production_quantity, product_id, product_name, quantity_used, unit)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
data.get('recipe_name'),
data.get('production_quantity'),
product['id'],
product['name'],
quantity,
entry['unit']
)
)
results.append({
'product_id': product['id'],
'product_name': product['name'],
'quantity_used': quantity,
'unit': entry['unit']
})
connection.commit()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'processed': len(results),
'results': results,
'errors': errors if allow_partial else []
}), 200
except Exception as e:
logger.error(f"Consume stock error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/transactions', methods=['POST']) @app.route('/transactions', methods=['POST'])
def save_transaction(): def save_transaction():
""" """

View File

@ -30,6 +30,19 @@ CREATE TABLE IF NOT EXISTS transactions (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Create Stock Usage History Table
CREATE TABLE IF NOT EXISTS stock_usage_history (
id INT AUTO_INCREMENT PRIMARY KEY,
recipe_name VARCHAR(255),
production_quantity INT,
product_id INT NOT NULL,
product_name VARCHAR(255) NOT NULL,
quantity_used FLOAT NOT NULL,
unit VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Create Predictions Table -- Create Predictions Table
CREATE TABLE IF NOT EXISTS predictions ( CREATE TABLE IF NOT EXISTS predictions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,