From ac2bb063bde5d396471208a7e6c5541f38dfa600 Mon Sep 17 00:00:00 2001 From: rhanarmt Date: Sat, 2 May 2026 23:37:13 +0700 Subject: [PATCH] Menambah database produk, resep dan menentukan satua pada produksi --- .../prediction/prediction_controller.dart | 84 +++++++++---------- lib/screens/prediction/prediction_page.dart | 19 +++-- lib/services/ml_service.dart | 20 ++++- ml_model/app.py | 66 ++++++++++++++- 4 files changed, 134 insertions(+), 55 deletions(-) diff --git a/lib/screens/prediction/prediction_controller.dart b/lib/screens/prediction/prediction_controller.dart index eb2d1b7..abccdc7 100644 --- a/lib/screens/prediction/prediction_controller.dart +++ b/lib/screens/prediction/prediction_controller.dart @@ -202,7 +202,15 @@ class PredictionController extends ChangeNotifier { String getStockUnit(String ingredient) { final unit = productUnits[ingredient]; if (unit != null && unit.isNotEmpty) return unit; - return getIngredientUnit(ingredient); + return 'kg'; + } + + double toGram({required double amount, required String unit}) { + final normalized = unit.toLowerCase(); + if (normalized == 'gr') return amount; + if (normalized == 'kg') return amount * 1000; + if (normalized == 'butir') return amount * eggGramPerButir; + return amount; } double getRequiredInStockUnit(String ingredient) { @@ -269,23 +277,9 @@ class PredictionController extends ChangeNotifier { 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; + double _roundRequiredGramToKg(double grams) { 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; + return MLService.gramsToKgRounded(grams); } Map get roundedUsage { @@ -294,52 +288,55 @@ class PredictionController extends ChangeNotifier { 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, - ); + final unit = getIngredientUnit(ingredient); + final requiredGram = toGram(amount: neededAmount, unit: unit); + rounded[ingredient] = _roundRequiredGramToKg(requiredGram); }); return rounded; } Future> submitProduction() async { - if (selectedRecipe == null || !isCalculated) { - return {'status': 'error', 'message': 'Hitung kebutuhan terlebih dahulu'}; - } - - await refreshStock(); - if (insufficientStock.isNotEmpty) { + if (isSubmitting) { return { 'status': 'error', - 'message': 'Stok masih kurang, silakan update stok dulu', + 'message': 'Permintaan sedang diproses, mohon tunggu', }; } - final rounded = roundedUsage; - if (rounded.isEmpty) { - return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'}; - } - isSubmitting = true; notifyListeners(); try { + 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'}; + } + final items = - rounded.entries.map((entry) { + rounded.entries.where((entry) => entry.value > 0).map((entry) { final ingredient = entry.key; - final quantity = entry.value; + final quantityKg = entry.value; return { 'product_id': productIds[ingredient], 'product_name': ingredient, - 'quantity': quantity, - 'unit': getStockUnit(ingredient), + 'quantity': quantityKg, + 'unit': 'kg', }; }).toList(); @@ -359,6 +356,7 @@ class PredictionController extends ChangeNotifier { double.infinity, ); } + await refreshStock(); } return result; diff --git a/lib/screens/prediction/prediction_page.dart b/lib/screens/prediction/prediction_page.dart index 26a1229..bff9835 100644 --- a/lib/screens/prediction/prediction_page.dart +++ b/lib/screens/prediction/prediction_page.dart @@ -27,6 +27,7 @@ class _PredictionScreenState extends State { } Future _submitProduction() async { + if (_controller.isSubmitting) return; final result = await _controller.submitProduction(); if (!mounted) return; @@ -606,8 +607,6 @@ class _PredictionScreenState extends State { _controller.getIngredientUnit( ingredient, ); - final stockUnit = _controller - .getStockUnit(ingredient); final quantityPerUnit = (details['quantity'] as num) .toDouble(); @@ -617,18 +616,24 @@ class _PredictionScreenState extends State { _controller .productionQuantity : 0.0; - final availableAmount = + final stockKg = _controller .currentStock[ingredient] ?? 0.0; + final requiredGram = + isSelected + ? _controller.toGram( + amount: neededAmount, + unit: unit, + ) + : 0.0; final requiredInStockUnit = _controller .getRequiredInStockUnit( ingredient, ); final isSufficient = - availableAmount >= - requiredInStockUnit; + stockKg >= requiredInStockUnit; final statusColor = isSelected ? (isSufficient @@ -720,7 +725,7 @@ class _PredictionScreenState extends State { ), ), Text( - '${_controller.formatQuantity(neededAmount)} $unit', + '${_controller.formatQuantity(requiredGram)} gr', style: const TextStyle( fontSize: 12, @@ -750,7 +755,7 @@ class _PredictionScreenState extends State { ), ), Text( - '${_controller.formatQuantity(availableAmount)} $stockUnit', + '${_controller.formatQuantity(stockKg)} kg', style: const TextStyle( fontSize: 12, diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 9efb987..1828702 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -5,10 +5,23 @@ 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.12:5000'; static const int timeoutSeconds = 30; + /// Convert grams to kilograms with rounding rules: + /// - <= 500g -> 0.5kg + /// - 500g < g <= 1000g -> 1kg + /// - > 1000g -> round up to nearest 0.5kg + static double gramsToKgRounded(num grams) { + if (grams <= 0) { + throw ArgumentError('grams must be greater than 0'); + } + if (grams <= 500) return 0.5; + if (grams <= 1000) return 1.0; + return (grams / 500).ceilToDouble() * 0.5; + } + /// Health Check - Test if API is running static Future healthCheck() async { try { @@ -203,6 +216,8 @@ class MLService { 'production_quantity': productionQuantity, }; + print('[consumeStock] Request payload: ${jsonEncode(data)}'); + final response = await http .post( Uri.parse('$baseUrl/stock/consume'), @@ -211,6 +226,9 @@ class MLService { ) .timeout(Duration(seconds: timeoutSeconds)); + print('[consumeStock] Response status: ${response.statusCode}'); + print('[consumeStock] Response body: ${response.body}'); + if (response.statusCode == 200) { return jsonDecode(response.body); } else { diff --git a/ml_model/app.py b/ml_model/app.py index 43ef608..61d6ab2 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -9,6 +9,7 @@ import joblib import pandas as pd import numpy as np import logging +import math from datetime import datetime import mysql.connector from mysql.connector import Error @@ -20,6 +21,9 @@ logger = logging.getLogger(__name__) app = Flask(__name__) CORS(app) +# Prevent overlapping stock consumption transactions +transaction_in_progress = False + # ============================================================================ # DATABASE CONFIGURATION # ============================================================================ @@ -59,6 +63,21 @@ def get_product_name_column(connection) -> str: cursor.close() return 'name' if has_name else 'product_name' +def grams_to_kg_rounded(grams: float) -> float: + """ + Convert grams to kilograms with rounding rules: + - <= 500g -> 0.5kg + - 500g < g <= 1000g -> 1kg + - > 1000g -> round up to nearest 0.5kg + """ + if grams <= 0: + raise ValueError("grams must be greater than 0") + if grams <= 500: + return 0.5 + if grams <= 1000: + return 1.0 + return math.ceil(grams / 500.0) * 0.5 + # ============================================================================ # LOAD MODELS AT STARTUP # ============================================================================ @@ -449,8 +468,17 @@ def consume_stock(): "allow_partial": false } """ + global transaction_in_progress + if transaction_in_progress: + return jsonify({ + 'status': 'error', + 'message': 'Transaction already in progress' + }), 429 + + transaction_in_progress = True try: data = request.json or {} + logger.info(f"[stock/consume] Incoming payload: {data}") items = data.get('items', []) allow_partial = bool(data.get('allow_partial', False)) @@ -477,6 +505,7 @@ def consume_stock(): product_id = item.get('product_id') product_name = item.get('product_name') quantity = item.get('quantity') + unit = (item.get('unit') or '').strip().lower() if not isinstance(quantity, (int, float)) or quantity <= 0: errors.append({ @@ -504,7 +533,29 @@ def consume_stock(): }) continue - if product['current_stock'] < quantity: + product_unit = (product.get('unit') or '').strip().lower() + effective_quantity = quantity + effective_unit = unit or product_unit + + if unit in ['g', 'gram', 'grams']: + try: + effective_quantity = grams_to_kg_rounded(float(quantity)) + effective_unit = 'kg' + logger.info( + f"[stock/consume] Rounded grams to kg: {quantity}g -> {effective_quantity}kg (product_id={product.get('id')})" + ) + except ValueError: + errors.append({ + 'item': item, + 'message': 'Quantity gram harus lebih dari 0' + }) + continue + else: + logger.info( + f"[stock/consume] Using quantity without gram rounding: {quantity} {effective_unit} (product_id={product.get('id')})" + ) + + if product['current_stock'] < effective_quantity: errors.append({ 'item': item, 'message': 'Stok tidak cukup', @@ -514,8 +565,10 @@ def consume_stock(): valid_items.append({ 'product': product, - 'quantity': quantity, - 'unit': item.get('unit') or product.get('unit'), + 'quantity': effective_quantity, + 'unit': effective_unit or product.get('unit'), + 'input_quantity': quantity, + 'input_unit': unit or product.get('unit') }) if errors and not allow_partial: @@ -558,7 +611,10 @@ def consume_stock(): 'product_id': product['id'], 'product_name': product['name'], 'quantity_used': quantity, - 'unit': entry['unit'] + 'deducted_amount': quantity, + 'unit': entry['unit'], + 'quantity_input': entry.get('input_quantity'), + 'unit_input': entry.get('input_unit') }) connection.commit() @@ -575,6 +631,8 @@ def consume_stock(): except Exception as e: logger.error(f"Consume stock error: {str(e)}") return jsonify({'status': 'error', 'message': str(e)}), 500 + finally: + transaction_in_progress = False @app.route('/transactions', methods=['POST'])