Menambah database produk, resep dan menentukan satua pada produksi

This commit is contained in:
rhanarmt 2026-05-02 23:37:13 +07:00
parent 343e540332
commit ac2bb063bd
4 changed files with 134 additions and 55 deletions

View File

@ -202,7 +202,15 @@ class PredictionController extends ChangeNotifier {
String getStockUnit(String ingredient) { String getStockUnit(String ingredient) {
final unit = productUnits[ingredient]; final unit = productUnits[ingredient];
if (unit != null && unit.isNotEmpty) return unit; 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) { double getRequiredInStockUnit(String ingredient) {
@ -269,23 +277,9 @@ class PredictionController extends ChangeNotifier {
return amount; return amount;
} }
double _applyRoundingForStock(double amountInStockUnit, String stockUnit) { double _roundRequiredGramToKg(double grams) {
final unit = stockUnit.toLowerCase();
if (unit != 'kg' && unit != 'gr') return amountInStockUnit;
final grams = unit == 'kg' ? amountInStockUnit * 1000 : amountInStockUnit;
if (grams <= 0) return 0; if (grams <= 0) return 0;
return MLService.gramsToKgRounded(grams);
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 { Map<String, double> get roundedUsage {
@ -294,24 +288,31 @@ class PredictionController extends ChangeNotifier {
required.forEach((ingredient, neededAmount) { required.forEach((ingredient, neededAmount) {
if (!isIngredientSelected(ingredient)) return; if (!isIngredientSelected(ingredient)) return;
final stockUnit = getStockUnit(ingredient); final unit = getIngredientUnit(ingredient);
final requiredInStockUnit = _convertToStockUnit( final requiredGram = toGram(amount: neededAmount, unit: unit);
ingredient: ingredient, rounded[ingredient] = _roundRequiredGramToKg(requiredGram);
amount: neededAmount,
fromUnit: getIngredientUnit(ingredient),
);
rounded[ingredient] = _applyRoundingForStock(
requiredInStockUnit,
stockUnit,
);
}); });
return rounded; return rounded;
} }
Future<Map<String, dynamic>> submitProduction() async { Future<Map<String, dynamic>> submitProduction() async {
if (isSubmitting) {
return {
'status': 'error',
'message': 'Permintaan sedang diproses, mohon tunggu',
};
}
isSubmitting = true;
notifyListeners();
try {
if (selectedRecipe == null || !isCalculated) { if (selectedRecipe == null || !isCalculated) {
return {'status': 'error', 'message': 'Hitung kebutuhan terlebih dahulu'}; return {
'status': 'error',
'message': 'Hitung kebutuhan terlebih dahulu',
};
} }
await refreshStock(); await refreshStock();
@ -327,19 +328,15 @@ class PredictionController extends ChangeNotifier {
return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'}; return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'};
} }
isSubmitting = true;
notifyListeners();
try {
final items = final items =
rounded.entries.map((entry) { rounded.entries.where((entry) => entry.value > 0).map((entry) {
final ingredient = entry.key; final ingredient = entry.key;
final quantity = entry.value; final quantityKg = entry.value;
return { return {
'product_id': productIds[ingredient], 'product_id': productIds[ingredient],
'product_name': ingredient, 'product_name': ingredient,
'quantity': quantity, 'quantity': quantityKg,
'unit': getStockUnit(ingredient), 'unit': 'kg',
}; };
}).toList(); }).toList();
@ -359,6 +356,7 @@ class PredictionController extends ChangeNotifier {
double.infinity, double.infinity,
); );
} }
await refreshStock();
} }
return result; return result;

View File

@ -27,6 +27,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
} }
Future<void> _submitProduction() async { Future<void> _submitProduction() async {
if (_controller.isSubmitting) return;
final result = await _controller.submitProduction(); final result = await _controller.submitProduction();
if (!mounted) return; if (!mounted) return;
@ -606,8 +607,6 @@ 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)
.toDouble(); .toDouble();
@ -617,18 +616,24 @@ class _PredictionScreenState extends State<PredictionScreen> {
_controller _controller
.productionQuantity .productionQuantity
: 0.0; : 0.0;
final availableAmount = final stockKg =
_controller _controller
.currentStock[ingredient] ?? .currentStock[ingredient] ??
0.0; 0.0;
final requiredGram =
isSelected
? _controller.toGram(
amount: neededAmount,
unit: unit,
)
: 0.0;
final requiredInStockUnit = final requiredInStockUnit =
_controller _controller
.getRequiredInStockUnit( .getRequiredInStockUnit(
ingredient, ingredient,
); );
final isSufficient = final isSufficient =
availableAmount >= stockKg >= requiredInStockUnit;
requiredInStockUnit;
final statusColor = final statusColor =
isSelected isSelected
? (isSufficient ? (isSufficient
@ -720,7 +725,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
Text( Text(
'${_controller.formatQuantity(neededAmount)} $unit', '${_controller.formatQuantity(requiredGram)} gr',
style: const TextStyle( style: const TextStyle(
fontSize: fontSize:
12, 12,
@ -750,7 +755,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
Text( Text(
'${_controller.formatQuantity(availableAmount)} $stockUnit', '${_controller.formatQuantity(stockKg)} kg',
style: const TextStyle( style: const TextStyle(
fontSize: fontSize:
12, 12,

View File

@ -5,10 +5,23 @@ 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.12:5000';
static const int timeoutSeconds = 30; 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 /// Health Check - Test if API is running
static Future<bool> healthCheck() async { static Future<bool> healthCheck() async {
try { try {
@ -203,6 +216,8 @@ class MLService {
'production_quantity': productionQuantity, 'production_quantity': productionQuantity,
}; };
print('[consumeStock] Request payload: ${jsonEncode(data)}');
final response = await http final response = await http
.post( .post(
Uri.parse('$baseUrl/stock/consume'), Uri.parse('$baseUrl/stock/consume'),
@ -211,6 +226,9 @@ class MLService {
) )
.timeout(Duration(seconds: timeoutSeconds)); .timeout(Duration(seconds: timeoutSeconds));
print('[consumeStock] Response status: ${response.statusCode}');
print('[consumeStock] Response body: ${response.body}');
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {

View File

@ -9,6 +9,7 @@ import joblib
import pandas as pd import pandas as pd
import numpy as np import numpy as np
import logging import logging
import math
from datetime import datetime from datetime import datetime
import mysql.connector import mysql.connector
from mysql.connector import Error from mysql.connector import Error
@ -20,6 +21,9 @@ logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
# Prevent overlapping stock consumption transactions
transaction_in_progress = False
# ============================================================================ # ============================================================================
# DATABASE CONFIGURATION # DATABASE CONFIGURATION
# ============================================================================ # ============================================================================
@ -59,6 +63,21 @@ def get_product_name_column(connection) -> str:
cursor.close() cursor.close()
return 'name' if has_name else 'product_name' 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 # LOAD MODELS AT STARTUP
# ============================================================================ # ============================================================================
@ -449,8 +468,17 @@ def consume_stock():
"allow_partial": false "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: try:
data = request.json or {} data = request.json or {}
logger.info(f"[stock/consume] Incoming payload: {data}")
items = data.get('items', []) items = data.get('items', [])
allow_partial = bool(data.get('allow_partial', False)) allow_partial = bool(data.get('allow_partial', False))
@ -477,6 +505,7 @@ def consume_stock():
product_id = item.get('product_id') product_id = item.get('product_id')
product_name = item.get('product_name') product_name = item.get('product_name')
quantity = item.get('quantity') quantity = item.get('quantity')
unit = (item.get('unit') or '').strip().lower()
if not isinstance(quantity, (int, float)) or quantity <= 0: if not isinstance(quantity, (int, float)) or quantity <= 0:
errors.append({ errors.append({
@ -504,7 +533,29 @@ def consume_stock():
}) })
continue 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({ errors.append({
'item': item, 'item': item,
'message': 'Stok tidak cukup', 'message': 'Stok tidak cukup',
@ -514,8 +565,10 @@ def consume_stock():
valid_items.append({ valid_items.append({
'product': product, 'product': product,
'quantity': quantity, 'quantity': effective_quantity,
'unit': item.get('unit') or product.get('unit'), 'unit': effective_unit or product.get('unit'),
'input_quantity': quantity,
'input_unit': unit or product.get('unit')
}) })
if errors and not allow_partial: if errors and not allow_partial:
@ -558,7 +611,10 @@ def consume_stock():
'product_id': product['id'], 'product_id': product['id'],
'product_name': product['name'], 'product_name': product['name'],
'quantity_used': quantity, '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() connection.commit()
@ -575,6 +631,8 @@ def consume_stock():
except Exception as e: except Exception as e:
logger.error(f"Consume stock error: {str(e)}") logger.error(f"Consume stock error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
transaction_in_progress = False
@app.route('/transactions', methods=['POST']) @app.route('/transactions', methods=['POST'])