fix final1

This commit is contained in:
rhanarmt 2026-05-24 21:52:52 +07:00
parent cc37039155
commit 437f973963
14 changed files with 307 additions and 96 deletions

View File

@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS recipe_ingredients (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
recipe_id INT NOT NULL, recipe_id INT NOT NULL,
product_name VARCHAR(100) NOT NULL, product_name VARCHAR(100) NOT NULL,
quantity_needed INT NOT NULL, quantity_needed FLOAT NOT NULL,
unit VARCHAR(20) NOT NULL, unit VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE,
@ -34,9 +34,9 @@ CREATE TABLE IF NOT EXISTS products (
product_name VARCHAR(100) NOT NULL UNIQUE, product_name VARCHAR(100) NOT NULL UNIQUE,
category VARCHAR(50) NOT NULL, category VARCHAR(50) NOT NULL,
price INT NOT NULL, price INT NOT NULL,
current_stock INT NOT NULL DEFAULT 0, current_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
unit VARCHAR(20) NOT NULL, unit VARCHAR(20) NOT NULL,
min_stock INT DEFAULT 0, min_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_product_name (product_name), KEY idx_product_name (product_name),
KEY idx_category (category) KEY idx_category (category)

View File

@ -3,7 +3,8 @@ class Product {
final String name; final String name;
final String category; final String category;
final int price; final int price;
final int stock; final double stock;
final double minStock;
final String unit; final String unit;
final String status; // 'tersedia', 'sedang', 'kritis' final String status; // 'tersedia', 'sedang', 'kritis'
@ -13,17 +14,27 @@ class Product {
required this.category, required this.category,
required this.price, required this.price,
required this.stock, required this.stock,
this.minStock = 0,
this.unit = 'kg', this.unit = 'kg',
this.status = 'tersedia', this.status = 'tersedia',
}); });
factory Product.fromJson(Map<String, dynamic> json) { factory Product.fromJson(Map<String, dynamic> json) {
final stockRaw = json['stock'] ?? json['current_stock'] ?? 0;
final minStockRaw = json['min_stock'] ?? json['stok_minimum'] ?? 0;
return Product( return Product(
id: json['id'] as int, id: json['id'] as int,
name: json['name'] as String, name: json['name'] as String,
category: json['category'] as String, category: json['category'] as String,
price: json['price'] as int, price: json['price'] as int,
stock: json['stock'] as int, stock:
stockRaw is num
? stockRaw.toDouble()
: double.tryParse(stockRaw.toString()) ?? 0,
minStock:
minStockRaw is num
? minStockRaw.toDouble()
: double.tryParse(minStockRaw.toString()) ?? 0,
unit: (json['unit'] as String?) ?? 'kg', unit: (json['unit'] as String?) ?? 'kg',
status: json['status'] as String? ?? 'tersedia', status: json['status'] as String? ?? 'tersedia',
); );
@ -36,6 +47,7 @@ class Product {
'category': category, 'category': category,
'price': price, 'price': price,
'stock': stock, 'stock': stock,
'min_stock': minStock,
'unit': unit, 'unit': unit,
'status': status, 'status': status,
}; };
@ -46,7 +58,8 @@ class Product {
String? name, String? name,
String? category, String? category,
int? price, int? price,
int? stock, double? stock,
double? minStock,
String? unit, String? unit,
String? status, String? status,
}) { }) {
@ -56,6 +69,7 @@ class Product {
category: category ?? this.category, category: category ?? this.category,
price: price ?? this.price, price: price ?? this.price,
stock: stock ?? this.stock, stock: stock ?? this.stock,
minStock: minStock ?? this.minStock,
unit: unit ?? this.unit, unit: unit ?? this.unit,
status: status ?? this.status, status: status ?? this.status,
); );

View File

@ -109,7 +109,11 @@ class DashboardController extends ChangeNotifier {
if (item is! Map) continue; if (item is! Map) continue;
final stockValue = StockStatusUtils.parseStock(item['current_stock']); final stockValue = StockStatusUtils.parseStock(item['current_stock']);
final statusKey = StockStatusUtils.statusFromStock(stockValue); final minStock = StockStatusUtils.parseStock(item['min_stock']);
final statusKey = StockStatusUtils.statusFromStock(
stockValue,
minStock: minStock,
);
if (statusKey != StockStatusUtils.statusKritis) continue; if (statusKey != StockStatusUtils.statusKritis) continue;
final category = item['category']?.toString().toLowerCase() ?? ''; final category = item['category']?.toString().toLowerCase() ?? '';

View File

@ -88,13 +88,12 @@ class PredictionController extends ChangeNotifier {
(product['name'] ?? product['product_name'] ?? '').toString(); (product['name'] ?? product['product_name'] ?? '').toString();
if (name.isEmpty) continue; if (name.isEmpty) continue;
final id = product['id'] ?? 0; final id = _toInt(product['id']);
final unit = (product['unit'] ?? '').toString(); final unit = (product['unit'] ?? '').toString();
final stockRaw = product['current_stock'] ?? 0; final stock = _toDouble(product['current_stock'] ?? product['stock']);
final stock = stockRaw is num ? stockRaw.toDouble() : 0.0;
currentStock[name] = stock; currentStock[name] = stock;
if (id is int) { if (id != null) {
productIds[name] = id; productIds[name] = id;
} }
if (unit.isNotEmpty) { if (unit.isNotEmpty) {
@ -162,7 +161,7 @@ class PredictionController extends ChangeNotifier {
ingredients.forEach((productName, details) { ingredients.forEach((productName, details) {
if (!isIngredientSelected(productName)) return; if (!isIngredientSelected(productName)) return;
final quantity = (details['quantity'] as num).toDouble(); final quantity = _toDouble(details['quantity']);
required[productName] = quantity * productionQuantity; required[productName] = quantity * productionQuantity;
}); });
@ -294,6 +293,17 @@ class PredictionController extends ChangeNotifier {
return normalized; return normalized;
} }
double _toDouble(dynamic value) {
if (value is num) return value.toDouble();
return double.tryParse(value?.toString() ?? '') ?? 0;
}
int? _toInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
double _convertToStockUnit({ double _convertToStockUnit({
required String ingredient, required String ingredient,
required double amount, required double amount,

View File

@ -19,7 +19,8 @@ class ProductListController extends ChangeNotifier {
products = products =
fetchedProducts.map((p) { fetchedProducts.map((p) {
final stock = p['current_stock'] ?? 0; final stock = StockStatusUtils.parseStock(p['current_stock']);
final minStock = StockStatusUtils.parseStock(p['min_stock']);
final category = p['category'] ?? ''; final category = p['category'] ?? '';
final unit = final unit =
p['unit'] ?? p['unit'] ??
@ -30,8 +31,12 @@ class ProductListController extends ChangeNotifier {
category: category, category: category,
price: p['price'] ?? 0, price: p['price'] ?? 0,
stock: stock, stock: stock,
minStock: minStock,
unit: unit, unit: unit,
status: StockStatusUtils.statusFromStock(stock), status: StockStatusUtils.statusFromStock(
stock,
minStock: minStock,
),
); );
}).toList(); }).toList();
} finally { } finally {
@ -74,8 +79,16 @@ class ProductListController extends ChangeNotifier {
} }
int get maxStock { int get maxStock {
return maxStockValue.ceil().clamp(1, double.infinity).toInt();
}
double get maxStockValue {
if (products.isEmpty) return 1; if (products.isEmpty) return 1;
return products.fold<int>(0, (max, p) => p.stock > max ? p.stock : max); final maxValue = products.fold<double>(
0,
(max, p) => p.stock > max ? p.stock : max,
);
return maxValue <= 0 ? 1 : maxValue;
} }
Color getStatusColor(String status) { Color getStatusColor(String status) {
@ -107,6 +120,14 @@ class ProductListController extends ChangeNotifier {
String formatPrice(int price) => 'Rp ${(price ~/ 1000)}K'; String formatPrice(int price) => 'Rp ${(price ~/ 1000)}K';
String formatStock(double value) {
if (value % 1 == 0) return value.toInt().toString();
return value
.toStringAsFixed(3)
.replaceAll(RegExp(r'0+$'), '')
.replaceAll(RegExp(r'\.$'), '');
}
String getStatusLabel(String status) { String getStatusLabel(String status) {
return StockStatusUtils.label(status, withIcon: true); return StockStatusUtils.label(status, withIcon: true);
} }

View File

@ -360,7 +360,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
} }
Widget _buildProductCard(Product product) { Widget _buildProductCard(Product product) {
final maxStock = _controller.maxStock; final maxStock = _controller.maxStockValue;
final stockPercentage = (product.stock / maxStock * 100).toInt(); final stockPercentage = (product.stock / maxStock * 100).toInt();
return InkWell( return InkWell(
@ -488,7 +488,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${product.stock} ${product.unit}', '${_controller.formatStock(product.stock)} ${product.unit}',
style: AppTextStyles.labelLarge.copyWith( style: AppTextStyles.labelLarge.copyWith(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@ -560,7 +560,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
} }
void _showProductDetail(Product product) { void _showProductDetail(Product product) {
final maxStock = _controller.maxStock; final maxStock = _controller.maxStockValue;
final stockPercentage = final stockPercentage =
maxStock == 0 ? 0 : (product.stock / maxStock * 100).round(); maxStock == 0 ? 0 : (product.stock / maxStock * 100).round();
final statusColor = _controller.getStatusColor(product.status); final statusColor = _controller.getStatusColor(product.status);
@ -672,7 +672,14 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
_buildDetailTile( _buildDetailTile(
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
label: 'Stok', label: 'Stok',
value: '${product.stock} ${product.unit}', value:
'${_controller.formatStock(product.stock)} ${product.unit}',
),
_buildDetailTile(
icon: Icons.low_priority_outlined,
label: 'Minimum',
value:
'${_controller.formatStock(product.minStock)} ${product.unit}',
), ),
_buildDetailTile( _buildDetailTile(
icon: Icons.straighten_outlined, icon: Icons.straighten_outlined,

View File

@ -176,7 +176,11 @@ class ReportController extends ChangeNotifier {
if (item is! Map) continue; if (item is! Map) continue;
final stock = StockStatusUtils.parseStock(item['current_stock']); final stock = StockStatusUtils.parseStock(item['current_stock']);
final status = StockStatusUtils.statusFromStock(stock); final minStock = StockStatusUtils.parseStock(item['min_stock']);
final status = StockStatusUtils.statusFromStock(
stock,
minStock: minStock,
);
if (status != StockStatusUtils.statusKritis) continue; if (status != StockStatusUtils.statusKritis) continue;
final category = item['category']?.toString().toLowerCase() ?? ''; final category = item['category']?.toString().toLowerCase() ?? '';

View File

@ -371,11 +371,14 @@ class _ReportScreenState extends State<ReportScreen> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = sortedItems[index]; final item = sortedItems[index];
final stockValue = StockStatusUtils.parseStock(item['stock']); final stockValue = StockStatusUtils.parseStock(item['stock']);
final statusKey = StockStatusUtils.statusFromStock(stockValue); final minimumStock = StockStatusUtils.parseStock(item['min_stock']);
final statusKey = StockStatusUtils.statusFromStock(
stockValue,
minStock: minimumStock,
);
final statusColor = StockStatusUtils.color(statusKey); final statusColor = StockStatusUtils.color(statusKey);
final statusLabel = StockStatusUtils.label(statusKey); final statusLabel = StockStatusUtils.label(statusKey);
final unit = item['unit']?.toString() ?? 'kg'; final unit = item['unit']?.toString() ?? 'kg';
final minimumStock = StockStatusUtils.parseStock(item['min_stock']);
return _buildReportItemCard( return _buildReportItemCard(
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
@ -1468,7 +1471,13 @@ class _ReportScreenState extends State<ReportScreen> {
...stockItems.asMap().entries.map((entry) { ...stockItems.asMap().entries.map((entry) {
final item = entry.value; final item = entry.value;
final stockValue = StockStatusUtils.parseStock(item['stock']); final stockValue = StockStatusUtils.parseStock(item['stock']);
final statusKey = StockStatusUtils.statusFromStock(stockValue); final minimumStock = StockStatusUtils.parseStock(
item['min_stock'],
);
final statusKey = StockStatusUtils.statusFromStock(
stockValue,
minStock: minimumStock,
);
return [ return [
'${entry.key + 1}', '${entry.key + 1}',
item['name']?.toString() ?? '-', item['name']?.toString() ?? '-',
@ -1620,7 +1629,11 @@ class _ReportScreenState extends State<ReportScreen> {
for (var index = 0; index < stockItems.length; index++) { for (var index = 0; index < stockItems.length; index++) {
final item = stockItems[index]; final item = stockItems[index];
final stockValue = StockStatusUtils.parseStock(item['stock']); final stockValue = StockStatusUtils.parseStock(item['stock']);
final statusKey = StockStatusUtils.statusFromStock(stockValue); final minimumStock = StockStatusUtils.parseStock(item['min_stock']);
final statusKey = StockStatusUtils.statusFromStock(
stockValue,
minStock: minimumStock,
);
final statusLabel = StockStatusUtils.label(statusKey); final statusLabel = StockStatusUtils.label(statusKey);
buffer.writeln( buffer.writeln(
'${index + 1},${_escapeCsv(item['name'])},' '${index + 1},${_escapeCsv(item['name'])},'
@ -1709,8 +1722,16 @@ class _ReportScreenState extends State<ReportScreen> {
return items.toList()..sort((a, b) { return items.toList()..sort((a, b) {
final aStock = StockStatusUtils.parseStock(a['stock']); final aStock = StockStatusUtils.parseStock(a['stock']);
final bStock = StockStatusUtils.parseStock(b['stock']); final bStock = StockStatusUtils.parseStock(b['stock']);
final aStatus = StockStatusUtils.statusFromStock(aStock); final aMinStock = StockStatusUtils.parseStock(a['min_stock']);
final bStatus = StockStatusUtils.statusFromStock(bStock); final bMinStock = StockStatusUtils.parseStock(b['min_stock']);
final aStatus = StockStatusUtils.statusFromStock(
aStock,
minStock: aMinStock,
);
final bStatus = StockStatusUtils.statusFromStock(
bStock,
minStock: bMinStock,
);
final statusComparison = _statusOrder( final statusComparison = _statusOrder(
aStatus, aStatus,
).compareTo(_statusOrder(bStatus)); ).compareTo(_statusOrder(bStatus));

View File

@ -209,6 +209,7 @@ class TransactionController extends ChangeNotifier {
required String category, required String category,
required int price, required int price,
required int initialStock, required int initialStock,
required double minStock,
required String unit, required String unit,
required String productType, required String productType,
}) async { }) async {
@ -221,6 +222,7 @@ class TransactionController extends ChangeNotifier {
category: category, category: category,
price: price, price: price,
currentStock: initialStock, currentStock: initialStock,
minStock: minStock,
unit: unit, unit: unit,
productType: productType, productType: productType,
); );

View File

@ -92,6 +92,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
final newProductNameController = TextEditingController(); final newProductNameController = TextEditingController();
final newPriceController = TextEditingController(); final newPriceController = TextEditingController();
final newStockController = TextEditingController(); final newStockController = TextEditingController();
final newMinStockController = TextEditingController();
const unitOptionsByType = { const unitOptionsByType = {
'Bahan': ['kg'], 'Bahan': ['kg'],
'Barang': ['pcs'], 'Barang': ['pcs'],
@ -268,6 +269,30 @@ class _TransactionScreenState extends State<TransactionScreen> {
fillColor: AppColors.bgLight, fillColor: AppColors.bgLight,
), ),
), ),
const SizedBox(height: 16),
Text(
'Stok Minimum',
style: AppTextStyles.labelLarge.copyWith(
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: newMinStockController,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
hintText:
'Batas minimum stok untuk peringatan',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
filled: true,
fillColor: AppColors.bgLight,
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
Row( Row(
children: [ children: [
@ -308,11 +333,16 @@ class _TransactionScreenState extends State<TransactionScreen> {
newPriceController.text.trim(); newPriceController.text.trim();
final stockText = final stockText =
newStockController.text.trim(); newStockController.text.trim();
final minStockText =
newMinStockController.text
.trim()
.replaceAll(',', '.');
if (productName.isEmpty || if (productName.isEmpty ||
category.isEmpty || category.isEmpty ||
priceText.isEmpty || priceText.isEmpty ||
stockText.isEmpty) { stockText.isEmpty ||
minStockText.isEmpty) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar( ).showSnackBar(
@ -361,12 +391,37 @@ class _TransactionScreenState extends State<TransactionScreen> {
return; return;
} }
final minStock =
double.tryParse(minStockText) ??
-1;
if (minStock < 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: const Text(
'Stok minimum tidak boleh negatif',
),
backgroundColor:
AppColors.statusError,
),
);
return;
}
final navigator = Navigator.of(
context,
);
final messenger =
ScaffoldMessenger.of(context);
final result = await _controller final result = await _controller
.createProduct( .createProduct(
productName: productName, productName: productName,
category: category, category: category,
price: priceInt, price: priceInt,
initialStock: stockInt, initialStock: stockInt,
minStock: minStock,
unit: selectedUnit, unit: selectedUnit,
productType: productType:
selectedProductType, selectedProductType,
@ -375,10 +430,8 @@ class _TransactionScreenState extends State<TransactionScreen> {
if (!mounted) return; if (!mounted) return;
if (result['status'] == 'success') { if (result['status'] == 'success') {
Navigator.pop(context); navigator.pop();
ScaffoldMessenger.of( messenger.showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Produk "$productName" berhasil ditambahkan', 'Produk "$productName" berhasil ditambahkan',
@ -388,9 +441,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
), ),
); );
} else { } else {
ScaffoldMessenger.of( messenger.showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
result['message'] ?? result['message'] ??
@ -724,7 +775,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
height: 32, height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.statusError color: AppColors.statusError
.withOpacity(0.1), .withValues(alpha: 0.1),
borderRadius: borderRadius:
BorderRadius.circular(6), BorderRadius.circular(6),
), ),

View File

@ -6,7 +6,7 @@ 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.16:5000'; static const String baseUrl = 'http://192.168.1.2:5000';
static const int timeoutSeconds = 30; static const int timeoutSeconds = 30;
@ -501,6 +501,7 @@ class MLService {
required String category, required String category,
required int price, required int price,
required int currentStock, required int currentStock,
double? minStock,
String? unit, String? unit,
String? productType, String? productType,
}) async { }) async {
@ -510,6 +511,7 @@ class MLService {
'category': category, 'category': category,
'price': price, 'price': price,
'current_stock': currentStock, 'current_stock': currentStock,
if (minStock != null) 'min_stock': minStock,
if (unit != null) 'unit': unit, if (unit != null) 'unit': unit,
if (productType != null) 'product_type': productType, if (productType != null) 'product_type': productType,
}; };

View File

@ -16,8 +16,15 @@ class StockStatusUtils {
return double.tryParse(value?.toString() ?? '') ?? 0.0; return double.tryParse(value?.toString() ?? '') ?? 0.0;
} }
static String statusFromStock(num stockKg) { static String statusFromStock(num stockKg, {num? minStock}) {
final stock = stockKg.toDouble(); final stock = stockKg.toDouble();
final minimum = minStock?.toDouble() ?? 0;
if (minimum > 0) {
if (stock <= minimum) return statusKritis;
if (stock <= minimum * 2) return statusSedang;
return statusTersedia;
}
if (stock < criticalStockLimitKg) return statusKritis; if (stock < criticalStockLimitKg) return statusKritis;
if (stock < warningStockLimitKg) return statusSedang; if (stock < warningStockLimitKg) return statusSedang;
return statusTersedia; return statusTersedia;

View File

@ -108,6 +108,87 @@ def has_product_unit_column(connection) -> bool:
cursor.close() cursor.close()
return has_unit return has_unit
def ensure_product_stock_precision(connection):
if not table_exists(connection, 'products'):
return
stock_col = get_existing_column(connection, 'products', ['current_stock'])
if not stock_col:
return
cursor = connection.cursor(dictionary=True)
try:
cursor.execute("SHOW COLUMNS FROM products LIKE 'current_stock'")
column = cursor.fetchone()
column_type = (column or {}).get('Type', '').lower()
if any(kind in column_type for kind in ['decimal', 'float', 'double']):
return
cursor.execute(
"ALTER TABLE products MODIFY current_stock DECIMAL(10,3) NOT NULL DEFAULT 0"
)
connection.commit()
except Error as e:
logger.warning(f"Could not update current_stock precision: {e}")
finally:
cursor.close()
def ensure_product_min_stock_column(connection):
if not table_exists(connection, 'products'):
return
min_col = get_existing_column(connection, 'products', ['min_stock'])
if min_col:
return
cursor = connection.cursor()
try:
cursor.execute(
"ALTER TABLE products ADD COLUMN min_stock DECIMAL(10,3) NOT NULL DEFAULT 0"
)
connection.commit()
except Error as e:
logger.warning(f"Could not add min_stock column: {e}")
finally:
cursor.close()
def backfill_default_min_stock(connection):
if not table_exists(connection, 'products'):
return
min_col = get_existing_column(connection, 'products', ['min_stock'])
if not min_col:
return
name_col = get_product_name_column(connection)
default_minimums = {
'Tepung Terigu 1kg': 10,
'Telur 1kg': 5,
'Gula Pasir 1kg': 8,
'Susu Bubuk': 4,
'Cokelat Bubuk 250gr': 3,
'Mentega 500gr': 5,
'Keju Parut 250gr': 2,
'Baking Powder': 2,
}
cursor = connection.cursor()
try:
for product_name, min_stock in default_minimums.items():
cursor.execute(
f"""
UPDATE products
SET {min_col} = %s
WHERE {name_col} = %s AND COALESCE({min_col}, 0) = 0
""",
(min_stock, product_name)
)
connection.commit()
except Error as e:
logger.warning(f"Could not backfill min_stock defaults: {e}")
finally:
cursor.close()
def escape_table_name(table_name: str) -> str: def escape_table_name(table_name: str) -> str:
return f"`{table_name}`" return f"`{table_name}`"
@ -253,6 +334,11 @@ def fetch_stock_report(connection):
if not table_name: if not table_name:
return None, 'Tabel bahan atau products tidak ditemukan' return None, 'Tabel bahan atau products tidak ditemukan'
if table_name == 'products':
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
backfill_default_min_stock(connection)
name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name']) name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name'])
stock_col = get_existing_column(connection, table_name, ['stok', 'current_stock', 'stock']) stock_col = get_existing_column(connection, table_name, ['stok', 'current_stock', 'stock'])
min_col = get_existing_column(connection, table_name, ['stok_minimum', 'min_stock', 'minimum_stock']) min_col = get_existing_column(connection, table_name, ['stok_minimum', 'min_stock', 'minimum_stock'])
@ -1109,6 +1195,10 @@ def get_products():
if not connection: if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
backfill_default_min_stock(connection)
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM products ORDER BY name") cursor.execute("SELECT * FROM products ORDER BY name")
products = cursor.fetchall() products = cursor.fetchall()
@ -1184,6 +1274,9 @@ def create_product():
if not connection: if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
cursor = connection.cursor() cursor = connection.cursor()
# Optional columns compatibility (works for old/new schemas) # Optional columns compatibility (works for old/new schemas)
@ -1191,9 +1284,12 @@ def create_product():
has_unit_column = cursor.fetchone() is not None has_unit_column = cursor.fetchone() is not None
cursor.execute("SHOW COLUMNS FROM products LIKE 'product_type'") cursor.execute("SHOW COLUMNS FROM products LIKE 'product_type'")
has_product_type_column = cursor.fetchone() is not None has_product_type_column = cursor.fetchone() is not None
cursor.execute("SHOW COLUMNS FROM products LIKE 'min_stock'")
has_min_stock_column = cursor.fetchone() is not None
unit_value = data.get('unit') unit_value = data.get('unit')
product_type_value = data.get('product_type') product_type_value = data.get('product_type')
min_stock_value = data.get('min_stock', 0)
# Check for duplicate product name # Check for duplicate product name
cursor.execute("SELECT id FROM products WHERE name = %s", (data['name'],)) cursor.execute("SELECT id FROM products WHERE name = %s", (data['name'],))
@ -1205,55 +1301,24 @@ def create_product():
'message': f'Product "{data["name"]}" already exists' 'message': f'Product "{data["name"]}" already exists'
}), 409 }), 409
# Insert new product columns = ['name', 'category', 'price', 'current_stock']
if has_unit_column and has_product_type_column: values = [data['name'], data['category'], data['price'], data['current_stock']]
cursor.execute(""" if has_unit_column:
INSERT INTO products columns.append('unit')
(name, category, price, current_stock, unit, product_type) values.append(unit_value)
VALUES (%s, %s, %s, %s, %s, %s) if has_product_type_column:
""", ( columns.append('product_type')
data['name'], values.append(product_type_value)
data['category'], if has_min_stock_column:
data['price'], columns.append('min_stock')
data['current_stock'], values.append(min_stock_value)
unit_value,
product_type_value column_sql = ', '.join(columns)
)) placeholders = ', '.join(['%s'] * len(columns))
elif has_unit_column: cursor.execute(
cursor.execute(""" f"INSERT INTO products ({column_sql}) VALUES ({placeholders})",
INSERT INTO products tuple(values)
(name, category, price, current_stock, unit) )
VALUES (%s, %s, %s, %s, %s)
""", (
data['name'],
data['category'],
data['price'],
data['current_stock'],
unit_value
))
elif has_product_type_column:
cursor.execute("""
INSERT INTO products
(name, category, price, current_stock, product_type)
VALUES (%s, %s, %s, %s, %s)
""", (
data['name'],
data['category'],
data['price'],
data['current_stock'],
product_type_value
))
else:
cursor.execute("""
INSERT INTO products
(name, category, price, current_stock)
VALUES (%s, %s, %s, %s)
""", (
data['name'],
data['category'],
data['price'],
data['current_stock']
))
connection.commit() connection.commit()
product_id = cursor.lastrowid product_id = cursor.lastrowid
@ -1305,6 +1370,8 @@ def consume_stock():
if not connection: if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_product_stock_precision(connection)
ensure_stock_usage_tables(connection) ensure_stock_usage_tables(connection)
name_column = get_product_name_column(connection) name_column = get_product_name_column(connection)

View File

@ -13,7 +13,8 @@ CREATE TABLE IF NOT EXISTS products (
product_type VARCHAR(20) DEFAULT 'Bahan', product_type VARCHAR(20) DEFAULT 'Bahan',
unit VARCHAR(20) DEFAULT 'kg', unit VARCHAR(20) DEFAULT 'kg',
price INT NOT NULL, price INT NOT NULL,
current_stock INT NOT NULL DEFAULT 0, current_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
min_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -100,15 +101,15 @@ CREATE TABLE IF NOT EXISTS recipe_ingredients (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Insert Initial Products (8 items) -- Insert Initial Products (8 items)
INSERT INTO products (name, category, product_type, unit, price, current_stock) VALUES INSERT INTO products (name, category, product_type, unit, price, current_stock, min_stock) VALUES
('Tepung Terigu 1kg', 'Tepung', 'Bahan', 'kg', 15000, 50), ('Tepung Terigu 1kg', 'Tepung', 'Bahan', 'kg', 15000, 50, 10),
('Telur 1kg', 'Telur', 'Bahan', 'kg', 25000, 30), ('Telur 1kg', 'Telur', 'Bahan', 'kg', 25000, 30, 5),
('Gula Pasir 1kg', 'Gula', 'Bahan', 'kg', 12000, 40), ('Gula Pasir 1kg', 'Gula', 'Bahan', 'kg', 12000, 40, 8),
('Susu Bubuk', 'Susu', 'Bahan', 'kg', 20000, 20), ('Susu Bubuk', 'Susu', 'Bahan', 'kg', 20000, 20, 4),
('Cokelat Bubuk 250gr', 'Cokelat', 'Bahan', 'kg', 18000, 15), ('Cokelat Bubuk 250gr', 'Cokelat', 'Bahan', 'kg', 18000, 15, 3),
('Mentega 500gr', 'Mentega', 'Bahan', 'kg', 22000, 25), ('Mentega 500gr', 'Mentega', 'Bahan', 'kg', 22000, 25, 5),
('Keju Parut 250gr', 'Keju', 'Bahan', 'kg', 28000, 10), ('Keju Parut 250gr', 'Keju', 'Bahan', 'kg', 28000, 10, 2),
('Baking Powder', 'Bahan Tambahan', 'Bahan', 'kg', 8000, 35); ('Baking Powder', 'Bahan Tambahan', 'Bahan', 'kg', 8000, 35, 2);
-- Insert Default Login Account -- Insert Default Login Account
INSERT INTO login (name, email, username, password) VALUES INSERT INTO login (name, email, username, password) VALUES