Update frontend and backend predictions
This commit is contained in:
parent
9b55f02ade
commit
8d81d30278
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
533
ml_model/app.py
533
ml_model/app.py
|
|
@ -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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue