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)
|
||||
);
|
||||
|
||||
-- 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
|
||||
-- =====================================================
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ class PredictionController extends ChangeNotifier {
|
|||
int productionQuantity = 0;
|
||||
bool isCalculated = false;
|
||||
bool isLoading = true;
|
||||
bool isSubmitting = false;
|
||||
|
||||
List<Map<String, dynamic>> recipes = [];
|
||||
Map<String, Map<String, dynamic>> recipeIngredients = {};
|
||||
Map<String, bool> ingredientSelections = {};
|
||||
|
||||
final Map<String, int> currentStock = {
|
||||
final Map<String, double> 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<String, int> productIds = {};
|
||||
final Map<String, String> productUnits = {};
|
||||
|
||||
static const double eggGramPerButir = 50;
|
||||
|
||||
Future<String?> 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<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) {
|
||||
selectedRecipe = value;
|
||||
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) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final ingredients = recipeIngredients[selectedRecipe] ?? {};
|
||||
final required = <String, int>{};
|
||||
final required = <String, double>{};
|
||||
|
||||
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<String, int> get insufficientStock {
|
||||
Map<String, double> get insufficientStock {
|
||||
final required = requiredIngredients;
|
||||
final insufficient = <String, int>{};
|
||||
final insufficient = <String, double>{};
|
||||
|
||||
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<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)));
|
||||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
|
@ -368,7 +383,11 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
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<PredictionScreen> {
|
|||
_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<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'$neededAmount $unit',
|
||||
'${_controller.formatQuantity(neededAmount)} $unit',
|
||||
style: const TextStyle(
|
||||
fontSize:
|
||||
12,
|
||||
|
|
@ -723,7 +750,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'$availableAmount $unit',
|
||||
'${_controller.formatQuantity(availableAmount)} $stockUnit',
|
||||
style: const TextStyle(
|
||||
fontSize:
|
||||
12,
|
||||
|
|
@ -799,6 +826,128 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
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<PredictionScreen> {
|
|||
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<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
child: Text(
|
||||
'+$deficitAmount $unit',
|
||||
'+${_controller.formatQuantity(deficitAmount)} $unit',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
static Future<List<Map<String, dynamic>>> getProducts() async {
|
||||
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}")
|
||||
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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue