Update frontend and backend predictions

This commit is contained in:
rhanarmt 2026-06-21 13:49:31 +07:00
parent 9b55f02ade
commit 8d81d30278
22 changed files with 1681 additions and 206 deletions

167
Subbab_4_4_Testing.docx Normal file
View File

@ -0,0 +1,167 @@
4.4 Testing
Testing atau pengujian sistem dilakukan untuk memastikan bahwa aplikasi mobile prediksi permintaan stok bahan kue pada Tobaku Sulastri dapat berjalan sesuai dengan kebutuhan pengguna. Pengujian ini bertujuan untuk mengetahui apakah setiap fitur yang telah dibuat dapat digunakan dengan baik, mulai dari proses login, pengelolaan data produk, pencatatan stok masuk, hingga proses prediksi kebutuhan stok bahan kue.
Metode pengujian yang digunakan dalam penelitian ini adalah Black Box Testing, yaitu metode pengujian yang berfokus pada fungsi aplikasi tanpa melihat struktur kode program. Pengujian dilakukan dengan cara menjalankan setiap fitur aplikasi berdasarkan skenario tertentu, kemudian membandingkan hasil yang muncul dengan hasil yang diharapkan.
4.4.1 Pengujian Black Box
Pengujian Black Box dilakukan kepada dua aktor pengguna, yaitu teknisi dan pemilik. Teknisi berperan sebagai pengguna yang melakukan pengelolaan data produk, stok masuk, serta proses prediksi permintaan stok. Sedangkan pemilik berperan sebagai pengguna yang memantau informasi stok, hasil prediksi, dan laporan yang tersedia pada aplikasi.
A. Pengujian Black Box Aktor Teknisi
Berdasarkan hasil pengujian yang dilakukan oleh aktor teknisi, seluruh fitur utama aplikasi dapat berjalan dengan baik. Pengujian dilakukan pada beberapa bagian, yaitu autentikasi pengguna, dashboard dan navigasi, data produk dan stok masuk, serta prediksi permintaan stok.
Tabel 4.1 Pengujian Black Box Aktor Teknisi
No
Fitur
Skenario Pengujian
Hasil yang Diharapkan
Status
A. Autentikasi Pengguna
1
Login
Memasukkan email/username dan password yang valid.
Sistem memvalidasi akun dan mengarahkan pengguna ke halaman dashboard.
Berhasil
2
Login
Memasukkan email/username atau password yang tidak sesuai.
Sistem menampilkan pesan bahwa email/username atau password salah dan tetap berada pada halaman login.
Berhasil
3
Lupa Password
Menekan tombol Lupa Password, mengisi email akun, lalu menekan Kirim OTP.
Sistem mengirimkan kode OTP ke email pengguna yang terdaftar.
Berhasil
4
Reset Password
Memasukkan OTP yang valid, password baru, dan konfirmasi password baru.
Sistem memperbarui password dan pengguna dapat login menggunakan password baru.
Berhasil
B. Dashboard dan Navigasi
5
Splash Screen
Membuka aplikasi Tobaku Sulastri.
Sistem menampilkan splash screen, logo aplikasi, nama aplikasi, dan mengarahkan pengguna sesuai status login.
Berhasil
6
Dashboard
Mengakses halaman dashboard setelah login.
Sistem menampilkan ringkasan data stok, produk, dan informasi operasional utama.
Berhasil
7
Navigasi Menu
Menekan menu Produk, Stok Masuk, dan Prediksi pada navigation bar.
Sistem menampilkan halaman yang sesuai dengan menu yang dipilih.
Berhasil
8
Status Koneksi API
Membuka halaman yang memuat data dari server ketika API aktif.
Sistem berhasil mengambil data dari API dan menampilkannya pada halaman aplikasi.
Berhasil
C. Data Produk dan Stok Masuk
9
Data Produk
Mengakses menu Produk.
Sistem menampilkan daftar produk beserta kategori, harga, stok, satuan, dan status stok.
Berhasil
10
Pencarian Produk
Memasukkan kata kunci pada kolom pencarian produk.
Sistem memfilter daftar produk sesuai nama atau kategori yang dicari.
Berhasil
11
Filter Status Stok
Memilih filter Semua, Tersedia, Sedang, atau Kritis.
Sistem menampilkan produk sesuai status stok yang dipilih.
Berhasil
12
Stok Masuk
Mengisi data produk, satuan, jumlah, dan harga lalu menyimpan stok masuk.
Sistem menyimpan transaksi stok masuk dan memperbarui jumlah stok produk pada database.
Berhasil
D. Prediksi Permintaan Stok
13
Form Prediksi
Mengakses menu Prediksi dan mengisi input prediksi sesuai kebutuhan.
Sistem menampilkan form prediksi dan memvalidasi input yang wajib diisi.
Berhasil
14
Proses Prediksi
Menekan tombol prediksi setelah input lengkap.
Sistem memproses prediksi menggunakan model Random Forest dan menampilkan hasil kebutuhan stok bahan kue.
Berhasil
15
Simpan Hasil Prediksi
Menyimpan hasil prediksi yang telah ditampilkan.
Sistem menyimpan hasil prediksi ke database sehingga data prediksi dapat digunakan sebagai bahan evaluasi.
Berhasil
Berdasarkan pengujian tersebut, dapat disimpulkan bahwa fitur-fitur yang digunakan oleh teknisi telah berjalan sesuai dengan hasil yang diharapkan.
B. Pengujian Black Box Aktor Pemilik
Pengujian Black Box pada aktor pemilik dilakukan untuk memastikan bahwa pemilik dapat menggunakan aplikasi dalam memantau kondisi stok dan melihat hasil prediksi permintaan stok bahan kue. Pengujian ini berfokus pada fitur yang berkaitan dengan pemantauan data, laporan, dan hasil prediksi.
Tabel 4.2 Pengujian Black Box Aktor Pemilik
No
Fitur
Skenario Pengujian
Hasil yang Diharapkan
Status
1
Login
Pemilik memasukkan email/username dan password yang valid.
Sistem memvalidasi akun dan mengarahkan pemilik ke halaman dashboard.
Berhasil
2
Login
Pemilik memasukkan email/username atau password yang tidak sesuai.
Sistem menampilkan pesan kesalahan dan tetap berada pada halaman login.
Berhasil
3
Dashboard
Pemilik mengakses dashboard setelah login.
Sistem menampilkan ringkasan data stok, jumlah produk, dan informasi utama aplikasi.
Berhasil
4
Data Produk
Pemilik membuka menu Produk.
Sistem menampilkan daftar produk beserta kategori, satuan, harga, stok, dan status stok.
Berhasil
5
Pencarian Produk
Pemilik memasukkan kata kunci pada kolom pencarian produk.
Sistem menampilkan produk sesuai kata kunci yang dicari.
Berhasil
6
Filter Status Stok
Pemilik memilih filter stok seperti tersedia, sedang, atau kritis.
Sistem menampilkan produk berdasarkan status stok yang dipilih.
Berhasil
7
Stok Masuk
Pemilik membuka data stok masuk.
Sistem menampilkan riwayat stok masuk yang telah dicatat oleh teknisi.
Berhasil
8
Hasil Prediksi
Pemilik membuka menu hasil prediksi.
Sistem menampilkan hasil prediksi permintaan stok bahan kue.
Berhasil
9
Detail Prediksi
Pemilik melihat detail hasil prediksi.
Sistem menampilkan informasi hasil prediksi secara lebih lengkap.
Berhasil
10
Laporan
Pemilik membuka menu laporan atau riwayat prediksi.
Sistem menampilkan data laporan yang dapat digunakan sebagai bahan evaluasi stok.
Berhasil
11
Logout
Pemilik menekan tombol logout.
Sistem mengakhiri sesi pengguna dan kembali ke halaman login.
Berhasil
Berdasarkan hasil pengujian pada aktor pemilik, aplikasi telah mampu menampilkan informasi yang dibutuhkan oleh pemilik secara baik. Pemilik dapat melihat data produk, memantau kondisi stok, melihat hasil prediksi, serta menggunakan informasi tersebut sebagai dasar dalam pengambilan keputusan terkait kebutuhan stok bahan kue.
4.4.2 Evaluasi Kelebihan Aplikasi
Berdasarkan hasil pengujian yang telah dilakukan, aplikasi mobile prediksi permintaan stok bahan kue Tobaku Sulastri memiliki beberapa kelebihan sebagai berikut:
- Mempermudah pengelolaan stok. Aplikasi membantu pengguna dalam mencatat dan memantau stok bahan kue secara lebih terstruktur. Data produk, jumlah stok, satuan, harga, dan status stok dapat ditampilkan dalam satu sistem sehingga memudahkan teknisi maupun pemilik dalam melihat kondisi persediaan.
- Menyediakan fitur prediksi kebutuhan stok. Aplikasi dilengkapi dengan fitur prediksi permintaan stok menggunakan model Random Forest. Fitur ini dapat membantu memperkirakan kebutuhan stok bahan kue berdasarkan data yang tersedia, sehingga pemilik dapat mengambil keputusan lebih cepat dalam melakukan penambahan stok.
- Mengurangi risiko kekurangan stok. Dengan adanya informasi status stok dan hasil prediksi, aplikasi dapat membantu pengguna mengetahui produk yang berada dalam kondisi kritis atau perlu segera ditambah. Hal ini dapat mengurangi risiko kekurangan stok bahan kue pada saat dibutuhkan.
- Memiliki tampilan yang mudah digunakan. Aplikasi dirancang dengan tampilan mobile yang sederhana dan mudah dipahami. Menu-menu utama seperti dashboard, produk, stok masuk, dan prediksi dapat diakses melalui navigasi yang jelas sehingga pengguna tidak mengalami kesulitan dalam menggunakan aplikasi.
- Mendukung dua jenis pengguna. Aplikasi mendukung penggunaan oleh teknisi dan pemilik dengan kebutuhan yang berbeda. Teknisi dapat mengelola data produk, stok masuk, dan prediksi, sedangkan pemilik dapat memantau data stok serta melihat hasil prediksi sebagai bahan evaluasi.
- Data tersimpan ke database. Setiap data penting seperti produk, stok masuk, dan hasil prediksi dapat tersimpan ke database. Hal ini membuat data lebih aman, mudah diakses kembali, dan dapat digunakan untuk evaluasi di kemudian hari.
- Membantu pengambilan keputusan. Hasil prediksi dan informasi stok yang ditampilkan aplikasi dapat menjadi dasar bagi pemilik dalam menentukan jumlah pembelian bahan kue. Dengan demikian, proses pengambilan keputusan tidak hanya berdasarkan perkiraan manual, tetapi juga didukung oleh data dari sistem.
Secara keseluruhan, hasil pengujian menunjukkan bahwa aplikasi telah berjalan sesuai dengan fungsi yang dirancang. Aplikasi ini dapat membantu proses pengelolaan stok bahan kue menjadi lebih efektif, terstruktur, dan mendukung proses prediksi kebutuhan stok pada Tobaku Sulastri.

