Memperbaiki logika alur sistem
This commit is contained in:
parent
e08e9c91e9
commit
343e540332
|
|
@ -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
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
161
ml_model/app.py
161
ml_model/app.py
|
|
@ -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():
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue