diff --git a/database/recipes_schema.sql b/database/recipes_schema.sql index 24010cd..9106693 100644 --- a/database/recipes_schema.sql +++ b/database/recipes_schema.sql @@ -42,6 +42,22 @@ CREATE TABLE IF NOT EXISTS products ( 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 -- ===================================================== diff --git a/lib/screens/prediction/prediction_controller.dart b/lib/screens/prediction/prediction_controller.dart index 2889364..eb2d1b7 100644 --- a/lib/screens/prediction/prediction_controller.dart +++ b/lib/screens/prediction/prediction_controller.dart @@ -6,12 +6,13 @@ class PredictionController extends ChangeNotifier { int productionQuantity = 0; bool isCalculated = false; bool isLoading = true; + bool isSubmitting = false; List> recipes = []; Map> recipeIngredients = {}; Map ingredientSelections = {}; - final Map currentStock = { + final Map currentStock = { 'Tepung Terigu 1kg': 45000, 'Telur 1kg': 12, 'Gula Pasir 1kg': 28000, @@ -21,6 +22,10 @@ class PredictionController extends ChangeNotifier { 'Keju Parut 250gr': 3000, 'Baking Powder': 60000, }; + final Map productIds = {}; + final Map productUnits = {}; + + static const double eggGramPerButir = 50; Future loadRecipes() async { isLoading = true; @@ -28,6 +33,7 @@ class PredictionController extends ChangeNotifier { try { final fetchedRecipes = await MLService.getRecipes(); + final fetchedProducts = await MLService.getProducts(); recipes = fetchedRecipes; recipeIngredients = {}; @@ -47,6 +53,7 @@ class PredictionController extends ChangeNotifier { } } + _applyProductStocks(fetchedProducts); _initializeIngredientSelections(); return null; @@ -58,6 +65,44 @@ class PredictionController extends ChangeNotifier { } } + Future refreshStock() async { + try { + final fetchedProducts = await MLService.getProducts(); + _applyProductStocks(fetchedProducts); + } catch (_) { + // Ignore refresh errors; keep existing stock + } finally { + notifyListeners(); + } + } + + void _applyProductStocks(List> 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) { selectedRecipe = value; isCalculated = false; @@ -107,31 +152,36 @@ class PredictionController extends ChangeNotifier { }; } - Map get requiredIngredients { + Map get requiredIngredients { if (selectedRecipe == null || productionQuantity == 0) { return {}; } final ingredients = recipeIngredients[selectedRecipe] ?? {}; - final required = {}; + final required = {}; ingredients.forEach((productName, details) { if (!isIngredientSelected(productName)) return; - final quantity = (details['quantity'] as num).toInt(); + final quantity = (details['quantity'] as num).toDouble(); required[productName] = quantity * productionQuantity; }); return required; } - Map get insufficientStock { + Map get insufficientStock { final required = requiredIngredients; - final insufficient = {}; + final insufficient = {}; required.forEach((ingredient, neededAmount) { + final requiredInStockUnit = _convertToStockUnit( + ingredient: ingredient, + amount: neededAmount, + fromUnit: getIngredientUnit(ingredient), + ); final available = currentStock[ingredient] ?? 0; - if (available < neededAmount) { - insufficient[ingredient] = neededAmount - available; + if (available < requiredInStockUnit) { + insufficient[ingredient] = requiredInStockUnit - available; } }); @@ -149,8 +199,27 @@ class PredictionController extends ChangeNotifier { 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; + 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; return available >= required ? const Color(0xFF10B981) @@ -160,4 +229,144 @@ class PredictionController extends ChangeNotifier { String cleanIngredientName(String ingredient) { 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 get roundedUsage { + final required = requiredIngredients; + final rounded = {}; + + 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> 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(); + } + } } diff --git a/lib/screens/prediction/prediction_page.dart b/lib/screens/prediction/prediction_page.dart index 524ea1f..26a1229 100644 --- a/lib/screens/prediction/prediction_page.dart +++ b/lib/screens/prediction/prediction_page.dart @@ -26,6 +26,21 @@ class _PredictionScreenState extends State { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); } + Future _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 Widget build(BuildContext context) { return AnimatedBuilder( @@ -368,7 +383,11 @@ class _PredictionScreenState extends State { child: ElevatedButton.icon( onPressed: _controller.canCalculate - ? _controller.calculate + ? () async { + await _controller + .refreshStock(); + _controller.calculate(); + } : null, icon: const Icon( Icons.calculate, @@ -587,21 +606,29 @@ class _PredictionScreenState extends State { _controller.getIngredientUnit( ingredient, ); + final stockUnit = _controller + .getStockUnit(ingredient); final quantityPerUnit = (details['quantity'] as num) - .toInt(); + .toDouble(); final neededAmount = isSelected ? quantityPerUnit * _controller .productionQuantity - : 0; + : 0.0; final availableAmount = _controller .currentStock[ingredient] ?? - 0; + 0.0; + final requiredInStockUnit = + _controller + .getRequiredInStockUnit( + ingredient, + ); final isSufficient = - availableAmount >= neededAmount; + availableAmount >= + requiredInStockUnit; final statusColor = isSelected ? (isSufficient @@ -693,7 +720,7 @@ class _PredictionScreenState extends State { ), ), Text( - '$neededAmount $unit', + '${_controller.formatQuantity(neededAmount)} $unit', style: const TextStyle( fontSize: 12, @@ -723,7 +750,7 @@ class _PredictionScreenState extends State { ), ), Text( - '$availableAmount $unit', + '${_controller.formatQuantity(availableAmount)} $stockUnit', style: const TextStyle( fontSize: 12, @@ -799,6 +826,128 @@ class _PredictionScreenState extends State { ), ), 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) ...[ const Text( 'Rekomendasi Penambahan Stok', @@ -833,7 +982,7 @@ class _PredictionScreenState extends State { final ingredient = entry.key; final deficitAmount = entry.value; final unit = _controller - .getIngredientUnit(ingredient); + .getStockUnit(ingredient); return Padding( padding: @@ -877,7 +1026,7 @@ class _PredictionScreenState extends State { ), ), child: Text( - '+$deficitAmount $unit', + '+${_controller.formatQuantity(deficitAmount)} $unit', style: const TextStyle( fontSize: 12, fontWeight: diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index b89f9f1..9efb987 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -5,7 +5,7 @@ class MLService { // API URL - Change based on environment // Untuk emulator Android: 10.0.2.2 // 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; @@ -189,6 +189,42 @@ class MLService { // DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS // ======================================================================== + /// Consume stock (batch) after production + static Future> consumeStock({ + required List> 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 static Future>> getProducts() async { try { diff --git a/ml_model/app.py b/ml_model/app.py index 0628144..43ef608 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -42,6 +42,23 @@ def get_db_connection(): logger.error(f"Database connection error: {e}") 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 # ============================================================================ @@ -238,7 +255,8 @@ def info(): 'GET /products': 'Get all products', 'POST /transactions': 'Save transaction (legacy)', '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 }), 200 @@ -418,6 +436,147 @@ def create_product(): 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']) def save_transaction(): """ diff --git a/ml_model/setup_database.sql b/ml_model/setup_database.sql index 46342bd..4574471 100644 --- a/ml_model/setup_database.sql +++ b/ml_model/setup_database.sql @@ -30,6 +30,19 @@ CREATE TABLE IF NOT EXISTS transactions ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) 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 TABLE IF NOT EXISTS predictions ( id INT AUTO_INCREMENT PRIMARY KEY,