View File

@ -0,0 +1,30 @@
KEMENTERIAN PENDIDIKAN TINGGI, SAINS, DAN TEKNOLOGI Penilaian Bimbingan
POLITEKNIK NEGERI JEMBER
08/06/26, 13.25
Kode Dokumen : FR-JUR-059
Revisi :0
Nama Mahasiswa PENILAIAN BIMBINGAN
NIM
Program Studi : Roihan Jaya Arianto
: E31232435
: Manajemen Informatika
No Unsur Penilaian Unsur Penilaian
30 % x ...........= ................
1 Kemampuan menggali permasalahan dan kreatifitas 40 % x ...........= ................
30 % x ...........= ................
2 Kemampuan berkerja sesuai target dan penyusunan laporan
3 Etika akademik dan sopan santun
Jumlah
Jember, ..........................................
Dosen Pembimbing 1,
Ir Hendra Yufit Riskiawan S.Kom., M.Cs.
NIP. 198302032006041003
https://sim-online.polije.ac.id/akademik/ta/bimbingan/sidang/cetak/nilai-bimbingan/1# 1/1

View File

@ -0,0 +1,33 @@
KEMENTERIAN PENDIDIKAN TINGGI, SAINS, DAN TEKNOLOGI Rekapitulasi Nilai Ujian Tugas Akhir
POLITEKNIK NEGERI JEMBER
08/06/26, 13.27
Kode Dokumen : FR-JUR-036
Revisi :0
Nama Mahasiswa REKAPITULASI NILAI UJIAN TUGAS AKHIR
NIM
Program Studi : Roihan Jaya Arianto
Judul : E31232435
: Manajemen Informatika
: Rancang Bangun Aplikasi Mobile Untuk Prediksi Permintaan Stok Bahan Kue
Menggunakan Random Forest dan Linear Regression Sebagai Model pembanding
Pada Toko Bahan Kue Sulastri
No Unsur Penilaian Nilai
1 Nilai Seminar 25 % x ...........= ................
2 Nilai Bimbingan 40 % x ...........= ................
3 Nilai Ujian 35 % x ...........= ................
Jumlah
Jember, Rabu/10-06-2026
Komisi Penguji Ketua,
Taufiq Rizaldi S.ST., M.T.
NIP. 198903292019031007
https://sim-online.polije.ac.id/akademik/ta/pendaftaran/sidang/cetak/rekap-nilai/1# 1/1

View File

@ -386,15 +386,17 @@ class PredictionController extends ChangeNotifier {
.replaceAll(RegExp(r'\.$'), '');
}
String formatQuantityWithUnit(double amount, String unit) {
final displayUnit = _displayUnitLabel(unit);
return '${formatQuantity(amount)} $displayUnit';
}
String formatStockQuantity(String ingredient, double value) {
final unit = getStockUnit(ingredient);
if (unit == 'kg' && value > 0 && value < 1) {
return '${formatQuantity(value * 1000)} gr';
}
if (unit == 'l' && value > 0 && value < 1) {
return '${formatQuantity(value * 1000)} ml';
}
return '${formatQuantity(value)} $unit';
return formatQuantityWithUnit(value, getStockUnit(ingredient));
}
String formatRequiredQuantity(String ingredient, double value) {
return formatQuantityWithUnit(value, getIngredientUnit(ingredient));
}
String _normalizeUnit(String unit) {
@ -411,6 +413,16 @@ class PredictionController extends ChangeNotifier {
return normalized;
}
String _displayUnitLabel(String unit) {
final normalized = _normalizeUnit(unit);
if (normalized == 'gr') return 'kg';
if (normalized == 'ml') return 'L';
if (normalized == 'l') return 'L';
if (normalized == 'kg') return 'kg';
if (normalized == 'butir') return 'butir';
return unit.trim().isEmpty ? 'unit' : unit.trim();
}
double _toDouble(dynamic value) {
if (value is num) return value.toDouble();
return double.tryParse(value?.toString() ?? '') ?? 0;

View File

@ -886,12 +886,6 @@ class _PredictionScreenState extends State<PredictionScreen> {
.isIngredientSelected(
ingredient,
);
final unit =
(details['unit']
as String?) ??
_controller.getIngredientUnit(
ingredient,
);
final quantityPerUnit =
(details['quantity'] as num)
.toDouble();
@ -901,22 +895,18 @@ class _PredictionScreenState extends State<PredictionScreen> {
_controller
.productionQuantity
: 0.0;
final stockKg = _controller
.getCurrentStock(ingredient);
final requiredGram =
isSelected
? _controller.toGram(
amount: neededAmount,
unit: unit,
)
: 0.0;
final currentStockValue =
_controller
.getCurrentStock(
ingredient,
);
final requiredInStockUnit =
_controller
.getRequiredInStockUnit(
ingredient,
);
final isSufficient =
stockKg >=
currentStockValue >=
requiredInStockUnit;
final statusColor =
isSelected
@ -1012,7 +1002,12 @@ class _PredictionScreenState extends State<PredictionScreen> {
),
),
Text(
'${_controller.formatQuantity(requiredGram)} gr',
isSelected
? _controller.formatRequiredQuantity(
ingredient,
neededAmount,
)
: '-',
style: const TextStyle(
fontSize:
12,
@ -1044,7 +1039,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
Text(
_controller.formatStockQuantity(
ingredient,
stockKg,
currentStockValue,
),
style: const TextStyle(
fontSize:

View File

@ -143,15 +143,45 @@ class ProductListController extends ChangeNotifier {
.replaceAll(RegExp(r'\.$'), '');
}
String formatStockDisplay(double value, String rawUnit) {
return formatQuantityWithUnit(value, rawUnit);
}
String minimumStockUnit(Product product) {
final unit = product.unit.toLowerCase();
if (product.category.toLowerCase() == 'barang' || unit == 'pcs') {
return _displayUnitLabel(product.unit);
}
String displayUnit(Product product) {
return _displayUnitLabel(product.unit);
}
String formatQuantityWithUnit(double amount, String rawUnit) {
final displayUnit = _displayUnitLabel(rawUnit);
return '${formatStock(amount)} $displayUnit';
}
String _displayUnitLabel(String unit) {
final normalized = _normalizeUnit(unit);
if (normalized == 'pcs' || normalized == 'butir') return 'pcs';
if (normalized == 'gr') return 'kg';
if (normalized == 'ml') return 'L';
if (normalized == 'l') return 'L';
if (normalized == 'kg') return 'kg';
return unit.trim().isEmpty ? 'kg' : unit.trim();
}
String _normalizeUnit(String unit) {
final normalized = unit.trim().toLowerCase();
if (['g', 'gr', 'gram', 'grams'].contains(normalized)) return 'gr';
if (['kg', 'kilogram', 'kilograms'].contains(normalized)) return 'kg';
if (['ml', 'mili', 'mililiter', 'milliliter'].contains(normalized)) {
return 'ml';
}
if (['l', 'lt', 'ltr', 'liter', 'litre'].contains(normalized)) return 'l';
if (['butir', 'pcs', 'piece', 'pieces'].contains(normalized)) {
return 'pcs';
}
if (unit == 'ml' || unit == 'l') {
return 'L';
}
return 'kg';
return normalized;
}
Future<String?> updateProduct({

View File

@ -511,7 +511,10 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
),
const SizedBox(height: 4),
Text(
'${_controller.formatStock(product.stock)} ${product.unit}',
_controller.formatStockDisplay(
product.stock,
product.unit,
),
style: AppTextStyles.labelLarge.copyWith(
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
@ -696,18 +699,24 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
icon: Icons.inventory_2_outlined,
label: 'Stok',
value:
'${_controller.formatStock(product.stock)} ${product.unit}',
_controller.formatStockDisplay(
product.stock,
product.unit,
),
),
_buildDetailTile(
icon: Icons.low_priority_outlined,
label: 'Minimum',
value:
'${_controller.formatStock(product.minStock)} ${_controller.minimumStockUnit(product)}',
_controller.formatStockDisplay(
product.minStock,
product.unit,
),
),
_buildDetailTile(
icon: Icons.straighten_outlined,
label: 'Satuan',
value: product.unit,
value: _controller.displayUnit(product),
),
_buildDetailTile(
icon: Icons.speed_outlined,

View File

@ -7,11 +7,13 @@ import 'package:flutter/material.dart';
class ReportController extends ChangeNotifier {
bool isLoading = true;
bool isForecastLoading = false;
String? errorMessage;
final List<Map<String, dynamic>> stockItems = [];
final List<Map<String, dynamic>> stockHistory = [];
final List<Map<String, dynamic>> predictionItems = [];
final List<Map<String, dynamic>> forecastItems = [];
final List<Map<String, dynamic>> criticalItems = [];
final List<Map<String, dynamic>> usageSummary = [];
final List<double> demandTrend = [];
@ -22,6 +24,8 @@ class ReportController extends ChangeNotifier {
int totalPrediksi = 0;
int totalKritis = 0;
int forecastDays = 7;
int _productCount = 0;
Timer? _refreshTimer;
@ -97,6 +101,7 @@ class ReportController extends ChangeNotifier {
_rebuildSummary();
_rebuildDemandTrend();
await loadForecast(showLoading: false);
} catch (e) {
errorMessage = 'Gagal memuat laporan: $e';
} finally {
@ -105,6 +110,39 @@ class ReportController extends ChangeNotifier {
}
}
Future<void> setForecastDays(int days) async {
if (forecastDays == days) return;
forecastDays = days;
await loadForecast();
}
Future<void> loadForecast({bool showLoading = true}) async {
if (showLoading) {
isForecastLoading = true;
notifyListeners();
}
try {
final forecastResponse = await MLService.getReportPredictionForecast(
days: forecastDays,
months: 1,
limit: 8,
);
if (forecastResponse['status'] != true) {
forecastItems.clear();
} else {
_applyForecasts(forecastResponse['data']);
}
} catch (_) {
forecastItems.clear();
} finally {
isForecastLoading = false;
notifyListeners();
}
}
/// Refresh otomatis agar laporan selalu realtime.
void startAutoRefresh() {
_refreshTimer?.cancel();
@ -169,6 +207,50 @@ class ReportController extends ChangeNotifier {
}
}
void _applyForecasts(dynamic data) {
forecastItems.clear();
if (data is! List) return;
for (final item in data) {
if (item is! Map) continue;
final ingredientsData = item['bahan'];
final recipeTargetsData = item['target_produksi'];
final ingredients = <Map<String, dynamic>>[];
final recipeTargets = <Map<String, dynamic>>[];
if (ingredientsData is List) {
for (final ingredient in ingredientsData) {
if (ingredient is! Map) continue;
ingredients.add({
'name': ingredient['nama_bahan']?.toString() ?? '-',
'quantity': _toDouble(ingredient['jumlah']),
'unit': ingredient['unit']?.toString() ?? 'kg',
});
}
}
if (recipeTargetsData is List) {
for (final target in recipeTargetsData) {
if (target is! Map) continue;
recipeTargets.add({
'recipe_name': target['recipe_name']?.toString() ?? '-',
'production': _toDouble(target['target_produksi']),
});
}
}
forecastItems.add({
'date': _parseDate(item['tanggal_prediksi']),
'multiplier': _toDouble(item['multiplier']),
'ingredients': ingredients,
'recipe_targets': recipeTargets,
'ingredient_count': _toDouble(item['jumlah_bahan']),
'total_bahan': _toDouble(item['total_bahan']),
});
}
}
void _applyCriticalProducts(List<dynamic> products) {
criticalItems.clear();

View File

@ -195,6 +195,20 @@ class _ReportScreenState extends State<ReportScreen> {
const SizedBox(height: 12),
_buildPredictionList(_controller.predictionItems),
const SizedBox(height: 24),
_buildSectionTitle(
'Prediksi Kebutuhan Bahan Kedepan',
icon: Icons.event_available_rounded,
subtitle:
'Prediksi kebutuhan bahan untuk beberapa hari ke depan.',
),
const SizedBox(height: 12),
_buildForecastControls(),
const SizedBox(height: 12),
_buildForecastList(
_controller.forecastItems,
isLoading: _controller.isForecastLoading,
),
const SizedBox(height: 24),
_buildSectionTitle('Grafik Penggunaan Bahan'),
const SizedBox(height: 12),
_buildCharts(usageBars, usagePie, demandTrend),
@ -522,6 +536,174 @@ class _ReportScreenState extends State<ReportScreen> {
);
}
Widget _buildForecastControls() {
const dayOptions = [3, 7, 14, 30];
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.bgWhite,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.grey200),
),
child: Row(
children: [
Icon(
Icons.calendar_month_rounded,
size: 20,
color: AppColors.primaryBrown,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Jumlah hari ke depan',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w700,
),
),
),
DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _controller.forecastDays,
borderRadius: BorderRadius.circular(12),
items:
dayOptions.map((day) {
return DropdownMenuItem<int>(
value: day,
child: Text('$day hari'),
);
}).toList(),
onChanged:
_controller.isLoading
? null
: (value) {
if (value == null) return;
_controller.setForecastDays(value);
},
),
),
],
),
);
}
Widget _buildForecastList(
List<Map<String, dynamic>> items, {
required bool isLoading,
}) {
if (isLoading) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
decoration: BoxDecoration(
color: AppColors.bgWhite,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.grey200),
),
child: Row(
children: [
const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.4),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Memuat prediksi kedepan...',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
),
],
),
);
}
if (items.isEmpty) {
return _buildReportEmptyState(
icon: Icons.event_available_rounded,
message: 'Belum ada data prediksi kebutuhan bahan kedepan.',
);
}
return Column(
children:
items.take(8).map((item) {
final ingredients =
(item['ingredients'] as List?)?.cast<Map<String, dynamic>>() ??
<Map<String, dynamic>>[];
final recipeTargets =
(item['recipe_targets'] as List?)?.cast<Map<String, dynamic>>() ??
<Map<String, dynamic>>[];
final date = item['date'] as DateTime;
final ingredientCount = StockStatusUtils.parseStock(
item['ingredient_count'],
);
final ingredientText = ingredients
.take(3)
.map((entry) {
final quantity = StockStatusUtils.parseStock(
entry['quantity'],
);
return '${entry['name']}: ${_formatQuantity(quantity)} ${entry['unit'] ?? 'kg'}';
})
.join(' | ');
final recipeText = recipeTargets
.take(2)
.map((entry) {
final production = StockStatusUtils.parseStock(
entry['production'],
);
return '${entry['recipe_name']}: ${_formatQuantity(production)}';
})
.join(' | ');
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _buildReportItemCard(
icon: Icons.event_available_rounded,
iconColor: AppColors.statusSuccess,
title: _dateFormat.format(date),
subtitle: 'Total kebutuhan ${_controller.forecastDays} hari',
trailing: Text(
'${_formatQuantity(ingredientCount)} bahan',
style: AppTextStyles.labelLarge.copyWith(
color: AppColors.statusSuccess,
fontWeight: FontWeight.w800,
),
),
footer: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipeText.isEmpty ? '-' : recipeText,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
height: 1.35,
),
),
const SizedBox(height: 6),
Text(
ingredientText.isEmpty ? '-' : ingredientText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textTertiary,
height: 1.35,
),
),
],
),
),
);
}).toList(),
);
}
double _reportListHeight(int itemCount, {required double itemHeight}) {
final contentHeight = (itemCount * itemHeight) + ((itemCount - 1) * 10);
final maxHeight = _sectionMaxHeight();
@ -1417,7 +1599,8 @@ class _ReportScreenState extends State<ReportScreen> {
if (_controller.stockItems.isEmpty &&
_controller.stockHistory.isEmpty &&
_controller.predictionItems.isEmpty) {
_controller.predictionItems.isEmpty &&
_controller.forecastItems.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Data laporan masih kosong.')),
@ -1532,9 +1715,13 @@ class _ReportScreenState extends State<ReportScreen> {
_controller.predictionItems.where((entry) {
return _isDateInRange(entry['date'] as DateTime, startDate, endDate);
}).toList();
final forecastItems = _controller.forecastItems;
final forecastDays = _controller.forecastDays;
final stockItems = _sortedStockItems(_controller.stockItems);
final stockSummary = _summarizeStockHistory(stockHistory);
final predictionSummary = _summarizePredictions(predictionItems);
final forecastSummary = _summarizeForecasts(forecastItems);
final forecastRows = _flattenForecastRows(forecastItems);
final document = pw.Document();
document.addPage(
@ -1574,6 +1761,7 @@ class _ReportScreenState extends State<ReportScreen> {
['Total Bahan', _controller.totalBahan.toString()],
['Stok Kritis', _controller.totalKritis.toString()],
['Total Prediksi Periode', predictionItems.length.toString()],
['Total Forecast Bahan', forecastRows.length.toString()],
[
'Total Transaksi Stok Masuk Periode',
stockHistory.length.toString(),
@ -1667,6 +1855,39 @@ class _ReportScreenState extends State<ReportScreen> {
];
}),
]),
_pdfSectionTitle('Rekap Prediksi Kebutuhan Bahan Kedepan'),
_pdfTable([
['No', 'Nama Bahan', 'Total $forecastDays Hari', 'Unit'],
if (forecastSummary.isEmpty)
['-', 'Tidak ada data forecast', '-', '-']
else
...forecastSummary.asMap().entries.map((entry) {
final item = entry.value;
return [
'${entry.key + 1}',
item['name']?.toString() ?? '-',
_formatQuantity(item['quantity']),
item['unit']?.toString() ?? 'kg',
];
}),
]),
_pdfSectionTitle('Detail Prediksi Kebutuhan Bahan Kedepan'),
_pdfTable([
['No', 'Tanggal', 'Nama Bahan', 'Kebutuhan', 'Unit'],
if (forecastRows.isEmpty)
['-', 'Tidak ada data forecast', '-', '-', '-']
else
...forecastRows.asMap().entries.map((entry) {
final item = entry.value;
return [
'${entry.key + 1}',
_dateFormat.format(item['date'] as DateTime),
item['name']?.toString() ?? '-',
_formatQuantity(item['quantity']),
item['unit']?.toString() ?? 'kg',
];
}),
]),
];
},
),
@ -1711,6 +1932,10 @@ class _ReportScreenState extends State<ReportScreen> {
_controller.predictionItems.where((entry) {
return _isDateInRange(entry['date'] as DateTime, startDate, endDate);
}).toList();
final forecastItems = _controller.forecastItems;
final forecastDays = _controller.forecastDays;
final forecastSummary = _summarizeForecasts(forecastItems);
final forecastRows = _flattenForecastRows(forecastItems);
final buffer =
StringBuffer()
@ -1734,6 +1959,7 @@ class _ReportScreenState extends State<ReportScreen> {
..writeln('Total Bahan,${_controller.totalBahan}')
..writeln('Stok Kritis,${_controller.totalKritis}')
..writeln('Total Prediksi Periode,${predictionItems.length}')
..writeln('Total Forecast Bahan,${forecastRows.length}')
..writeln('Total Transaksi Stok Masuk Periode,${stockHistory.length}')
..writeln('');
@ -1821,6 +2047,37 @@ class _ReportScreenState extends State<ReportScreen> {
}
buffer.writeln('');
_writeSection(buffer, 'Rekap Prediksi Kebutuhan Bahan Kedepan');
buffer.writeln('No,Nama Bahan,Total $forecastDays Hari,Unit');
if (forecastSummary.isEmpty) {
buffer.writeln('-,Tidak ada data forecast,-,-');
}
for (var index = 0; index < forecastSummary.length; index++) {
final item = forecastSummary[index];
buffer.writeln(
'${index + 1},${_escapeCsv(item['name'])},'
'${_formatQuantity(item['quantity'])},'
'${_escapeCsv(item['unit'])}',
);
}
buffer.writeln('');
_writeSection(buffer, 'Detail Prediksi Kebutuhan Bahan Kedepan');
buffer.writeln('No,Tanggal,Nama Bahan,Kebutuhan,Unit');
if (forecastRows.isEmpty) {
buffer.writeln('-,Tidak ada data forecast,-,-,-');
}
for (var index = 0; index < forecastRows.length; index++) {
final item = forecastRows[index];
buffer.writeln(
'${index + 1},${_dateFormat.format(item['date'] as DateTime)},'
'${_escapeCsv(item['name'])},'
'${_formatQuantity(item['quantity'])},'
'${_escapeCsv(item['unit'])}',
);
}
buffer.writeln('');
return buffer.toString();
}
@ -1935,6 +2192,78 @@ class _ReportScreenState extends State<ReportScreen> {
);
}
List<Map<String, dynamic>> _summarizeForecasts(
List<Map<String, dynamic>> items,
) {
final summary = <String, Map<String, dynamic>>{};
for (final item in items) {
final ingredients =
(item['ingredients'] as List?)?.cast<Map<String, dynamic>>() ??
<Map<String, dynamic>>[];
for (final ingredient in ingredients) {
final name = ingredient['name']?.toString() ?? '-';
final unit = ingredient['unit']?.toString() ?? 'kg';
final key = '$name|$unit';
final quantity = StockStatusUtils.parseStock(ingredient['quantity']);
final current = summary[key];
if (current == null) {
summary[key] = {
'name': name,
'unit': unit,
'quantity': quantity,
};
} else {
current['quantity'] = (current['quantity'] as double) + quantity;
}
}
}
final values = summary.values.toList();
values.sort((a, b) {
final nameComparison = a['name'].toString().compareTo(b['name'].toString());
if (nameComparison != 0) return nameComparison;
return a['unit'].toString().compareTo(b['unit'].toString());
});
return values;
}
List<Map<String, dynamic>> _flattenForecastRows(
List<Map<String, dynamic>> items,
) {
final rows = <Map<String, dynamic>>[];
for (final item in items) {
final date = item['date'] as DateTime;
final ingredients =
(item['ingredients'] as List?)?.cast<Map<String, dynamic>>() ??
<Map<String, dynamic>>[];
for (final ingredient in ingredients) {
rows.add({
'date': date,
'name': ingredient['name']?.toString() ?? '-',
'quantity': StockStatusUtils.parseStock(ingredient['quantity']),
'unit': ingredient['unit']?.toString() ?? 'kg',
});
}
}
rows.sort((a, b) {
final aDate = a['date'] as DateTime;
final bDate = b['date'] as DateTime;
final dateComparison = aDate.compareTo(bDate);
if (dateComparison != 0) return dateComparison;
final nameComparison = a['name'].toString().compareTo(b['name'].toString());
if (nameComparison != 0) return nameComparison;
return a['unit'].toString().compareTo(b['unit'].toString());
});
return rows;
}
String _escapeCsv(dynamic value) {
final text = value?.toString() ?? '';
if (text.contains(',') || text.contains('"') || text.contains('\n')) {

View File

@ -3,12 +3,33 @@ import 'dart:async';
import 'dart:convert';
class MLService {
// API URL local Flask backend.
static const String baseUrl = 'http://192.168.1.67:5000';
//https://web-production-c3c06.up.railway.app/api
// API URL Railway Flask backend.
static const String baseUrl = 'http://127.0.0.1:5000';
//https://web-production-c3c06.up.railway.app
static const int timeoutSeconds = 30;
static dynamic _tryDecodeJsonBody(String body) {
final trimmed = body.trimLeft();
if (trimmed.isEmpty) return null;
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return null;
}
try {
return jsonDecode(body);
} catch (_) {
return null;
}
}
static Map<String, dynamic>? _decodeJsonMap(String body) {
final decoded = _tryDecodeJsonBody(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
return null;
}
/// Login using account data from MySQL login table.
static Future<Map<String, dynamic>> login({
required String usernameOrEmail,
@ -27,7 +48,14 @@ class MLService {
)
.timeout(Duration(seconds: timeoutSeconds));
final body = jsonDecode(response.body) as Map<String, dynamic>;
final body = _decodeJsonMap(response.body);
if (body == null) {
return {
'status': 'error',
'message':
'Server mengembalikan respons bukan JSON. Cek baseUrl API.',
};
}
if (response.statusCode == 200) {
return body;
}
@ -107,7 +135,14 @@ class MLService {
)
.timeout(Duration(seconds: timeoutSeconds));
final body = jsonDecode(response.body) as Map<String, dynamic>;
final body = _decodeJsonMap(response.body);
if (body == null) {
return {
'status': 'error',
'message':
'Server mengembalikan respons bukan JSON. Cek baseUrl API.',
};
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return body;
}
@ -161,7 +196,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{'status': 'error', 'message': 'Respons metadata tidak valid'};
} else {
return {'status': 'error', 'message': 'Failed to get metadata'};
}
@ -196,7 +232,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{'status': 'error', 'message': 'Respons prediksi tidak valid'};
}
return {
@ -244,7 +281,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{'status': 'error', 'message': 'Respons prediksi tidak valid'};
} else {
return {
'status': 'error',
@ -272,7 +310,11 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{
'status': 'error',
'message': 'Respons batch prediksi tidak valid',
};
} else {
return {
'status': 'error',
@ -292,7 +334,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{'status': 'error', 'message': 'Respons info tidak valid'};
} else {
return {'status': 'error', 'message': 'Failed to get info'};
}
@ -313,7 +356,12 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{
'status': false,
'message': 'Respons dashboard tidak valid',
'penggunaan_bahan': [],
};
}
return {
@ -344,7 +392,7 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final data = _decodeJsonMap(response.body);
return data is Map<String, dynamic> ? data : fallback;
}
@ -370,7 +418,12 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{
'status': false,
'message': 'Respons laporan tidak valid',
'data': [],
};
}
print('Report request failed [$path]: ${response.statusCode}');
@ -400,6 +453,32 @@ class MLService {
return _getReport('/laporan/prediksi');
}
/// Laporan prediksi hari/tanggal dan bulan ke depan.
static Future<Map<String, dynamic>> getReportPredictionForecast({
int days = 7,
int months = 3,
int limit = 8,
DateTime? startDate,
String? productName,
}) async {
final queryParameters = <String, String>{
'days': days.toString(),
'months': months.toString(),
'limit': limit.toString(),
if (startDate != null)
'start_date': startDate.toIso8601String().split('T').first,
if (productName != null && productName.trim().isNotEmpty)
'product_name': productName.trim(),
};
final path =
Uri(
path: '/laporan/prediksi-forecast',
queryParameters: queryParameters,
).toString();
return _getReport(path);
}
/// Laporan bahan kritis
static Future<Map<String, dynamic>> getReportCritical() async {
return _getReport('/laporan/bahan-kritis');
@ -436,8 +515,7 @@ class MLService {
print('[consumeStock] Response status: ${response.statusCode}');
print('[consumeStock] Response body: ${response.body}');
final parsedBody =
response.body.isNotEmpty ? jsonDecode(response.body) : null;
final parsedBody = _decodeJsonMap(response.body);
if (response.statusCode == 200) {
return parsedBody is Map<String, dynamic>
@ -467,11 +545,20 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success') {
return List<Map<String, dynamic>>.from(data['products']);
final data = _decodeJsonMap(response.body);
if (data != null && data['status'] == 'success') {
final products = data['products'];
if (products is List) {
return products
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
}
}
print(
'Get products unexpected response: ${response.statusCode} ${response.body}',
);
return [];
} catch (e) {
print('Get products error: $e');
@ -509,15 +596,16 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 201) {
return jsonDecode(response.body);
return _decodeJsonMap(response.body) ??
{'status': 'success', 'message': 'Produk berhasil ditambahkan'};
} else if (response.statusCode == 409) {
final result = jsonDecode(response.body);
return result;
return _decodeJsonMap(response.body) ??
{'status': 'error', 'message': 'Produk sudah ada'};
} else if (response.statusCode == 400) {
final result = jsonDecode(response.body);
final result = _decodeJsonMap(response.body);
return {
'status': 'error',
'message': result['message'] ?? 'Bad request',
'message': result?['message'] ?? 'Bad request',
};
} else {
print('Create product error - Status: ${response.statusCode}');
@ -541,8 +629,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success') {
final data = _decodeJsonMap(response.body);
if (data != null && data['status'] == 'success') {
return data['product'];
}
}
@ -581,10 +669,7 @@ class MLService {
)
.timeout(Duration(seconds: timeoutSeconds));
final body =
response.body.isNotEmpty
? jsonDecode(response.body) as Map<String, dynamic>
: <String, dynamic>{};
final body = _decodeJsonMap(response.body) ?? <String, dynamic>{};
if (response.statusCode >= 200 && response.statusCode < 300) {
return body;
@ -626,8 +711,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 201) {
final result = jsonDecode(response.body);
return result;
final result = _decodeJsonMap(response.body);
return result ?? {'status': 'success'};
} else {
return {
'status': 'error',
@ -668,8 +753,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 201) {
final result = jsonDecode(response.body);
return result['status'] == 'success';
final result = _decodeJsonMap(response.body);
return result?['status'] == 'success';
}
return false;
} catch (e) {
@ -695,9 +780,15 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success') {
return List<Map<String, dynamic>>.from(data['transactions']);
final data = _decodeJsonMap(response.body);
if (data != null && data['status'] == 'success') {
final transactions = data['transactions'];
if (transactions is List) {
return transactions
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
}
}
return [];
@ -744,8 +835,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 201) {
final result = jsonDecode(response.body);
return result['status'] == 'success';
final result = _decodeJsonMap(response.body);
return result?['status'] == 'success';
}
return false;
} catch (e) {
@ -766,9 +857,15 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success') {
return List<Map<String, dynamic>>.from(data['recipes']);
final data = _decodeJsonMap(response.body);
if (data != null && data['status'] == 'success') {
final recipes = data['recipes'];
if (recipes is List) {
return recipes
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
}
}
return [];
@ -786,8 +883,8 @@ class MLService {
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success') {
final data = _decodeJsonMap(response.body);
if (data != null && data['status'] == 'success') {
return data['recipe'];
}
}

View File

@ -148,6 +148,21 @@ def has_product_unit_column(connection) -> bool:
cursor.close()
return has_unit
def ensure_product_unit_column(connection):
if not table_exists(connection, 'products') or has_product_unit_column(connection):
return
cursor = connection.cursor()
try:
cursor.execute(
"ALTER TABLE products ADD COLUMN unit VARCHAR(20) NOT NULL DEFAULT 'kg'"
)
connection.commit()
except Error as e:
logger.warning(f"Could not add unit column: {e}")
finally:
cursor.close()
def ensure_product_stock_precision(connection):
if not table_exists(connection, 'products'):
return
@ -193,41 +208,7 @@ def ensure_product_min_stock_column(connection):
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()
return
def escape_table_name(table_name: str) -> str:
return f"`{table_name}`"
@ -454,6 +435,7 @@ def fetch_stock_report(connection):
if table_name == 'products':
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
ensure_product_unit_column(connection)
backfill_default_min_stock(connection)
name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name'])
@ -721,6 +703,151 @@ def convert_stock_quantity(quantity: float, from_unit: str, to_unit: str) -> flo
return quantity
PRODUCT_CODE_MAP = {
'baking powder 45gr': 0,
'cokelat bubuk 250gr': 1,
'gula pasir 1kg': 2,
'keju parut 250gr': 3,
'mentega 500gr': 4,
'susu bubuk 27gr': 5,
'telur 1kg': 6,
'tepung terigu 1kg': 7,
}
PRODUCT_CATEGORY_MAP = {
'baking powder': 'Bahan Tambahan',
'baking powder 45gr': 'Bahan Tambahan',
'cokelat bubuk 250gr': 'Cokelat',
'gula pasir 1kg': 'Gula',
'keju parut 250gr': 'Keju',
'mentega 500gr': 'Mentega',
'susu bubuk': 'Susu',
'susu bubuk 27gr': 'Susu',
'telur 1kg': 'Telur',
'tepung terigu 1kg': 'Tepung',
}
PRODUCT_PRICE_MAP = {
'baking powder': 8180,
'baking powder 45gr': 8180,
'cokelat bubuk 250gr': 21036,
'gula pasir 1kg': 14608,
'keju parut 250gr': 23373,
'mentega 500gr': 17529,
'susu bubuk': 17529,
'susu bubuk 27gr': 17529,
'telur 1kg': 26878,
'tepung terigu 1kg': 11687,
}
PRODUCT_UNIT_MAP = {
'baking powder 45gr': 'gr',
'cokelat bubuk 250gr': 'gr',
'gula pasir 1kg': 'kg',
'keju parut 250gr': 'gr',
'mentega 500gr': 'gr',
'susu bubuk 27gr': 'gr',
'susu bubuk': 'gr',
'telur 1kg': 'kg',
'tepung terigu 1kg': 'kg',
}
def normalize_product_for_model(product_name: str) -> str:
value = (product_name or '').lower()
if 'tepung' in value:
return 'Tepung Terigu 1kg'
if 'gula' in value:
return 'Gula Pasir 1kg'
if 'mentega' in value:
return 'Mentega 500gr'
if 'telur' in value:
return 'Telur 1kg'
if 'susu' in value:
return 'Susu Bubuk 27gr'
if 'cokelat' in value or 'coklat' in value:
return 'Cokelat Bubuk 250gr'
if 'keju' in value:
return 'Keju Parut 250gr'
if 'baking' in value or 'pengembang' in value:
return 'Baking Powder 45gr'
return product_name
def dataset_product_key(product_name: str) -> str:
return ' '.join((product_name or '').lower().split())
def dataset_product_unit(product_name: str, fallback: str | None = None) -> str:
return fallback or PRODUCT_UNIT_MAP.get(dataset_product_key(product_name), 'unit')
def apply_dataset_product_fields(product: dict) -> dict:
name = product.get('name') or product.get('product_name') or ''
key = dataset_product_key(name)
product['unit'] = product.get('unit') or PRODUCT_UNIT_MAP.get(key, 'unit')
if key in PRODUCT_CATEGORY_MAP:
product['category'] = PRODUCT_CATEGORY_MAP[key]
if key in PRODUCT_PRICE_MAP and not product.get('price'):
product['price'] = PRODUCT_PRICE_MAP[key]
return product
def encode_with_label_encoder(column_name: str, value: str, fallback: int = 0) -> int:
encoder = encoders.get(column_name)
if encoder is None:
return fallback
try:
return int(encoder.transform([value])[0])
except ValueError:
return fallback
def normalize_prediction_payload(data: dict) -> dict:
"""Accept full model features or simple UI payload and return model features."""
if all(feature in data for feature in feature_columns):
return data
normalized = dict(data)
prediction_date = data.get('prediction_date') or data.get('tanggal_prediksi')
if prediction_date:
date_value = datetime.fromisoformat(str(prediction_date).split('T')[0])
else:
date_value = datetime.now()
raw_product_name = data.get('product_name') or data.get('nama_produk') or ''
model_product_name = normalize_product_for_model(raw_product_name)
model_product_key = model_product_name.lower()
model_category = PRODUCT_CATEGORY_MAP.get(
model_product_key,
data.get('category') or data.get('kategori_produk') or 'Bahan Tambahan'
)
unit_price = int(float(
data.get('unit_price')
or data.get('harga_satuan_update')
or PRODUCT_PRICE_MAP.get(model_product_key, 1)
))
planned_quantity = max(1, int(float(data.get('planned_quantity') or data.get('jumlah_rencana') or 1)))
normalized.update({
'tahun': date_value.year,
'bulan': date_value.month,
'hari': date_value.day,
'hari_dalam_minggu': date_value.weekday(),
'hari_minggu': date_value.weekday(),
'harga_satuan_update': unit_price,
'total_harga_update': int(float(data.get('total_harga_update') or unit_price * planned_quantity)),
'produk_encoded': PRODUCT_CODE_MAP.get(model_product_key, 0),
'nama_produk_encoded': encode_with_label_encoder('nama_produk', model_product_name),
'kategori_produk_encoded': encode_with_label_encoder('kategori_produk', model_category),
})
return normalized
# ============================================================================
# LOAD MODELS AT STARTUP
# ============================================================================
@ -1165,6 +1292,8 @@ def prediksi():
try:
data = request.json
data = normalize_prediction_payload(data)
# Validate required fields
required_fields = feature_columns
missing_fields = [f for f in required_fields if f not in data]
@ -1177,7 +1306,7 @@ def prediksi():
}), 400
# Create feature array
X_pred = np.array([[data[f] for f in feature_columns]])
X_pred = pd.DataFrame([[data[f] for f in feature_columns]], columns=feature_columns)
# Predict
prediksi_raw = model.predict(X_pred)[0]
@ -1243,7 +1372,7 @@ def batch_prediksi():
continue
# Create feature array
X_pred = np.array([[item[f] for f in feature_columns]])
X_pred = pd.DataFrame([[item[f] for f in feature_columns]], columns=feature_columns)
# Predict
prediksi_raw = model.predict(X_pred)[0]
@ -1319,6 +1448,7 @@ def get_products():
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM products ORDER BY name")
products = cursor.fetchall()
products = [apply_dataset_product_fields(product) for product in products]
cursor.close()
connection.close()
@ -1350,6 +1480,8 @@ def get_product(product_id):
if not product:
return jsonify({'status': 'error', 'message': 'Product not found'}), 404
product = apply_dataset_product_fields(product)
return jsonify({
'status': 'success',
'product': product
@ -1393,6 +1525,7 @@ def create_product():
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
ensure_product_unit_column(connection)
cursor = connection.cursor()
@ -1404,9 +1537,10 @@ def create_product():
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 = dataset_product_unit(data['name'], data.get('unit'))
product_type_value = data.get('product_type')
min_stock_value = data.get('min_stock', 0)
category_value = PRODUCT_CATEGORY_MAP.get(dataset_product_key(data['name']), data['category'])
# Check for duplicate product name
cursor.execute("SELECT id FROM products WHERE name = %s", (data['name'],))
@ -1419,7 +1553,7 @@ def create_product():
}), 409
columns = ['name', 'category', 'price', 'current_stock']
values = [data['name'], data['category'], data['price'], data['current_stock']]
values = [data['name'], category_value, data['price'], data['current_stock']]
if has_unit_column:
columns.append('unit')
values.append(unit_value)
@ -1453,6 +1587,100 @@ def create_product():
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/products/<int:product_id>', methods=['PUT', 'PATCH'])
def update_product(product_id):
"""Update product data from Flutter product edit form."""
try:
data = request.json or {}
allowed_fields = {
'name': 'name',
'category': 'category',
'price': 'price',
'current_stock': 'current_stock',
'stock': 'current_stock',
'min_stock': 'min_stock',
'unit': 'unit',
}
connection = get_db_connection()
if not connection:
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
ensure_product_stock_precision(connection)
ensure_product_min_stock_column(connection)
ensure_product_unit_column(connection)
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT id FROM products WHERE id = %s", (product_id,))
if not cursor.fetchone():
cursor.close()
connection.close()
return jsonify({'status': 'error', 'message': 'Product not found'}), 404
if 'name' in data and str(data['name']).strip():
cursor.execute(
"SELECT id FROM products WHERE name = %s AND id <> %s",
(str(data['name']).strip(), product_id),
)
if cursor.fetchone():
cursor.close()
connection.close()
return jsonify({
'status': 'error',
'message': f'Product "{data["name"]}" already exists'
}), 409
updates = []
values = []
for input_key, column_name in allowed_fields.items():
if input_key not in data:
continue
value = data[input_key]
if input_key == 'name':
value = str(value).strip()
if not value:
continue
if input_key == 'category':
value = str(value).strip()
if input_key == 'unit':
value = str(value).strip()
if input_key == 'price':
value = int(value)
if input_key in ['current_stock', 'stock', 'min_stock']:
value = float(value)
updates.append(f"{column_name} = %s")
values.append(value)
if not updates:
cursor.close()
connection.close()
return jsonify({'status': 'error', 'message': 'Tidak ada data yang diubah'}), 400
values.append(product_id)
cursor.execute(
f"UPDATE products SET {', '.join(updates)} WHERE id = %s",
tuple(values),
)
connection.commit()
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
product = cursor.fetchone()
cursor.close()
connection.close()
return jsonify({
'status': 'success',
'message': 'Product updated successfully',
'product': apply_dataset_product_fields(product),
}), 200
except Exception as e:
logger.error(f"Update product error: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/stock/consume', methods=['POST'])
def consume_stock():
"""
@ -2184,6 +2412,233 @@ def laporan_prediksi():
except Exception as e:
logger.error(f"Laporan prediksi error: {str(e)}")
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
def normalize_prediction_date(value: str | None) -> datetime:
if not value:
return datetime.now()
try:
return datetime.fromisoformat(value.split('T')[0])
except Exception:
return datetime.now()
def forecast_day_multiplier(target_date: datetime) -> float:
weekday = target_date.weekday()
if weekday == 4: # Friday
return 1.1
if weekday == 5: # Saturday
return 1.2
if weekday == 6: # Sunday
return 1.15
return 1.0
def load_recipe_production_averages(connection) -> dict[str, float]:
averages: dict[str, float] = {}
if not table_exists(connection, 'stock_usage_history'):
return averages
cursor = connection.cursor(dictionary=True)
try:
cursor.execute(
"""
SELECT recipe_name, AVG(COALESCE(production_quantity, 0)) AS avg_production
FROM stock_usage_history
WHERE COALESCE(recipe_name, '') <> ''
AND COALESCE(production_quantity, 0) > 0
GROUP BY recipe_name
"""
)
rows = cursor.fetchall()
for row in rows:
recipe_name = (row.get('recipe_name') or '').strip()
if not recipe_name:
continue
averages[recipe_name] = float(row.get('avg_production') or 1.0)
except Exception:
pass
finally:
cursor.close()
return averages
def load_recipes_with_ingredients(connection, recipe_filter: str = '', limit: int | None = None) -> list[dict]:
if not table_exists(connection, 'recipes') or not table_exists(connection, 'recipe_ingredients'):
return []
cursor = connection.cursor(dictionary=True)
try:
query = """
SELECT r.id AS recipe_id,
r.recipe_name,
r.description,
ri.product_name,
ri.quantity_needed,
ri.unit
FROM recipes r
LEFT JOIN recipe_ingredients ri ON ri.recipe_id = r.id
"""
params = []
if recipe_filter:
query += " WHERE r.recipe_name LIKE %s"
params.append(f"%{recipe_filter}%")
query += " ORDER BY r.recipe_name, ri.product_name"
if limit:
query += " LIMIT %s"
params.append(limit)
cursor.execute(query, tuple(params))
return cursor.fetchall()
except Exception:
return []
finally:
cursor.close()
def build_forecast_items(connection, start_date, days, recipe_filter='', limit=8):
"""Predict ingredient needs for the next N days based on recipes and the ML model."""
averages = load_recipe_production_averages(connection)
recipes = load_recipes_with_ingredients(connection, recipe_filter=recipe_filter, limit=limit)
if not recipes:
return []
# Group ingredients by recipe
recipe_dict = {}
for r in recipes:
name = r['recipe_name']
if name not in recipe_dict:
recipe_dict[name] = {
'recipe_name': name,
'ingredients': []
}
if r['product_name']:
recipe_dict[name]['ingredients'].append({
'product_name': r['product_name'],
'quantity_needed': r['quantity_needed'],
'unit': r['unit']
})
report_items = []
for day_offset in range(days):
target_date = start_date + timedelta(days=day_offset)
multiplier = forecast_day_multiplier(target_date)
day_ingredients = {}
recipe_targets = []
for recipe_name, recipe_data in recipe_dict.items():
avg_production = averages.get(recipe_name, 1.0)
target_prod = max(1.0, avg_production * multiplier)
recipe_targets.append({
'recipe_name': recipe_name,
'target_produksi': round(target_prod, 2)
})
for ing in recipe_data['ingredients']:
ing_name = ing['product_name']
ing_unit = ing['unit'] or 'kg'
# Build model prediction features using standard normalization
features = normalize_prediction_payload({
'prediction_date': target_date.date().isoformat(),
'product_name': ing_name,
'planned_quantity': int(target_prod)
})
# Predict using the loaded model directly
X_pred = pd.DataFrame([[features[f] for f in feature_columns]], columns=feature_columns)
pred_raw = float(model.predict(X_pred)[0])
# Determine model package size mapping (in grams/kg)
package_size = 1.0
pkg_unit = 'kg'
ing_lower = ing_name.lower()
if 'baking' in ing_lower:
package_size = 45.0
pkg_unit = 'gr'
elif 'cokelat' in ing_lower or 'coklat' in ing_lower:
package_size = 250.0
pkg_unit = 'gr'
elif 'keju' in ing_lower:
package_size = 250.0
pkg_unit = 'gr'
elif 'mentega' in ing_lower:
package_size = 500.0
pkg_unit = 'gr'
elif 'susu' in ing_lower:
package_size = 27.0
pkg_unit = 'gr'
# Convert predicted package float quantity into the ingredient's recipe unit
pred_qty = convert_stock_quantity(pred_raw * package_size, pkg_unit, ing_unit)
if ing_name not in day_ingredients:
day_ingredients[ing_name] = {
'nama_bahan': ing_name,
'jumlah': 0.0,
'unit': ing_unit
}
day_ingredients[ing_name]['jumlah'] += pred_qty
ingredients_list = []
total_qty = 0.0
for ing_name, ing_info in day_ingredients.items():
ing_info['jumlah'] = round(ing_info['jumlah'], 2)
ingredients_list.append(ing_info)
total_qty += ing_info['jumlah']
report_items.append({
'tanggal_prediksi': target_date.date().isoformat(),
'multiplier': round(multiplier, 2),
'bahan': ingredients_list,
'target_produksi': recipe_targets,
'jumlah_bahan': float(len(ingredients_list)),
'total_bahan': round(total_qty, 2)
})
return report_items
@app.route('/laporan/prediksi-forecast', methods=['GET'])
def laporan_prediksi_forecast():
"""Laporan prediksi kebutuhan bahan beberapa hari ke depan dalam format JSON."""
try:
days = max(1, min(request.args.get('days', default=7, type=int), 90))
limit = max(1, min(request.args.get('limit', default=8, type=int), 50))
recipe_filter = request.args.get('product_name', default='', type=str).strip()
start_date = normalize_prediction_date(request.args.get('start_date'))
connection = get_db_connection()
if not connection:
return build_report_response(False, 'Database connection failed', [], 500)
try:
report_items = build_forecast_items(
connection,
start_date,
days,
recipe_filter=recipe_filter,
limit=limit,
)
finally:
connection.close()
return jsonify({
'status': True,
'message': 'Data prediksi kebutuhan bahan berhasil diambil',
'parameter': {
'start_date': start_date.date().isoformat(),
'days': days,
'limit': limit,
'recipe_name': recipe_filter or None,
},
'model_accuracy': {
'r2_score': round(metadata['r2_score'], 4),
'mae': round(metadata['mae'], 4),
'rmse': round(metadata['rmse'], 4),
},
'data': report_items,
}), 200
except Exception as e:
logger.error(f"Laporan prediksi forecast error: {str(e)}")
return build_report_response(False, f'Gagal membuat forecast: {str(e)}', [], 500)
# ============================================================================

View File

@ -0,0 +1,72 @@
import os
from urllib.parse import urlparse
import mysql.connector
def load_env_file():
env_path = os.path.join(os.path.dirname(__file__), '.env')
if not os.path.exists(env_path):
return
with open(env_path, 'r', encoding='utf-8') as env_file:
for raw_line in env_file:
line = raw_line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip().strip('"').strip("'")
def db_config():
load_env_file()
database_url = os.getenv('MYSQL_URL') or os.getenv('DATABASE_URL', '')
parsed = urlparse(database_url) if database_url else None
return {
'host': os.getenv('MYSQLHOST')
or os.getenv('DB_HOST')
or (parsed.hostname if parsed else None)
or 'localhost',
'port': int(
os.getenv('MYSQLPORT')
or os.getenv('DB_PORT')
or (parsed.port if parsed and parsed.port else 3306)
),
'user': os.getenv('MYSQLUSER')
or os.getenv('DB_USER')
or (parsed.username if parsed else None)
or 'root',
'password': os.getenv('MYSQLPASSWORD')
or os.getenv('DB_PASSWORD')
or (parsed.password if parsed else None)
or '',
'database': os.getenv('MYSQLDATABASE')
or os.getenv('DB_NAME')
or (parsed.path.lstrip('/') if parsed and parsed.path else None)
or 'prediksi_stok_db',
}
def table_exists(cursor, table_name):
cursor.execute("SHOW TABLES LIKE %s", (table_name,))
return cursor.fetchone() is not None
def clean_table(cursor, table_name):
raise RuntimeError(
'clean_table dinonaktifkan agar data backend tidak terhapus massal. '
'Gunakan restore_backend_dataset_data.py untuk memulihkan dan memfilter data non-dataset.'
)
def main():
print(
'Skrip pembersihan massal dinonaktifkan. '
'Jalankan restore_backend_dataset_data.py untuk memulihkan data backend '
'dan menghapus hanya baris yang tidak sesuai dataset.'
)
if __name__ == '__main__':
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,5 @@
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error
@ -86,7 +85,19 @@ for col in categorical_cols:
# Select features
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
feature_cols = [col for col in numeric_cols if col != target_col]
leakage_cols = [
target_col,
'total_harga_update',
]
feature_cols = [col for col in numeric_cols if col not in leakage_cols]
removed_leakage_cols = [
col for col in leakage_cols
if col in numeric_cols and col != target_col
]
if removed_leakage_cols:
print(f"\n[OK] Fitur data leakage dihapus: {removed_leakage_cols}")
print("[INFO] total_harga_update tidak dipakai karena nilainya bergantung pada jumlah permintaan.")
print(f"\nFeatures yang digunakan: {len(feature_cols)}")
print(f"Features: {feature_cols}")
@ -101,15 +112,31 @@ print(f"y shape: {y.shape}")
mask = ~(X.isnull().any(axis=1) | y.isnull())
X = X[mask]
y = y[mask]
if date_col:
date_series = df.loc[mask, date_col]
print(f"After removing NaN: X={X.shape}, y={y.shape}")
# Split data
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Split data by date order so testing represents future data.
if date_col:
sorted_index = date_series.sort_values().index
X = X.loc[sorted_index]
y = y.loc[sorted_index]
date_series = date_series.loc[sorted_index]
print("[OK] Data diurutkan berdasarkan tanggal untuk testing prediksi masa depan")
split_index = int(len(X) * 0.8)
X_train = X.iloc[:split_index]
X_test = X.iloc[split_index:]
y_train = y.iloc[:split_index]
y_test = y.iloc[split_index:]
print(f"\nData dibagi:")
print(f" - Training: {len(X_train)} samples ({len(X_train)/len(X)*100:.1f}%)")
print(f" - Testing: {len(X_test)} samples ({len(X_test)/len(X)*100:.1f}%)")
if date_col:
print(f" - Periode training: {date_series.iloc[:split_index].min().date()} s/d {date_series.iloc[:split_index].max().date()}")
print(f" - Periode testing: {date_series.iloc[split_index:].min().date()} s/d {date_series.iloc[split_index:].max().date()}")
# ============================================================================
# MODEL 1: LINEAR REGRESSION
@ -155,6 +182,7 @@ print("MODEL 2: RANDOM FOREST REGRESSOR")
print("=" * 80)
rf_model = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
rf_model.fit(X_train, y_train)
# Predictions
@ -213,6 +241,11 @@ else:
best_r2 = test_r2_lr
worst_r2 = test_r2_rf
app_model = "Random Forest"
app_r2 = test_r2_rf
app_mae = test_mae_rf
app_rmse = test_rmse_rf
print(f"\n[BEST] MODEL TERBAIK: {best_model}")
print(f" - Akurasi {best_model} (R²) = {best_r2:.4f}")
if test_r2_rf > test_r2_lr:
@ -222,6 +255,9 @@ else:
print(f" - Akurasi Random Forest (R²) = {test_r2_rf:.4f}")
print(f" - Perbedaan: {(test_r2_lr - test_r2_rf):.4f}")
print("\n[INFO] Model yang disimpan untuk aplikasi: Random Forest")
print("[INFO] Linear Regression hanya dipakai sebagai pembanding evaluasi.")
# ============================================================================
# SAVE RESULTS
# ============================================================================
@ -234,7 +270,13 @@ results_file.write(f"Total Data: {df.shape[0]} baris\n")
results_file.write(f"Training Set: {len(X_train)} ({len(X_train)/len(X)*100:.1f}%)\n")
results_file.write(f"Testing Set: {len(X_test)} ({len(X_test)/len(X)*100:.1f}%)\n")
results_file.write(f"Features: {len(feature_cols)}\n")
results_file.write(f"Target: {target_col}\n\n")
results_file.write(f"Fitur yang dihapus karena data leakage: {removed_leakage_cols}\n")
results_file.write(f"Target: {target_col}\n")
if date_col:
results_file.write("Metode split: chronological split berdasarkan tanggal transaksi\n")
results_file.write(f"Periode Training: {date_series.iloc[:split_index].min().date()} s/d {date_series.iloc[:split_index].max().date()}\n")
results_file.write(f"Periode Testing: {date_series.iloc[split_index:].min().date()} s/d {date_series.iloc[split_index:].max().date()}\n")
results_file.write("\n")
results_file.write("LINEAR REGRESSION - Test Metrics:\n")
results_file.write(f" R² Score: {test_r2_lr:.4f}\n")
@ -246,7 +288,6 @@ results_file.write(f" R² Score: {test_r2_rf:.4f}\n")
results_file.write(f" MAE: {test_mae_rf:.2f}\n")
results_file.write(f" RMSE: {test_rmse_rf:.2f}\n\n")
results_file.write(f"REKOMENDASI: {best_model} (Lebih baik)\n")
results_file.close()
print("\n[OK] Hasil disimpan ke: model_testing_results.txt")
@ -258,12 +299,8 @@ print("\n" + "=" * 80)
print("MENYIMPAN MODEL TERBAIK")
print("=" * 80)
if best_model == "Linear Regression":
joblib.dump(lr_model, 'model_prediksi.pkl')
print("\n[OK] Model Linear Regression disimpan: model_prediksi.pkl")
else:
joblib.dump(rf_model, 'model_prediksi.pkl')
print("\n[OK] Model Random Forest disimpan: model_prediksi.pkl")
joblib.dump(rf_model, 'model_prediksi.pkl')
print("\n[OK] Model Random Forest disimpan: model_prediksi.pkl")
# Save encoders
joblib.dump(encoders, 'encoders.pkl')
@ -275,11 +312,14 @@ print("[OK] Feature columns disimpan: feature_columns.pkl")
# Save metadata
metadata = {
'model_type': best_model,
'r2_score': best_r2,
'mae': test_mae_lr if best_model == "Linear Regression" else test_mae_rf,
'rmse': test_rmse_lr if best_model == "Linear Regression" else test_rmse_rf,
'model_type': app_model,
'r2_score': app_r2,
'mae': app_mae,
'rmse': app_rmse,
'feature_columns': feature_cols,
'removed_leakage_columns': removed_leakage_cols,
'split_method': 'chronological',
'comparison_best_model': best_model,
'target_column': target_col,
'total_samples': len(df)
}

View File

@ -3,19 +3,22 @@ HASIL TESTING MODEL - PREDIKSI PERMINTAAN STOK BAHAN KUE
================================================================================
Total Data: 6742 baris
Training Set: 5393 (80.0%)
Training Set: 5392 (80.0%)
Testing Set: 1349 (20.0%)
Features: 10
Features: 9
Fitur yang dihapus karena data leakage: ['total_harga_update']
Target: jumlah_permintaan_bahan
Metode split: chronological split berdasarkan tanggal transaksi
Periode Training: 2021-01-01 s/d 2024-12-28
Periode Testing: 2024-12-28 s/d 2025-12-31
LINEAR REGRESSION - Test Metrics:
R² Score: 0.7813
MAE: 0.63
RMSE: 0.91
R² Score: -0.0039
MAE: 2.49
RMSE: 2.86
RANDOM FOREST - Test Metrics:
R² Score: 0.9964
MAE: 0.03
RMSE: 0.12
R² Score: -0.1338
MAE: 2.59
RMSE: 3.04
REKOMENDASI: Random Forest (Lebih baik)

View File

@ -0,0 +1,191 @@
import re
import mysql.connector
from cleanup_dummy_data import db_config
DATASET_PRODUCTS = {
'Baking Powder',
'Baking Powder 45gr',
'Cokelat Bubuk 250gr',
'Gula Pasir 1kg',
'Keju Parut 250gr',
'Mentega 500gr',
'Susu Bubuk',
'Susu Bubuk 27gr',
'Telur 1kg',
'Tepung Terigu 1kg',
}
RESTORE_TABLES = [
'predictions',
'products',
'recipes',
'recipe_ingredients',
'stock in',
'stock_usage_history',
'stok_keluar',
'transactions',
]
def extract_insert_statements(sql_text):
pattern = re.compile(
r"INSERT INTO `([^`]+)` .*?;\n",
flags=re.IGNORECASE | re.DOTALL,
)
for match in pattern.finditer(sql_text):
table_name = match.group(1)
statement = match.group(0)
if table_name in RESTORE_TABLES:
yield table_name, statement
def table_exists(cursor, table_name):
cursor.execute("SHOW TABLES LIKE %s", (table_name,))
return cursor.fetchone() is not None
def delete_table(cursor, table_name):
if not table_exists(cursor, table_name):
return
escaped = f"`{table_name.replace('`', '``')}`"
cursor.execute(f"DELETE FROM {escaped}")
cursor.execute(f"ALTER TABLE {escaped} AUTO_INCREMENT = 1")
def get_existing_column(cursor, table_name, candidates):
for column in candidates:
cursor.execute(f"SHOW COLUMNS FROM `{table_name}` LIKE %s", (column,))
if cursor.fetchone():
return column
return None
def placeholders(values):
return ', '.join(['%s'] * len(values))
def delete_not_in_dataset(cursor, table_name, column_name):
if not table_exists(cursor, table_name):
return 0
product_list = sorted(DATASET_PRODUCTS)
escaped_table = f"`{table_name.replace('`', '``')}`"
escaped_column = f"`{column_name.replace('`', '``')}`"
cursor.execute(
f"DELETE FROM {escaped_table} WHERE {escaped_column} NOT IN ({placeholders(product_list)})",
tuple(product_list),
)
return cursor.rowcount
def normalize_dataset_product_rows(cursor):
if not table_exists(cursor, 'products'):
return
updates = {
'Baking Powder': ('Bahan Tambahan', 8180, '45gr'),
'Baking Powder 45gr': ('Bahan Tambahan', 8180, '45gr'),
'Cokelat Bubuk 250gr': ('Cokelat', 21036, '250gr'),
'Gula Pasir 1kg': ('Gula', 14608, '1kg'),
'Keju Parut 250gr': ('Keju', 23373, '250gr'),
'Mentega 500gr': ('Mentega', 17529, '500gr'),
'Susu Bubuk': ('Susu', 17529, '27gr'),
'Susu Bubuk 27gr': ('Susu', 17529, '27gr'),
'Telur 1kg': ('Telur', 26878, '1kg'),
'Tepung Terigu 1kg': ('Tepung', 11687, '1kg'),
}
unit_col = get_existing_column(cursor, 'products', ['unit'])
for name, (category, price, unit) in updates.items():
if unit_col:
cursor.execute(
f"""
UPDATE products
SET category = %s, price = %s, {unit_col} = %s
WHERE name = %s
""",
(category, price, unit, name),
)
else:
cursor.execute(
"UPDATE products SET category = %s, price = %s WHERE name = %s",
(category, price, name),
)
def cleanup_to_backend_recipe_data(cursor):
deleted = {}
# Jangan hapus products/recipe_ingredients yang tidak ada di dataset model.
# Random Forest memang hanya memakai bahan dataset sebagai bahan acuan,
# tetapi kalkulasi kebutuhan resep tetap membutuhkan bahan pendukung seperti
# Santan, Pewarna Makanan, Kelapa Parut, Selai Nanas, dan bahan lain dari backend.
deleted['products'] = 0
deleted['recipe_ingredients'] = 0
deleted['stock in'] = 0
deleted['stock_usage_history'] = 0
deleted['transactions'] = 0
if table_exists(cursor, 'stok_keluar') and table_exists(cursor, 'products'):
cursor.execute(
"""
DELETE sk
FROM stok_keluar sk
LEFT JOIN products p ON p.id = sk.bahan_id
WHERE p.id IS NULL
"""
)
deleted['stok_keluar'] = cursor.rowcount
if table_exists(cursor, 'recipes') and table_exists(cursor, 'recipe_ingredients'):
cursor.execute(
"""
DELETE r
FROM recipes r
LEFT JOIN recipe_ingredients ri ON ri.recipe_id = r.id
WHERE ri.id IS NULL
"""
)
deleted['recipes_without_dataset_ingredients'] = cursor.rowcount
normalize_dataset_product_rows(cursor)
return deleted
def main():
with open('prediksi_stok_db.sql', 'r', encoding='utf-8') as sql_file:
sql_text = sql_file.read()
connection = mysql.connector.connect(**db_config())
cursor = connection.cursor()
restored = []
try:
cursor.execute("SET FOREIGN_KEY_CHECKS = 0")
for table in reversed(RESTORE_TABLES):
delete_table(cursor, table)
for table, statement in extract_insert_statements(sql_text):
cursor.execute(statement)
restored.append(table)
deleted = cleanup_to_backend_recipe_data(cursor)
cursor.execute("SET FOREIGN_KEY_CHECKS = 1")
connection.commit()
except Exception:
connection.rollback()
raise
finally:
cursor.close()
connection.close()
print("Restored insert blocks:", ", ".join(restored))
print("Deleted unsupported rows:", deleted)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,218 @@
import mysql.connector
from cleanup_dummy_data import db_config
PRODUCT_ROWS = [
(25, 'Kelapa Parut', 'Bahan Baku', 8000, 0.000, 0.000, 'gr'),
(26, 'Santan', 'Bahan Baku', 10000, 19.000, 0.000, 'ml'),
(27, 'Selai Nanas', 'Bahan Tambahan', 15000, 10.000, 0.000, 'gr'),
(28, 'Tepung Ketan', 'Bahan Baku', 12000, 7.000, 0.000, 'gr'),
(29, 'Tepung Beras', 'Bahan Baku', 10000, 6.000, 0.000, 'gr'),
(30, 'Gula Merah', 'Bahan Baku', 12000, 13.000, 0.000, 'gr'),
(31, 'Cream Cheese', 'Bahan Tambahan', 30000, 6.000, 0.000, 'gr'),
(32, 'Susu Cair', 'Bahan Baku', 12000, 8.000, 0.000, 'ml'),
(33, 'Kopi', 'Bahan Tambahan', 10000, 10.000, 0.000, 'ml'),
(34, 'Biskuit', 'Bahan Tambahan', 8000, 10.000, 0.000, 'gr'),
(35, 'Pewarna Makanan', 'Bahan Tambahan', 5000, 5.000, 0.000, 'gr'),
(36, 'Pewarna Merah', 'Bahan Tambahan', 5000, 10.000, 0.000, 'gr'),
]
PRODUCT_UNIT_UPDATES = {
'Baking Powder': 'gr',
'Baking Powder 45gr': 'gr',
'Cokelat Bubuk 250gr': 'gr',
'Cream Cheese': 'gr',
'Gula Merah': 'gr',
'Gula Pasir 1kg': 'kg',
'Keju Parut 250gr': 'gr',
'Kelapa Parut': 'gr',
'Kopi': 'ml',
'Mentega 500gr': 'gr',
'Pewarna Makanan': 'gr',
'Pewarna Merah': 'gr',
'Santan': 'ml',
'Selai Nanas': 'gr',
'Susu Bubuk': 'gr',
'Susu Bubuk 27gr': 'gr',
'Susu Cair': 'ml',
'Telur 1kg': 'kg',
'Tepung Beras': 'gr',
'Tepung Ketan': 'gr',
'Tepung Terigu 1kg': 'kg',
}
RECIPE_INGREDIENT_ROWS = [
(43, 8, 'Selai Nanas', 150, 'gr'),
(51, 10, 'Kelapa Parut', 200, 'gr'),
(54, 11, 'Santan', 300, 'ml'),
(55, 11, 'Pewarna Makanan', 5, 'gr'),
(59, 12, 'Santan', 250, 'ml'),
(63, 13, 'Susu Cair', 250, 'ml'),
(64, 14, 'Tepung Ketan', 400, 'gr'),
(65, 14, 'Gula Merah', 200, 'gr'),
(66, 14, 'Kelapa Parut', 200, 'gr'),
(67, 15, 'Tepung Beras', 300, 'gr'),
(68, 15, 'Santan', 300, 'ml'),
(70, 16, 'Tepung Terigu 1kg', 250, 'gr'),
(71, 16, 'Telur 1kg', 2, 'butir'),
(72, 16, 'Kelapa Parut', 200, 'gr'),
(73, 16, 'Gula Merah', 150, 'gr'),
(74, 17, 'Tepung Beras', 300, 'gr'),
(75, 17, 'Kelapa Parut', 200, 'gr'),
(76, 17, 'Santan', 250, 'ml'),
(81, 19, 'Biskuit', 200, 'gr'),
(82, 19, 'Kopi', 100, 'ml'),
(83, 19, 'Krim', 200, 'ml'),
(88, 20, 'Pewarna Merah', 5, 'gr'),
(89, 20, 'Cream Cheese', 150, 'gr'),
]
def has_column(cursor, table_name, column_name):
cursor.execute(f"SHOW COLUMNS FROM `{table_name}` LIKE %s", (column_name,))
return cursor.fetchone() is not None
def ensure_product_unit_column(cursor):
if has_column(cursor, 'products', 'unit'):
return
cursor.execute(
"ALTER TABLE products ADD COLUMN unit VARCHAR(20) NOT NULL DEFAULT 'kg'"
)
def product_exists(cursor, name):
cursor.execute("SELECT id FROM products WHERE name = %s LIMIT 1", (name,))
return cursor.fetchone() is not None
def id_exists(cursor, table_name, row_id):
cursor.execute(f"SELECT id FROM `{table_name}` WHERE id = %s LIMIT 1", (row_id,))
return cursor.fetchone() is not None
def insert_missing_products(cursor):
ensure_product_unit_column(cursor)
has_unit = has_column(cursor, 'products', 'unit')
inserted = 0
for name, unit in PRODUCT_UNIT_UPDATES.items():
cursor.execute(
"""
UPDATE products
SET unit = %s
WHERE name = %s
""",
(unit, name),
)
for row_id, name, category, price, stock, min_stock, unit in PRODUCT_ROWS:
if product_exists(cursor, name):
if has_unit:
cursor.execute(
"""
UPDATE products
SET unit = %s
WHERE name = %s AND (unit IS NULL OR unit = '' OR unit = 'unit')
""",
(unit, name),
)
continue
columns = ['name', 'category', 'price', 'current_stock', 'min_stock']
values = [name, category, price, stock, min_stock]
if not id_exists(cursor, 'products', row_id):
columns.insert(0, 'id')
values.insert(0, row_id)
if has_unit:
columns.append('unit')
values.append(unit)
column_sql = ', '.join(columns)
placeholders = ', '.join(['%s'] * len(values))
cursor.execute(
f"INSERT INTO products ({column_sql}) VALUES ({placeholders})",
tuple(values),
)
inserted += 1
return inserted
def recipe_ingredient_exists(cursor, recipe_id, product_name):
cursor.execute(
"""
SELECT id
FROM recipe_ingredients
WHERE recipe_id = %s AND product_name = %s
LIMIT 1
""",
(recipe_id, product_name),
)
return cursor.fetchone() is not None
def recipe_exists(cursor, recipe_id):
cursor.execute("SELECT id FROM recipes WHERE id = %s LIMIT 1", (recipe_id,))
return cursor.fetchone() is not None
def insert_missing_recipe_ingredients(cursor):
inserted = 0
skipped_missing_recipes = []
for row_id, recipe_id, product_name, quantity_needed, unit in RECIPE_INGREDIENT_ROWS:
if not recipe_exists(cursor, recipe_id):
skipped_missing_recipes.append(recipe_id)
continue
if recipe_ingredient_exists(cursor, recipe_id, product_name):
continue
columns = ['recipe_id', 'product_name', 'quantity_needed', 'unit']
values = [recipe_id, product_name, quantity_needed, unit]
if not id_exists(cursor, 'recipe_ingredients', row_id):
columns.insert(0, 'id')
values.insert(0, row_id)
column_sql = ', '.join(columns)
placeholders = ', '.join(['%s'] * len(values))
cursor.execute(
f"INSERT INTO recipe_ingredients ({column_sql}) VALUES ({placeholders})",
tuple(values),
)
inserted += 1
return inserted, sorted(set(skipped_missing_recipes))
def main():
connection = mysql.connector.connect(**db_config())
cursor = connection.cursor()
try:
inserted_products = insert_missing_products(cursor)
inserted_ingredients, skipped_recipes = insert_missing_recipe_ingredients(cursor)
connection.commit()
except Exception:
connection.rollback()
raise
finally:
cursor.close()
connection.close()
print(f"Inserted products: {inserted_products}")
print(f"Inserted recipe ingredients: {inserted_ingredients}")
if skipped_recipes:
print(f"Skipped missing recipe IDs: {skipped_recipes}")
if __name__ == '__main__':
main()

View File

@ -100,67 +100,9 @@ CREATE TABLE IF NOT EXISTS recipe_ingredients (
FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Insert Initial Products (8 items)
INSERT INTO products (name, category, product_type, unit, price, current_stock, min_stock) VALUES
('Tepung Terigu 1kg', 'Tepung', 'Bahan', 'kg', 15000, 50, 10),
('Telur 1kg', 'Telur', 'Bahan', 'kg', 25000, 30, 5),
('Gula Pasir 1kg', 'Gula', 'Bahan', 'kg', 12000, 40, 8),
('Susu Bubuk', 'Susu', 'Bahan', 'kg', 20000, 20, 4),
('Cokelat Bubuk 250gr', 'Cokelat', 'Bahan', 'kg', 18000, 15, 3),
('Mentega 500gr', 'Mentega', 'Bahan', 'kg', 22000, 25, 5),
('Keju Parut 250gr', 'Keju', 'Bahan', 'kg', 28000, 10, 2),
('Baking Powder', 'Bahan Tambahan', 'Bahan', 'kg', 8000, 35, 2);
-- Insert Default Login Account
INSERT INTO login (name, email, username, password) VALUES
('Ibu Sulastri', 'sulastri.aritanto10@gmail.com', 'admin', 'password')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
email = VALUES(email),
username = VALUES(username);
-- Insert Sample Recipes
INSERT INTO recipes (recipe_name, description) VALUES
('Donat', 'Resep donat lezat'),
('Roti Putih', 'Resep roti putih'),
('Kue Brownies', 'Resep brownies cokelat'),
('Kue Tart', 'Resep kue tart');
-- Insert Recipe Ingredients
-- Donat
INSERT INTO recipe_ingredients (recipe_id, product_name, quantity_needed, unit) VALUES
(1, 'Tepung Terigu 1kg', 0.5, 'kg'),
(1, 'Telur 1kg', 2, 'butir'),
(1, 'Gula Pasir 1kg', 0.1, 'kg'),
(1, 'Mentega 500gr', 0.05, 'kg'),
(1, 'Baking Powder', 0.005, 'kg');
-- Roti Putih
INSERT INTO recipe_ingredients (recipe_id, product_name, quantity_needed, unit) VALUES
(2, 'Tepung Terigu 1kg', 0.8, 'kg'),
(2, 'Telur 1kg', 3, 'butir'),
(2, 'Gula Pasir 1kg', 0.08, 'kg'),
(2, 'Mentega 500gr', 0.08, 'kg'),
(2, 'Susu Bubuk', 0.05, 'kg'),
(2, 'Baking Powder', 0.008, 'kg');
-- Kue Brownies
INSERT INTO recipe_ingredients (recipe_id, product_name, quantity_needed, unit) VALUES
(3, 'Tepung Terigu 1kg', 0.3, 'kg'),
(3, 'Cokelat Bubuk 250gr', 0.1, 'kg'),
(3, 'Telur 1kg', 4, 'butir'),
(3, 'Gula Pasir 1kg', 0.2, 'kg'),
(3, 'Mentega 500gr', 0.15, 'kg'),
(3, 'Baking Powder', 0.005, 'kg');
-- Kue Tart
INSERT INTO recipe_ingredients (recipe_id, product_name, quantity_needed, unit) VALUES
(4, 'Tepung Terigu 1kg', 0.4, 'kg'),
(4, 'Telur 1kg', 5, 'butir'),
(4, 'Gula Pasir 1kg', 0.15, 'kg'),
(4, 'Mentega 500gr', 0.2, 'kg'),
(4, 'Keju Parut 250gr', 0.1, 'kg'),
(4, 'Susu Bubuk', 0.08, 'kg');
-- Data master tidak di-seed otomatis.
-- Isi produk, resep, akun, stok masuk, dan prediksi dari aplikasi/backend
-- agar data yang tampil bukan data dummy.
-- Verify tables created
SELECT 'Tables created successfully!' as status;