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) {
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<String, double> 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<Map<String, dynamic>> 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;

View File

@ -27,6 +27,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
}
Future<void> _submitProduction() async {
if (_controller.isSubmitting) return;
final result = await _controller.submitProduction();
if (!mounted) return;
@ -606,8 +607,6 @@ class _PredictionScreenState extends State<PredictionScreen> {
_controller.getIngredientUnit(
ingredient,
);
final stockUnit = _controller
.getStockUnit(ingredient);
final quantityPerUnit =
(details['quantity'] as num)
.toDouble();
@ -617,18 +616,24 @@ class _PredictionScreenState extends State<PredictionScreen> {
_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<PredictionScreen> {
),
),
Text(
'${_controller.formatQuantity(neededAmount)} $unit',
'${_controller.formatQuantity(requiredGram)} gr',
style: const TextStyle(
fontSize:
12,
@ -750,7 +755,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
),
),
Text(
'${_controller.formatQuantity(availableAmount)} $stockUnit',
'${_controller.formatQuantity(stockKg)} kg',
style: const TextStyle(
fontSize:
12,

View File

@ -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<bool> 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 {

View File

@ -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'])