API laporan
This commit is contained in:
parent
97ceebb831
commit
fdbe51d7f3
|
|
@ -0,0 +1,71 @@
|
|||
-- =====================================================
|
||||
-- Schema Laporan: bahan, stok_masuk, prediksi
|
||||
-- Tujuan: mendukung fitur laporan realtime
|
||||
-- =====================================================
|
||||
|
||||
-- 1) TABEL: bahan
|
||||
-- Menyimpan stok bahan dengan stok minimum
|
||||
CREATE TABLE IF NOT EXISTS bahan (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
product_id INT NULL,
|
||||
nama_bahan VARCHAR(100) NOT NULL,
|
||||
stok DOUBLE NOT NULL DEFAULT 0,
|
||||
stok_minimum DOUBLE NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(20) DEFAULT 'kg',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_bahan_product
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE,
|
||||
KEY idx_bahan_nama (nama_bahan),
|
||||
KEY idx_bahan_product (product_id)
|
||||
);
|
||||
|
||||
-- 2) TABEL: stok_masuk
|
||||
-- Riwayat stok masuk bahan
|
||||
CREATE TABLE IF NOT EXISTS stok_masuk (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
bahan_id INT NULL,
|
||||
product_id INT NULL,
|
||||
tanggal DATE NOT NULL,
|
||||
jumlah DOUBLE NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(20) DEFAULT 'kg',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_stok_masuk_bahan
|
||||
FOREIGN KEY (bahan_id) REFERENCES bahan(id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_stok_masuk_product
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE,
|
||||
KEY idx_stok_masuk_tanggal (tanggal),
|
||||
KEY idx_stok_masuk_bahan (bahan_id),
|
||||
KEY idx_stok_masuk_product (product_id)
|
||||
);
|
||||
|
||||
-- 3) TABEL: prediksi
|
||||
-- Menyimpan hasil prediksi permintaan
|
||||
CREATE TABLE IF NOT EXISTS prediksi (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
product_id INT NULL,
|
||||
nama_produk VARCHAR(100) NOT NULL,
|
||||
hasil_prediksi DOUBLE NOT NULL DEFAULT 0,
|
||||
estimasi_kebutuhan_bahan TEXT,
|
||||
tanggal_prediksi DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_prediksi_product
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE,
|
||||
KEY idx_prediksi_tanggal (tanggal_prediksi),
|
||||
KEY idx_prediksi_product (product_id)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- Catatan:
|
||||
-- - Jika tabel products sudah menyimpan stok (current_stock, min_stock),
|
||||
-- endpoint laporan akan otomatis fallback ke tabel products.
|
||||
-- - Pastikan data bahan/produk sinkron agar laporan tampil konsisten.
|
||||
-- =====================================================
|
||||
|
|
@ -1,22 +1,223 @@
|
|||
import 'dart:async';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:finalproject/services/ml_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ReportController extends ChangeNotifier {
|
||||
String selectedPeriod = 'bulanan';
|
||||
bool isLoading = true;
|
||||
String? errorMessage;
|
||||
|
||||
final List<String> periods = ['harian', 'mingguan', 'bulanan', 'tahunan'];
|
||||
final List<(String, int)> topProducts = const [
|
||||
('Tepung Terigu', 25),
|
||||
('Gula Pasir', 18),
|
||||
('Telur', 15),
|
||||
('Mentega', 12),
|
||||
('Cokelat Bubuk', 10),
|
||||
];
|
||||
final List<Map<String, dynamic>> stockItems = [];
|
||||
final List<Map<String, dynamic>> stockHistory = [];
|
||||
final List<Map<String, dynamic>> predictionItems = [];
|
||||
final List<Map<String, dynamic>> criticalItems = [];
|
||||
final List<Map<String, dynamic>> usageSummary = [];
|
||||
final List<double> demandTrend = [];
|
||||
|
||||
void setSelectedPeriod(String value) {
|
||||
selectedPeriod = value;
|
||||
notifyListeners();
|
||||
int totalProduk = 0;
|
||||
int totalBahan = 0;
|
||||
int totalPrediksi = 0;
|
||||
int totalKritis = 0;
|
||||
|
||||
int _productCount = 0;
|
||||
|
||||
Timer? _refreshTimer;
|
||||
|
||||
/// Load semua data laporan dari API.
|
||||
Future<void> loadReports({bool showLoading = true}) async {
|
||||
if (showLoading) {
|
||||
isLoading = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
errorMessage = null;
|
||||
|
||||
final results = await Future.wait([
|
||||
MLService.getReportStock(),
|
||||
MLService.getReportStockIn(),
|
||||
MLService.getReportPredictions(),
|
||||
MLService.getReportCritical(),
|
||||
MLService.getProducts(),
|
||||
]);
|
||||
|
||||
final stockResponse = results[0] as Map<String, dynamic>;
|
||||
final stockInResponse = results[1] as Map<String, dynamic>;
|
||||
final predictionResponse = results[2] as Map<String, dynamic>;
|
||||
final criticalResponse = results[3] as Map<String, dynamic>;
|
||||
final productsResponse = results[4];
|
||||
|
||||
if (stockResponse['status'] != true) {
|
||||
errorMessage = stockResponse['message']?.toString();
|
||||
} else {
|
||||
_applyStockItems(stockResponse['data']);
|
||||
}
|
||||
|
||||
if (stockInResponse['status'] != true) {
|
||||
errorMessage ??= stockInResponse['message']?.toString();
|
||||
} else {
|
||||
_applyStockHistory(stockInResponse['data']);
|
||||
}
|
||||
|
||||
if (predictionResponse['status'] != true) {
|
||||
errorMessage ??= predictionResponse['message']?.toString();
|
||||
} else {
|
||||
_applyPredictions(predictionResponse['data']);
|
||||
}
|
||||
|
||||
if (criticalResponse['status'] != true) {
|
||||
errorMessage ??= criticalResponse['message']?.toString();
|
||||
} else {
|
||||
_applyCriticalItems(criticalResponse['data']);
|
||||
}
|
||||
|
||||
if (productsResponse is List) {
|
||||
_productCount = productsResponse.length;
|
||||
}
|
||||
|
||||
_rebuildSummary();
|
||||
_rebuildUsageSummary();
|
||||
_rebuildDemandTrend();
|
||||
} catch (e) {
|
||||
errorMessage = 'Gagal memuat laporan: $e';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String capitalize(String value) =>
|
||||
'${value[0].toUpperCase()}${value.substring(1)}';
|
||||
/// Refresh otomatis agar laporan selalu realtime.
|
||||
void startAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = Timer.periodic(
|
||||
const Duration(minutes: 1),
|
||||
(_) => loadReports(showLoading: false),
|
||||
);
|
||||
}
|
||||
|
||||
void stopAutoRefresh() {
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = null;
|
||||
}
|
||||
|
||||
void _applyStockItems(dynamic data) {
|
||||
stockItems.clear();
|
||||
if (data is! List) return;
|
||||
for (final item in data) {
|
||||
stockItems.add({
|
||||
'name': item['nama_bahan']?.toString() ?? '-',
|
||||
'stock': _toDouble(item['stok']),
|
||||
'min_stock': _toDouble(item['stok_minimum']),
|
||||
'status': item['status']?.toString() ?? 'Aman',
|
||||
'unit': item['unit']?.toString() ?? 'kg',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyStockHistory(dynamic data) {
|
||||
stockHistory.clear();
|
||||
if (data is! List) return;
|
||||
for (final item in data) {
|
||||
stockHistory.add({
|
||||
'date': _parseDate(item['tanggal']),
|
||||
'name': item['nama_bahan']?.toString() ?? '-',
|
||||
'amount': _toDouble(item['jumlah']),
|
||||
'unit': item['unit']?.toString() ?? 'kg',
|
||||
});
|
||||
}
|
||||
stockHistory.sort((a, b) {
|
||||
final aDate = a['date'] as DateTime;
|
||||
final bDate = b['date'] as DateTime;
|
||||
return bDate.compareTo(aDate);
|
||||
});
|
||||
}
|
||||
|
||||
void _applyPredictions(dynamic data) {
|
||||
predictionItems.clear();
|
||||
if (data is! List) return;
|
||||
for (final item in data) {
|
||||
final needsValue = item['estimasi_kebutuhan_bahan'];
|
||||
final needsText =
|
||||
needsValue == null || needsValue.toString().isEmpty
|
||||
? 'Belum tersedia'
|
||||
: needsValue.toString();
|
||||
predictionItems.add({
|
||||
'product': item['nama_produk']?.toString() ?? '-',
|
||||
'prediction': _toDouble(item['hasil_prediksi']),
|
||||
'needs': needsText,
|
||||
'date': _parseDate(item['tanggal_prediksi']),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyCriticalItems(dynamic data) {
|
||||
criticalItems.clear();
|
||||
if (data is! List) return;
|
||||
for (final item in data) {
|
||||
criticalItems.add({
|
||||
'name': item['nama_bahan']?.toString() ?? '-',
|
||||
'stock': _toDouble(item['stok']),
|
||||
'status': item['status']?.toString() ?? 'Kritis',
|
||||
'unit': item['unit']?.toString() ?? 'kg',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _rebuildSummary() {
|
||||
totalBahan = stockItems.length;
|
||||
totalProduk = _productCount;
|
||||
totalPrediksi = predictionItems.length;
|
||||
totalKritis = criticalItems.length;
|
||||
}
|
||||
|
||||
void _rebuildUsageSummary() {
|
||||
final Map<String, double> totals = {};
|
||||
for (final item in stockHistory) {
|
||||
final name = item['name'] as String;
|
||||
final amount = item['amount'] as double;
|
||||
totals[name] = (totals[name] ?? 0) + amount;
|
||||
}
|
||||
|
||||
final sorted =
|
||||
totals.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
usageSummary
|
||||
..clear()
|
||||
..addAll(
|
||||
sorted
|
||||
.take(4)
|
||||
.map((entry) => {'label': entry.key, 'value': entry.value}),
|
||||
);
|
||||
}
|
||||
|
||||
void _rebuildDemandTrend() {
|
||||
final sorted =
|
||||
predictionItems.toList()..sort(
|
||||
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
|
||||
);
|
||||
|
||||
demandTrend
|
||||
..clear()
|
||||
..addAll(sorted.take(7).map((entry) => (entry['prediction'] as double)));
|
||||
}
|
||||
|
||||
double _toDouble(dynamic value) {
|
||||
if (value is num) return value.toDouble();
|
||||
return double.tryParse(value?.toString() ?? '') ?? 0.0;
|
||||
}
|
||||
|
||||
DateTime _parseDate(dynamic value) {
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) {
|
||||
return DateTime.tryParse(value) ?? DateTime.now();
|
||||
}
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stopAutoRefresh();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'report_controller.dart';
|
||||
|
||||
class ReportScreen extends StatefulWidget {
|
||||
const ReportScreen({super.key});
|
||||
|
||||
|
|
@ -14,67 +16,15 @@ class ReportScreen extends StatefulWidget {
|
|||
class _ReportScreenState extends State<ReportScreen> {
|
||||
final DateFormat _dateFormat = DateFormat('dd/MM/yyyy');
|
||||
|
||||
final List<Map<String, dynamic>> _stockItems = [
|
||||
{'name': 'Tepung Terigu 1kg', 'stock': 18.0, 'status': 'Aman'},
|
||||
{'name': 'Gula Pasir 1kg', 'stock': 6.5, 'status': 'Rendah'},
|
||||
{'name': 'Telur 1kg', 'stock': 2.0, 'status': 'Kritis'},
|
||||
{'name': 'Mentega 500gr', 'stock': 4.0, 'status': 'Rendah'},
|
||||
{'name': 'Baking Powder', 'stock': 12.0, 'status': 'Aman'},
|
||||
];
|
||||
late final ReportController _controller;
|
||||
|
||||
final List<Map<String, dynamic>> _stockHistory = [
|
||||
{
|
||||
'date': DateTime.now().subtract(const Duration(days: 1)),
|
||||
'name': 'Tepung Terigu 1kg',
|
||||
'amount': 10,
|
||||
},
|
||||
{
|
||||
'date': DateTime.now().subtract(const Duration(days: 2)),
|
||||
'name': 'Gula Pasir 1kg',
|
||||
'amount': 6,
|
||||
},
|
||||
{
|
||||
'date': DateTime.now().subtract(const Duration(days: 4)),
|
||||
'name': 'Mentega 500gr',
|
||||
'amount': 3,
|
||||
},
|
||||
{
|
||||
'date': DateTime.now().subtract(const Duration(days: 6)),
|
||||
'name': 'Telur 1kg',
|
||||
'amount': 5,
|
||||
},
|
||||
]..sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
|
||||
|
||||
final List<Map<String, dynamic>> _predictionItems = [
|
||||
{
|
||||
'product': 'Donat Cokelat',
|
||||
'prediction': 120,
|
||||
'needs': 'Tepung 15kg, Gula 6kg, Telur 4kg',
|
||||
'date': DateTime.now(),
|
||||
},
|
||||
{
|
||||
'product': 'Roti Manis',
|
||||
'prediction': 90,
|
||||
'needs': 'Tepung 12kg, Gula 5kg, Mentega 3kg',
|
||||
'date': DateTime.now().subtract(const Duration(days: 2)),
|
||||
},
|
||||
];
|
||||
|
||||
final List<Map<String, dynamic>> _usageBars = [
|
||||
{'label': 'Tepung', 'value': 40.0, 'color': AppColors.primaryBrown},
|
||||
{'label': 'Gula', 'value': 28.0, 'color': AppColors.secondaryOrange},
|
||||
{'label': 'Telur', 'value': 18.0, 'color': AppColors.secondaryBlue},
|
||||
{'label': 'Mentega', 'value': 12.0, 'color': AppColors.secondaryGreen},
|
||||
];
|
||||
|
||||
final List<double> _demandTrend = [28, 32, 40, 36, 44, 50, 48];
|
||||
|
||||
final List<Map<String, dynamic>> _usagePie = [
|
||||
{'label': 'Tepung', 'value': 40.0, 'color': AppColors.primaryBrown},
|
||||
{'label': 'Gula', 'value': 25.0, 'color': AppColors.secondaryOrange},
|
||||
{'label': 'Telur', 'value': 20.0, 'color': AppColors.secondaryBlue},
|
||||
{'label': 'Mentega', 'value': 15.0, 'color': AppColors.secondaryGreen},
|
||||
];
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = ReportController();
|
||||
_controller.loadReports();
|
||||
_controller.startAutoRefresh();
|
||||
}
|
||||
|
||||
Color _statusColor(String status) {
|
||||
switch (status) {
|
||||
|
|
@ -92,6 +42,13 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
final width = MediaQuery.of(context).size.width;
|
||||
final cardWidth = (width - 48) / 2;
|
||||
|
||||
final usageBars = _buildUsageBars(_controller.usageSummary);
|
||||
final usagePie = _buildUsagePie(usageBars);
|
||||
final List<double> demandTrend =
|
||||
_controller.demandTrend.isNotEmpty
|
||||
? _controller.demandTrend
|
||||
: const <double>[28, 32, 40, 36, 44, 50, 48];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.bgLight,
|
||||
appBar: AppBar(
|
||||
|
|
@ -102,70 +59,106 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
style: AppTextStyles.headlineLarge.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Total Produk',
|
||||
value: '24',
|
||||
icon: Icons.shopping_bag,
|
||||
color: AppColors.secondaryBlue,
|
||||
),
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Total Bahan',
|
||||
value: '18',
|
||||
icon: Icons.inventory_2,
|
||||
color: AppColors.primaryBrown,
|
||||
),
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Stok Kritis',
|
||||
value: '3',
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: AppColors.statusError,
|
||||
),
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Total Prediksi',
|
||||
value: '210',
|
||||
icon: Icons.trending_up,
|
||||
color: AppColors.statusSuccess,
|
||||
),
|
||||
],
|
||||
body: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
if (_controller.isLoading &&
|
||||
_controller.stockItems.isEmpty &&
|
||||
_controller.stockHistory.isEmpty &&
|
||||
_controller.predictionItems.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _controller.loadReports(),
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_controller.errorMessage != null) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.statusError.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.statusError.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_controller.errorMessage!,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.statusError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Total Produk',
|
||||
value: _controller.totalProduk.toString(),
|
||||
icon: Icons.shopping_bag,
|
||||
color: AppColors.secondaryBlue,
|
||||
),
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Total Bahan',
|
||||
value: _controller.totalBahan.toString(),
|
||||
icon: Icons.inventory_2,
|
||||
color: AppColors.primaryBrown,
|
||||
),
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Stok Kritis',
|
||||
value: _controller.totalKritis.toString(),
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: AppColors.statusError,
|
||||
),
|
||||
_buildSummaryCard(
|
||||
width: cardWidth,
|
||||
title: 'Total Prediksi',
|
||||
value: _controller.totalPrediksi.toString(),
|
||||
icon: Icons.trending_up,
|
||||
color: AppColors.statusSuccess,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Laporan Stok Bahan'),
|
||||
const SizedBox(height: 12),
|
||||
_buildStockTable(_controller.stockItems),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Riwayat Stok Masuk'),
|
||||
const SizedBox(height: 12),
|
||||
_buildStockHistory(_controller.stockHistory),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Laporan Prediksi Permintaan'),
|
||||
const SizedBox(height: 12),
|
||||
_buildPredictionList(_controller.predictionItems),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Grafik Penggunaan Bahan'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCharts(usageBars, usagePie, demandTrend),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Bahan Kritis'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCriticalItems(_controller.criticalItems),
|
||||
const SizedBox(height: 24),
|
||||
_buildExportButton(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Laporan Stok Bahan'),
|
||||
const SizedBox(height: 12),
|
||||
_buildStockTable(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Riwayat Stok Masuk'),
|
||||
const SizedBox(height: 12),
|
||||
_buildStockHistory(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Laporan Prediksi Permintaan'),
|
||||
const SizedBox(height: 12),
|
||||
_buildPredictionList(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Grafik Penggunaan Bahan'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCharts(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Bahan Kritis'),
|
||||
const SizedBox(height: 12),
|
||||
_buildCriticalItems(),
|
||||
const SizedBox(height: 24),
|
||||
_buildExportButton(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: 4,
|
||||
|
|
@ -278,7 +271,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildStockTable() {
|
||||
Widget _buildStockTable(List<Map<String, dynamic>> items) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgWhite,
|
||||
|
|
@ -287,47 +280,125 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: const [
|
||||
DataColumn(label: Text('Nama Bahan')),
|
||||
DataColumn(label: Text('Stok Tersedia')),
|
||||
DataColumn(label: Text('Status')),
|
||||
],
|
||||
rows:
|
||||
_stockItems.map((item) {
|
||||
final statusColor = _statusColor(item['status'] as String);
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(Text(item['name'] as String)),
|
||||
DataCell(Text('${item['stock']} kg')),
|
||||
DataCell(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
child:
|
||||
items.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Data stok bahan belum tersedia.',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: DataTable(
|
||||
columns: const [
|
||||
DataColumn(label: Text('Nama Bahan')),
|
||||
DataColumn(label: Text('Stok Tersedia')),
|
||||
DataColumn(label: Text('Status')),
|
||||
],
|
||||
rows:
|
||||
items.map((item) {
|
||||
final statusColor = _statusColor(
|
||||
item['status'] as String,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: statusColor,
|
||||
);
|
||||
final unit = item['unit']?.toString() ?? 'kg';
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(Text(item['name'] as String)),
|
||||
DataCell(Text('${item['stock']} $unit')),
|
||||
DataCell(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
item['status'] as String,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStockHistory(List<Map<String, dynamic>> items) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [AppColors.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
children:
|
||||
items.isEmpty
|
||||
? [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Belum ada riwayat stok masuk.',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
: items
|
||||
.map(
|
||||
(entry) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: AppColors.primaryBrown,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
entry['name'] as String,
|
||||
style: AppTextStyles.labelLarge,
|
||||
),
|
||||
subtitle: Text(
|
||||
_dateFormat.format(entry['date'] as DateTime),
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'+${entry['amount']} ${entry['unit'] ?? 'kg'}',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.statusSuccess,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStockHistory() {
|
||||
Widget _buildPredictionList(List<Map<String, dynamic>> items) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -337,97 +408,65 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
),
|
||||
child: Column(
|
||||
children:
|
||||
_stockHistory
|
||||
.map(
|
||||
(entry) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBrown.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: AppColors.primaryBrown,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
entry['name'] as String,
|
||||
style: AppTextStyles.labelLarge,
|
||||
),
|
||||
subtitle: Text(
|
||||
_dateFormat.format(entry['date'] as DateTime),
|
||||
items.isEmpty
|
||||
? [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Belum ada data prediksi.',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'+${entry['amount']} kg',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.statusSuccess,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
]
|
||||
: items
|
||||
.map(
|
||||
(item) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondaryBlue.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.trending_up,
|
||||
color: AppColors.secondaryBlue,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item['product'] as String,
|
||||
style: AppTextStyles.labelLarge,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item['needs']} • ${_dateFormat.format(item['date'] as DateTime)}',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${item['prediction']} unit',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.primaryBrown,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPredictionList() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgWhite,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [AppColors.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
children:
|
||||
_predictionItems
|
||||
.map(
|
||||
(item) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondaryBlue.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.trending_up,
|
||||
color: AppColors.secondaryBlue,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item['product'] as String,
|
||||
style: AppTextStyles.labelLarge,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item['needs']} • ${_dateFormat.format(item['date'] as DateTime)}',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${item['prediction']} unit',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.primaryBrown,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCharts() {
|
||||
Widget _buildCharts(
|
||||
List<Map<String, dynamic>> usageBars,
|
||||
List<Map<String, dynamic>> usagePie,
|
||||
List<double> demandTrend,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildChartCard(
|
||||
|
|
@ -451,13 +490,13 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value < 0 || value >= _usageBars.length) {
|
||||
if (value < 0 || value >= usageBars.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
_usageBars[value.toInt()]['label'] as String,
|
||||
usageBars[value.toInt()]['label'] as String,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
|
|
@ -468,7 +507,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
),
|
||||
),
|
||||
barGroups:
|
||||
_usageBars.asMap().entries.map((entry) {
|
||||
usageBars.asMap().entries.map((entry) {
|
||||
return BarChartGroupData(
|
||||
x: entry.key,
|
||||
barRods: [
|
||||
|
|
@ -524,7 +563,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots:
|
||||
_demandTrend
|
||||
demandTrend
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
|
|
@ -556,7 +595,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections:
|
||||
_usagePie.map((entry) {
|
||||
usagePie.map((entry) {
|
||||
return PieChartSectionData(
|
||||
value: entry['value'] as double,
|
||||
color: entry['color'] as Color,
|
||||
|
|
@ -601,10 +640,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildCriticalItems() {
|
||||
final criticalItems =
|
||||
_stockItems.where((item) => item['status'] == 'Kritis').toList();
|
||||
|
||||
Widget _buildCriticalItems(List<Map<String, dynamic>> criticalItems) {
|
||||
if (criticalItems.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -652,7 +688,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
style: AppTextStyles.labelLarge,
|
||||
),
|
||||
subtitle: Text(
|
||||
'Sisa stok: ${item['stock']} kg',
|
||||
'Sisa stok: ${item['stock']} ${item['unit'] ?? 'kg'}',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
|
|
@ -708,4 +744,62 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildUsageBars(
|
||||
List<Map<String, dynamic>> usageSummary,
|
||||
) {
|
||||
if (usageSummary.isEmpty) {
|
||||
return [
|
||||
{'label': 'Tepung', 'value': 40.0, 'color': AppColors.primaryBrown},
|
||||
{'label': 'Gula', 'value': 28.0, 'color': AppColors.secondaryOrange},
|
||||
{'label': 'Telur', 'value': 18.0, 'color': AppColors.secondaryBlue},
|
||||
{'label': 'Mentega', 'value': 12.0, 'color': AppColors.secondaryGreen},
|
||||
];
|
||||
}
|
||||
|
||||
final colors = [
|
||||
AppColors.primaryBrown,
|
||||
AppColors.secondaryOrange,
|
||||
AppColors.secondaryBlue,
|
||||
AppColors.secondaryGreen,
|
||||
];
|
||||
|
||||
return usageSummary.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
return {
|
||||
'label': item['label'] as String,
|
||||
'value': item['value'] as double,
|
||||
'color': colors[index % colors.length],
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildUsagePie(
|
||||
List<Map<String, dynamic>> usageBars,
|
||||
) {
|
||||
final total = usageBars.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + (item['value'] as double),
|
||||
);
|
||||
if (total == 0) {
|
||||
return usageBars;
|
||||
}
|
||||
|
||||
return usageBars
|
||||
.map(
|
||||
(entry) => {
|
||||
'label': entry['label'],
|
||||
'value': ((entry['value'] as double) / total) * 100,
|
||||
'color': entry['color'],
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class MLService {
|
|||
// API URL - Change based on environment
|
||||
// Untuk emulator Android: 10.0.2.2
|
||||
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
|
||||
static const String baseUrl = 'http://192.168.18.30:5000';
|
||||
static const String baseUrl = 'http://192.168.1.91:5000';
|
||||
|
||||
static const int timeoutSeconds = 30;
|
||||
|
||||
|
|
@ -198,6 +198,51 @@ class MLService {
|
|||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// REPORT ENDPOINTS - LAPORAN
|
||||
// ========================================================================
|
||||
|
||||
/// Helper GET untuk endpoint laporan (response: {status, message, data})
|
||||
static Future<Map<String, dynamic>> _getReport(String path) async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$baseUrl$path'))
|
||||
.timeout(Duration(seconds: timeoutSeconds));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
|
||||
return {
|
||||
'status': false,
|
||||
'message': 'Server error: ${response.statusCode}',
|
||||
'data': [],
|
||||
};
|
||||
} catch (e) {
|
||||
return {'status': false, 'message': 'Connection error: $e', 'data': []};
|
||||
}
|
||||
}
|
||||
|
||||
/// Laporan stok bahan
|
||||
static Future<Map<String, dynamic>> getReportStock() async {
|
||||
return _getReport('/laporan/stok');
|
||||
}
|
||||
|
||||
/// Riwayat stok masuk
|
||||
static Future<Map<String, dynamic>> getReportStockIn() async {
|
||||
return _getReport('/laporan/stok-masuk');
|
||||
}
|
||||
|
||||
/// Laporan prediksi permintaan
|
||||
static Future<Map<String, dynamic>> getReportPredictions() async {
|
||||
return _getReport('/laporan/prediksi');
|
||||
}
|
||||
|
||||
/// Laporan bahan kritis
|
||||
static Future<Map<String, dynamic>> getReportCritical() async {
|
||||
return _getReport('/laporan/bahan-kritis');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// DATABASE ENDPOINTS - PRODUCTS & TRANSACTIONS
|
||||
// ========================================================================
|
||||
|
|
|
|||
278
ml_model/app.py
278
ml_model/app.py
|
|
@ -70,6 +70,85 @@ def has_product_unit_column(connection) -> bool:
|
|||
cursor.close()
|
||||
return has_unit
|
||||
|
||||
def escape_table_name(table_name: str) -> str:
|
||||
return f"`{table_name}`"
|
||||
|
||||
def get_existing_table(connection, candidates: list[str]) -> str | None:
|
||||
for table_name in candidates:
|
||||
if table_exists(connection, table_name):
|
||||
return table_name
|
||||
return None
|
||||
|
||||
def get_existing_column(connection, table_name: str, candidates: list[str]) -> str | None:
|
||||
cursor = connection.cursor()
|
||||
try:
|
||||
for column in candidates:
|
||||
cursor.execute(f"SHOW COLUMNS FROM {escape_table_name(table_name)} LIKE %s", (column,))
|
||||
if cursor.fetchone() is not None:
|
||||
return column
|
||||
finally:
|
||||
cursor.close()
|
||||
return None
|
||||
|
||||
def build_report_response(success: bool, message: str, data: list | None = None, status_code: int = 200):
|
||||
return jsonify({
|
||||
'status': success,
|
||||
'message': message,
|
||||
'data': data or []
|
||||
}), status_code
|
||||
|
||||
def compute_stock_status(stok: float, stok_minimum: float) -> str:
|
||||
if stok > stok_minimum:
|
||||
return 'Aman'
|
||||
if stok == stok_minimum:
|
||||
return 'Rendah'
|
||||
return 'Kritis'
|
||||
|
||||
def fetch_stock_report(connection):
|
||||
table_name = get_existing_table(connection, ['bahan', 'products'])
|
||||
if not table_name:
|
||||
return None, 'Tabel bahan atau products tidak ditemukan'
|
||||
|
||||
name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name'])
|
||||
stock_col = get_existing_column(connection, table_name, ['stok', 'current_stock', 'stock'])
|
||||
min_col = get_existing_column(connection, table_name, ['stok_minimum', 'min_stock', 'minimum_stock'])
|
||||
unit_col = get_existing_column(connection, table_name, ['unit'])
|
||||
|
||||
if not name_col or not stock_col:
|
||||
return None, 'Kolom bahan/stok tidak ditemukan'
|
||||
|
||||
select_min = min_col if min_col else '0'
|
||||
select_unit = unit_col if unit_col else "''"
|
||||
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT {name_col} AS nama_bahan,
|
||||
{stock_col} AS stok,
|
||||
{select_min} AS stok_minimum,
|
||||
{select_unit} AS unit
|
||||
FROM {escape_table_name(table_name)}
|
||||
ORDER BY {name_col}
|
||||
"""
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
stok = float(row.get('stok') or 0)
|
||||
stok_minimum = float(row.get('stok_minimum') or 0)
|
||||
status = compute_stock_status(stok, stok_minimum)
|
||||
data.append({
|
||||
'nama_bahan': row.get('nama_bahan'),
|
||||
'stok': stok,
|
||||
'stok_minimum': stok_minimum,
|
||||
'status': status,
|
||||
'unit': row.get('unit') or 'kg'
|
||||
})
|
||||
|
||||
return data, None
|
||||
|
||||
def grams_to_kg_rounded(grams: float) -> float:
|
||||
"""
|
||||
Convert grams to kilograms with rounding rules:
|
||||
|
|
@ -927,6 +1006,205 @@ def save_prediction():
|
|||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REPORT ENDPOINTS - LAPORAN
|
||||
# ============================================================================
|
||||
|
||||
@app.route('/laporan/stok', methods=['GET'])
|
||||
def laporan_stok():
|
||||
"""Laporan stok bahan dari tabel bahan/products."""
|
||||
try:
|
||||
connection = get_db_connection()
|
||||
if not connection:
|
||||
return build_report_response(False, 'Database connection failed', [], 500)
|
||||
|
||||
data, error_message = fetch_stock_report(connection)
|
||||
connection.close()
|
||||
|
||||
if error_message:
|
||||
return build_report_response(False, error_message, [], 404)
|
||||
|
||||
return build_report_response(True, 'Data berhasil diambil', data, 200)
|
||||
except Exception as e:
|
||||
logger.error(f"Laporan stok error: {str(e)}")
|
||||
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
|
||||
|
||||
|
||||
@app.route('/laporan/bahan-kritis', methods=['GET'])
|
||||
def laporan_bahan_kritis():
|
||||
"""Laporan bahan kritis/ rendah berdasarkan stok minimum."""
|
||||
try:
|
||||
connection = get_db_connection()
|
||||
if not connection:
|
||||
return build_report_response(False, 'Database connection failed', [], 500)
|
||||
|
||||
data, error_message = fetch_stock_report(connection)
|
||||
connection.close()
|
||||
|
||||
if error_message:
|
||||
return build_report_response(False, error_message, [], 404)
|
||||
|
||||
critical_items = [
|
||||
item for item in data
|
||||
if item['status'] in ['Rendah', 'Kritis']
|
||||
]
|
||||
|
||||
return build_report_response(True, 'Data berhasil diambil', critical_items, 200)
|
||||
except Exception as e:
|
||||
logger.error(f"Laporan bahan kritis error: {str(e)}")
|
||||
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
|
||||
|
||||
|
||||
@app.route('/laporan/stok-masuk', methods=['GET'])
|
||||
def laporan_stok_masuk():
|
||||
"""Laporan riwayat stok masuk dari tabel stok_masuk/Stock In/transactions."""
|
||||
try:
|
||||
connection = get_db_connection()
|
||||
if not connection:
|
||||
return build_report_response(False, 'Database connection failed', [], 500)
|
||||
|
||||
table_name = get_existing_table(connection, ['stok_masuk', 'Stock In', 'transactions'])
|
||||
if not table_name:
|
||||
connection.close()
|
||||
return build_report_response(False, 'Tabel stok_masuk/transactions tidak ditemukan', [], 404)
|
||||
|
||||
name_col = get_existing_column(connection, table_name, ['nama_bahan', 'product_name', 'name'])
|
||||
qty_col = get_existing_column(connection, table_name, ['jumlah', 'quantity'])
|
||||
date_col = get_existing_column(connection, table_name, ['tanggal', 'transaction_date', 'created_at'])
|
||||
unit_col = get_existing_column(connection, table_name, ['unit'])
|
||||
product_id_col = get_existing_column(connection, table_name, ['product_id', 'produk_id', 'bahan_id'])
|
||||
|
||||
if not qty_col or not date_col:
|
||||
connection.close()
|
||||
return build_report_response(False, 'Kolom stok masuk tidak lengkap', [], 500)
|
||||
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
table_sql = escape_table_name(table_name)
|
||||
|
||||
if not name_col and product_id_col:
|
||||
product_table = get_existing_table(connection, ['products', 'bahan'])
|
||||
if product_table:
|
||||
product_name_col = get_existing_column(connection, product_table, ['nama_bahan', 'product_name', 'name'])
|
||||
product_unit_col = get_existing_column(connection, product_table, ['unit'])
|
||||
if product_name_col:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT p.{product_name_col} AS nama_bahan,
|
||||
sm.{qty_col} AS jumlah,
|
||||
sm.{date_col} AS tanggal,
|
||||
p.{product_unit_col} AS unit
|
||||
FROM {table_sql} sm
|
||||
JOIN {escape_table_name(product_table)} p ON p.id = sm.{product_id_col}
|
||||
ORDER BY sm.{date_col} DESC
|
||||
"""
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT sm.{qty_col} AS jumlah,
|
||||
sm.{date_col} AS tanggal
|
||||
FROM {table_sql} sm
|
||||
ORDER BY sm.{date_col} DESC
|
||||
"""
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT sm.{qty_col} AS jumlah,
|
||||
sm.{date_col} AS tanggal
|
||||
FROM {table_sql} sm
|
||||
ORDER BY sm.{date_col} DESC
|
||||
"""
|
||||
)
|
||||
else:
|
||||
unit_select = unit_col if unit_col else "''"
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT {name_col} AS nama_bahan,
|
||||
{qty_col} AS jumlah,
|
||||
{date_col} AS tanggal,
|
||||
{unit_select} AS unit
|
||||
FROM {table_sql}
|
||||
ORDER BY {date_col} DESC
|
||||
"""
|
||||
)
|
||||
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
'nama_bahan': row.get('nama_bahan'),
|
||||
'jumlah': float(row.get('jumlah') or 0),
|
||||
'tanggal': row.get('tanggal'),
|
||||
'unit': row.get('unit') or 'kg'
|
||||
})
|
||||
|
||||
return build_report_response(True, 'Data berhasil diambil', data, 200)
|
||||
except Exception as e:
|
||||
logger.error(f"Laporan stok masuk error: {str(e)}")
|
||||
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
|
||||
|
||||
|
||||
@app.route('/laporan/prediksi', methods=['GET'])
|
||||
def laporan_prediksi():
|
||||
"""Laporan prediksi permintaan dari tabel prediksi/predictions."""
|
||||
try:
|
||||
connection = get_db_connection()
|
||||
if not connection:
|
||||
return build_report_response(False, 'Database connection failed', [], 500)
|
||||
|
||||
table_name = get_existing_table(connection, ['prediksi', 'predictions'])
|
||||
if not table_name:
|
||||
connection.close()
|
||||
return build_report_response(False, 'Tabel prediksi/predictions tidak ditemukan', [], 404)
|
||||
|
||||
name_col = get_existing_column(connection, table_name, ['nama_produk', 'product_name', 'name'])
|
||||
result_col = get_existing_column(connection, table_name, ['hasil_prediksi', 'predicted_quantity'])
|
||||
estimate_col = get_existing_column(connection, table_name, ['estimasi_kebutuhan_bahan', 'estimated_needs', 'raw_value'])
|
||||
date_col = get_existing_column(connection, table_name, ['tanggal_prediksi', 'prediction_date', 'created_at'])
|
||||
|
||||
if not result_col or not date_col:
|
||||
connection.close()
|
||||
return build_report_response(False, 'Kolom prediksi tidak lengkap', [], 500)
|
||||
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
table_sql = escape_table_name(table_name)
|
||||
estimate_select = estimate_col if estimate_col else 'NULL'
|
||||
name_select = name_col if name_col else "''"
|
||||
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT {name_select} AS nama_produk,
|
||||
{result_col} AS hasil_prediksi,
|
||||
{estimate_select} AS estimasi_kebutuhan_bahan,
|
||||
{date_col} AS tanggal_prediksi
|
||||
FROM {table_sql}
|
||||
ORDER BY {date_col} DESC
|
||||
"""
|
||||
)
|
||||
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
'nama_produk': row.get('nama_produk'),
|
||||
'hasil_prediksi': float(row.get('hasil_prediksi') or 0),
|
||||
'estimasi_kebutuhan_bahan': row.get('estimasi_kebutuhan_bahan'),
|
||||
'tanggal_prediksi': row.get('tanggal_prediksi')
|
||||
})
|
||||
|
||||
return build_report_response(True, 'Data berhasil diambil', data, 200)
|
||||
except Exception as e:
|
||||
logger.error(f"Laporan prediksi error: {str(e)}")
|
||||
return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RECIPES ENDPOINTS
|
||||
# ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue