Update Flutter app data flow and add Railway scheduler service
This commit is contained in:
parent
7fe9adb1d1
commit
9c988e0f9c
|
|
@ -43,3 +43,7 @@ app.*.map.json
|
||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Firebase credentials and Railway env
|
||||||
|
*-firebase-adminsdk-*.json
|
||||||
|
railway-scheduler/.env
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../models/kandang_model.dart';
|
||||||
class KandangProvider extends ChangeNotifier {
|
class KandangProvider extends ChangeNotifier {
|
||||||
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
||||||
late DatabaseReference _kandangRef;
|
late DatabaseReference _kandangRef;
|
||||||
|
static const String _sharedKandangPath = 'kontrol/kandang';
|
||||||
|
|
||||||
List<Kandang> _kandangs = [];
|
List<Kandang> _kandangs = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
@ -24,33 +25,62 @@ class KandangProvider extends ChangeNotifier {
|
||||||
|
|
||||||
/// Initialize provider dengan user ID dan load data dari Firebase
|
/// Initialize provider dengan user ID dan load data dari Firebase
|
||||||
Future<void> initializeWithUser(String userId) async {
|
Future<void> initializeWithUser(String userId) async {
|
||||||
_kandangRef = _database.ref('users/$userId/kandang');
|
_kandangRef = _database.ref(_sharedKandangPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pastikan ada data awal agar user baru tidak selalu input dari nol.
|
||||||
|
await _ensureInitialKandangData(userId);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Warning ensuring kandang initial data: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear previous listener
|
// Clear previous listener
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
|
|
||||||
// Load data dari Firebase dengan real-time listener
|
// Load data dari Firebase dengan real-time listener
|
||||||
_subscription = _kandangRef.onValue.listen((event) {
|
_subscription = _kandangRef.onValue.listen(
|
||||||
if (event.snapshot.exists) {
|
(event) {
|
||||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
try {
|
||||||
_kandangs = data.entries.map((entry) {
|
if (event.snapshot.exists && event.snapshot.value is Map) {
|
||||||
final kandangData = entry.value as Map<dynamic, dynamic>;
|
final data =
|
||||||
return Kandang(
|
Map<dynamic, dynamic>.from(event.snapshot.value as Map);
|
||||||
id: entry.key,
|
_kandangs =
|
||||||
nama: kandangData['nama'] ?? '',
|
data.entries.where((entry) => entry.value is Map).map((entry) {
|
||||||
jumlahAyam: kandangData['jumlahAyam'] ?? 0,
|
final kandangData =
|
||||||
dibuat: kandangData['dibuat'] != null
|
Map<dynamic, dynamic>.from(entry.value as Map);
|
||||||
? DateTime.parse(kandangData['dibuat'])
|
return Kandang(
|
||||||
: DateTime.now(),
|
id: entry.key.toString(),
|
||||||
infraPath: kandangData['infraPath'],
|
nama: kandangData['nama'] ?? '',
|
||||||
);
|
jumlahAyam: kandangData['jumlahAyam'] ?? 0,
|
||||||
}).toList();
|
dibuat: kandangData['dibuat'] != null
|
||||||
} else {
|
? DateTime.parse(kandangData['dibuat'])
|
||||||
_kandangs = [];
|
: DateTime.now(),
|
||||||
}
|
infraPath: kandangData['infraPath'],
|
||||||
_isLoading = false;
|
);
|
||||||
notifyListeners();
|
}).toList();
|
||||||
});
|
} else {
|
||||||
|
_kandangs = [];
|
||||||
|
}
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error parsing kandang data: $e');
|
||||||
|
}
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error listening kandang data: $error');
|
||||||
|
}
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Start listening to sensors from 'data' folder
|
// Start listening to sensors from 'data' folder
|
||||||
listenToSensors();
|
listenToSensors();
|
||||||
|
|
@ -59,6 +89,39 @@ class KandangProvider extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureInitialKandangData(String userId) async {
|
||||||
|
final sharedSnapshot = await _kandangRef.get();
|
||||||
|
if (sharedSnapshot.exists) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final legacyRef = _database.ref('users/$userId/kandang');
|
||||||
|
final legacySnapshot = await legacyRef.get();
|
||||||
|
if (legacySnapshot.exists) {
|
||||||
|
await _kandangRef.set(legacySnapshot.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Warning reading legacy kandang data: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _kandangRef.set({
|
||||||
|
'kandang1': {
|
||||||
|
'nama': 'Kandang 1',
|
||||||
|
'jumlahAyam': 100,
|
||||||
|
'dibuat': DateTime.now().toIso8601String(),
|
||||||
|
'infraPath': 'infra1',
|
||||||
|
},
|
||||||
|
'kandang2': {
|
||||||
|
'nama': 'Kandang 2',
|
||||||
|
'jumlahAyam': 120,
|
||||||
|
'dibuat': DateTime.now().toIso8601String(),
|
||||||
|
'infraPath': 'infra2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear semua data saat logout
|
/// Clear semua data saat logout
|
||||||
Future<void> clearData() async {
|
Future<void> clearData() async {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
|
|
@ -138,26 +201,23 @@ class KandangProvider extends ChangeNotifier {
|
||||||
_sensorSubscription?.cancel();
|
_sensorSubscription?.cancel();
|
||||||
|
|
||||||
// Listen ke 'data' folder untuk mendapatkan infra1 dan infra2
|
// Listen ke 'data' folder untuk mendapatkan infra1 dan infra2
|
||||||
_sensorSubscription = _database
|
_sensorSubscription = _database.ref('data').onValue.listen(
|
||||||
.ref('data')
|
(event) {
|
||||||
.onValue
|
if (event.snapshot.exists) {
|
||||||
.listen(
|
final data = Map<dynamic, dynamic>.from(
|
||||||
(event) {
|
event.snapshot.value as Map,
|
||||||
if (event.snapshot.exists) {
|
);
|
||||||
final data = Map<dynamic, dynamic>.from(
|
_infra1Value = data['infra1'] ?? 0;
|
||||||
event.snapshot.value as Map,
|
_infra2Value = data['infra2'] ?? 0;
|
||||||
);
|
|
||||||
_infra1Value = data['infra1'] ?? 0;
|
|
||||||
_infra2Value = data['infra2'] ?? 0;
|
|
||||||
|
|
||||||
// Trigger notifyListeners() agar UI terupdate
|
// Trigger notifyListeners() agar UI terupdate
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
print('Error listening to sensors: $error');
|
print('Error listening to sensors: $error');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error setting up sensor listener: $e');
|
print('Error setting up sensor listener: $e');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,89 +5,13 @@ import '../models/panen_model.dart';
|
||||||
|
|
||||||
class PanenProvider extends ChangeNotifier {
|
class PanenProvider extends ChangeNotifier {
|
||||||
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
||||||
|
static const bool _allowFlutterAutoCaptureWrite = false;
|
||||||
|
|
||||||
// Tracking snapshot untuk time-windowing logic
|
// Tracking snapshot untuk time-windowing logic
|
||||||
Map<String, int> _snapshotPagiHariIni = {}; // kandangId -> nilaiPagi
|
final Map<String, int> _snapshotPagiHariIni = {}; // kandangId -> nilaiPagi
|
||||||
Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
|
final Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
|
||||||
|
|
||||||
final List<Panen> _panens = [
|
final List<Panen> _panens = [];
|
||||||
// Tanggal 7 March 2026 - Kandang 1
|
|
||||||
Panen(
|
|
||||||
id: 'panen_1',
|
|
||||||
kandangId: 'kandang_1',
|
|
||||||
kandangNama: 'Kandang 1',
|
|
||||||
jumlahTelur: 45,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 7, 9, 0), // 7 March 2026, 09:00
|
|
||||||
jam: '09:00',
|
|
||||||
catatan: 'Panen pagi - Kondisi baik',
|
|
||||||
),
|
|
||||||
Panen(
|
|
||||||
id: 'panen_2',
|
|
||||||
kandangId: 'kandang_1',
|
|
||||||
kandangNama: 'Kandang 1',
|
|
||||||
jumlahTelur: 42,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 7, 15, 0), // 7 March 2026, 15:00
|
|
||||||
jam: '15:00',
|
|
||||||
catatan: 'Panen sore - Ada 3 telur pecah',
|
|
||||||
),
|
|
||||||
// Tanggal 7 March 2026 - Kandang 2
|
|
||||||
Panen(
|
|
||||||
id: 'panen_3',
|
|
||||||
kandangId: 'kandang_2',
|
|
||||||
kandangNama: 'Kandang 2',
|
|
||||||
jumlahTelur: 68,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 7, 9, 0), // 7 March 2026, 09:00
|
|
||||||
jam: '09:00',
|
|
||||||
catatan: 'Panen pagi - Produksi meningkat',
|
|
||||||
),
|
|
||||||
Panen(
|
|
||||||
id: 'panen_4',
|
|
||||||
kandangId: 'kandang_2',
|
|
||||||
kandangNama: 'Kandang 2',
|
|
||||||
jumlahTelur: 71,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 7, 15, 0), // 7 March 2026, 15:00
|
|
||||||
jam: '15:00',
|
|
||||||
catatan: 'Panen sore - Normal',
|
|
||||||
),
|
|
||||||
// Tanggal 8 March 2026 - Kandang 1
|
|
||||||
Panen(
|
|
||||||
id: 'panen_5',
|
|
||||||
kandangId: 'kandang_1',
|
|
||||||
kandangNama: 'Kandang 1',
|
|
||||||
jumlahTelur: 48,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 8, 9, 0), // 8 March 2026, 09:00
|
|
||||||
jam: '09:00',
|
|
||||||
catatan: 'Panen pagi - Produksi stabil',
|
|
||||||
),
|
|
||||||
Panen(
|
|
||||||
id: 'panen_6',
|
|
||||||
kandangId: 'kandang_1',
|
|
||||||
kandangNama: 'Kandang 1',
|
|
||||||
jumlahTelur: 44,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 8, 15, 0), // 8 March 2026, 15:00
|
|
||||||
jam: '15:00',
|
|
||||||
catatan: 'Panen sore - Normal',
|
|
||||||
),
|
|
||||||
// Tanggal 8 March 2026 - Kandang 2
|
|
||||||
Panen(
|
|
||||||
id: 'panen_7',
|
|
||||||
kandangId: 'kandang_2',
|
|
||||||
kandangNama: 'Kandang 2',
|
|
||||||
jumlahTelur: 65,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 8, 9, 0), // 8 March 2026, 09:00
|
|
||||||
jam: '09:00',
|
|
||||||
catatan: 'Panen pagi - Kondisi baik',
|
|
||||||
),
|
|
||||||
Panen(
|
|
||||||
id: 'panen_8',
|
|
||||||
kandangId: 'kandang_2',
|
|
||||||
kandangNama: 'Kandang 2',
|
|
||||||
jumlahTelur: 69,
|
|
||||||
tanggalPanen: DateTime(2026, 3, 8, 15, 0), // 8 March 2026, 15:00
|
|
||||||
jam: '15:00',
|
|
||||||
catatan: 'Panen sore - Normal',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
List<Panen> get panens => _panens;
|
List<Panen> get panens => _panens;
|
||||||
|
|
||||||
|
|
@ -293,8 +217,10 @@ class PanenProvider extends ChangeNotifier {
|
||||||
_panens.add(newPanen);
|
_panens.add(newPanen);
|
||||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||||
|
|
||||||
// Simpan ke Firebase
|
// Guard: auto-capture write hanya dari Railway worker.
|
||||||
await _savePanenToFirebase(newPanen);
|
if (_allowFlutterAutoCaptureWrite) {
|
||||||
|
await _savePanenToFirebase(newPanen);
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('✅ Panen Pagi recorded untuk $kandangNama: $jumlahTelur telur');
|
print('✅ Panen Pagi recorded untuk $kandangNama: $jumlahTelur telur');
|
||||||
|
|
@ -361,8 +287,10 @@ class PanenProvider extends ChangeNotifier {
|
||||||
_panens.add(newPanen);
|
_panens.add(newPanen);
|
||||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||||
|
|
||||||
// Simpan ke Firebase
|
// Guard: auto-capture write hanya dari Railway worker.
|
||||||
await _savePanenToFirebase(newPanen);
|
if (_allowFlutterAutoCaptureWrite) {
|
||||||
|
await _savePanenToFirebase(newPanen);
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print(
|
print(
|
||||||
|
|
@ -381,7 +309,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
/// Simpan panen ke Firebase
|
/// Simpan panen ke Firebase
|
||||||
Future<void> _savePanenToFirebase(Panen panen) async {
|
Future<void> _savePanenToFirebase(Panen panen) async {
|
||||||
try {
|
try {
|
||||||
final ref = _database.ref('riwayat').push();
|
final ref = _database.ref('riwayat/records').push();
|
||||||
await ref.set({
|
await ref.set({
|
||||||
'id': panen.id,
|
'id': panen.id,
|
||||||
'kandang_id': panen.kandangId,
|
'kandang_id': panen.kandangId,
|
||||||
|
|
@ -395,6 +323,8 @@ class PanenProvider extends ChangeNotifier {
|
||||||
'catatan': panen.catatan,
|
'catatan': panen.catatan,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await _updateRiwayatSummary(panen);
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('✅ Panen saved to Firebase: ${panen.id}');
|
print('✅ Panen saved to Firebase: ${panen.id}');
|
||||||
}
|
}
|
||||||
|
|
@ -405,6 +335,52 @@ class PanenProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _updateRiwayatSummary(Panen panen) async {
|
||||||
|
final now = panen.tanggalPanen;
|
||||||
|
final dateKey =
|
||||||
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||||
|
final summaryRef = _database.ref('riwayat/summary');
|
||||||
|
|
||||||
|
await summaryRef.runTransaction((current) {
|
||||||
|
final data = Map<dynamic, dynamic>.from(
|
||||||
|
(current as Map?) ?? <dynamic, dynamic>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
int telurHariIni = (data['telur_hari_ini'] ?? 0) as int;
|
||||||
|
int totalTelur = (data['total_telur'] ?? 0) as int;
|
||||||
|
int kandang1 = (data['kandang1_hari_ini'] ?? 0) as int;
|
||||||
|
int kandang2 = (data['kandang2_hari_ini'] ?? 0) as int;
|
||||||
|
final lastDate = '${data['last_reset_date'] ?? ''}';
|
||||||
|
|
||||||
|
if (lastDate != dateKey) {
|
||||||
|
telurHariIni = 0;
|
||||||
|
kandang1 = 0;
|
||||||
|
kandang2 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
telurHariIni += panen.jumlahTelur;
|
||||||
|
totalTelur += panen.jumlahTelur;
|
||||||
|
|
||||||
|
final kandangIdLower = panen.kandangId.toLowerCase();
|
||||||
|
if (kandangIdLower == 'kandang1' || kandangIdLower == 'kandang_1') {
|
||||||
|
kandang1 += panen.jumlahTelur;
|
||||||
|
}
|
||||||
|
if (kandangIdLower == 'kandang2' || kandangIdLower == 'kandang_2') {
|
||||||
|
kandang2 += panen.jumlahTelur;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Transaction.success({
|
||||||
|
...data,
|
||||||
|
'telur_hari_ini': telurHariIni,
|
||||||
|
'kandang1_hari_ini': kandang1,
|
||||||
|
'kandang2_hari_ini': kandang2,
|
||||||
|
'total_telur': totalTelur,
|
||||||
|
'last_reset_date': dateKey,
|
||||||
|
'updated_at': DateTime.now().toIso8601String(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Load snapshots dari Firebase ketika app start (untuk sync state)
|
/// Load snapshots dari Firebase ketika app start (untuk sync state)
|
||||||
Future<void> loadTodaySnapshots() async {
|
Future<void> loadTodaySnapshots() async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -457,7 +433,7 @@ class PanenProvider extends ChangeNotifier {
|
||||||
/// Restore historical panen dari Firebase saat app start
|
/// Restore historical panen dari Firebase saat app start
|
||||||
Future<void> restorePanenHistoryFromFirebase() async {
|
Future<void> restorePanenHistoryFromFirebase() async {
|
||||||
try {
|
try {
|
||||||
final ref = _database.ref('riwayat');
|
final ref = _database.ref('riwayat/records');
|
||||||
final snapshot = await ref.get();
|
final snapshot = await ref.get();
|
||||||
|
|
||||||
if (snapshot.exists) {
|
if (snapshot.exists) {
|
||||||
|
|
@ -465,13 +441,18 @@ class PanenProvider extends ChangeNotifier {
|
||||||
_panens.clear();
|
_panens.clear();
|
||||||
|
|
||||||
data.forEach((key, value) {
|
data.forEach((key, value) {
|
||||||
final panenData = Map<dynamic, dynamic>.from(value as Map);
|
if (value is! Map) return;
|
||||||
|
final panenData = Map<dynamic, dynamic>.from(value);
|
||||||
|
final tanggalRaw = panenData['tanggal_panen'];
|
||||||
|
final tanggal = DateTime.tryParse('${tanggalRaw ?? ''}');
|
||||||
|
if (tanggal == null) return;
|
||||||
|
|
||||||
final panen = Panen(
|
final panen = Panen(
|
||||||
id: panenData['id'] ?? '',
|
id: panenData['id'] ?? '',
|
||||||
kandangId: panenData['kandang_id'] ?? '',
|
kandangId: panenData['kandang_id'] ?? '',
|
||||||
kandangNama: panenData['kandang_nama'] ?? '',
|
kandangNama: panenData['kandang_nama'] ?? '',
|
||||||
jumlahTelur: panenData['jumlah_telur'] ?? 0,
|
jumlahTelur: panenData['jumlah_telur'] ?? 0,
|
||||||
tanggalPanen: DateTime.parse(panenData['tanggal_panen'] ?? ''),
|
tanggalPanen: tanggal,
|
||||||
jam: panenData['jam'] ?? '',
|
jam: panenData['jam'] ?? '',
|
||||||
catatan: panenData['catatan'] ?? '',
|
catatan: panenData['catatan'] ?? '',
|
||||||
jenisPanen: panenData['jenis_panen'],
|
jenisPanen: panenData['jenis_panen'],
|
||||||
|
|
@ -480,6 +461,39 @@ class PanenProvider extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
_panens.add(panen);
|
_panens.add(panen);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback legacy structure: /riwayat langsung berisi records + summary.
|
||||||
|
final legacySnapshot = await _database.ref('riwayat').get();
|
||||||
|
if (legacySnapshot.exists) {
|
||||||
|
final legacyData = Map<dynamic, dynamic>.from(
|
||||||
|
legacySnapshot.value as Map,
|
||||||
|
);
|
||||||
|
_panens.clear();
|
||||||
|
|
||||||
|
legacyData.forEach((key, value) {
|
||||||
|
if (value is! Map) return;
|
||||||
|
final panenData = Map<dynamic, dynamic>.from(value);
|
||||||
|
if (!panenData.containsKey('kandang_id')) return;
|
||||||
|
final tanggalRaw = panenData['tanggal_panen'];
|
||||||
|
final tanggal = DateTime.tryParse('${tanggalRaw ?? ''}');
|
||||||
|
if (tanggal == null) return;
|
||||||
|
|
||||||
|
_panens.add(
|
||||||
|
Panen(
|
||||||
|
id: panenData['id'] ?? '',
|
||||||
|
kandangId: panenData['kandang_id'] ?? '',
|
||||||
|
kandangNama: panenData['kandang_nama'] ?? '',
|
||||||
|
jumlahTelur: panenData['jumlah_telur'] ?? 0,
|
||||||
|
tanggalPanen: tanggal,
|
||||||
|
jam: panenData['jam'] ?? '',
|
||||||
|
catatan: panenData['catatan'] ?? '',
|
||||||
|
jenisPanen: panenData['jenis_panen'],
|
||||||
|
sensorSnapshot: panenData['sensor_snapshot'],
|
||||||
|
panenSebelumnya: panenData['panen_sebelumnya'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,37 +18,91 @@ class PenjadwalanProvider extends ChangeNotifier {
|
||||||
Future<void> initializeWithUser(String userId) async {
|
Future<void> initializeWithUser(String userId) async {
|
||||||
_penjadwalanRef = _database.ref('kontrol/penjadwalan');
|
_penjadwalanRef = _database.ref('kontrol/penjadwalan');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pastikan jadwal awal tersedia agar login pertama langsung ada isi.
|
||||||
|
await _ensureInitialPenjadwalanData();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Warning ensuring penjadwalan initial data: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear previous listener
|
// Clear previous listener
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
|
|
||||||
// Load data dari Firebase dengan real-time listener
|
// Load data dari Firebase dengan real-time listener
|
||||||
_subscription = _penjadwalanRef.onValue.listen((event) {
|
_subscription = _penjadwalanRef.onValue.listen(
|
||||||
if (event.snapshot.exists) {
|
(event) {
|
||||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
try {
|
||||||
_penjadwalans = data.entries.map((entry) {
|
if (event.snapshot.exists && event.snapshot.value is Map) {
|
||||||
return Penjadwalan.fromJson({
|
final data =
|
||||||
'id': entry.key,
|
Map<dynamic, dynamic>.from(event.snapshot.value as Map);
|
||||||
...entry.value as Map<dynamic, dynamic>,
|
_penjadwalans =
|
||||||
});
|
data.entries.where((entry) => entry.value is Map).map((entry) {
|
||||||
}).toList();
|
return Penjadwalan.fromJson({
|
||||||
|
'id': entry.key,
|
||||||
|
...Map<dynamic, dynamic>.from(entry.value as Map),
|
||||||
|
});
|
||||||
|
}).toList();
|
||||||
|
|
||||||
// Sort berdasarkan nomor untuk urutan yang rapi
|
// Sort berdasarkan nomor untuk urutan yang rapi
|
||||||
_penjadwalans.sort((a, b) {
|
_penjadwalans.sort((a, b) {
|
||||||
int numA = int.tryParse(a.id.replaceAll('penjadwalan', '')) ?? 999;
|
int numA =
|
||||||
int numB = int.tryParse(b.id.replaceAll('penjadwalan', '')) ?? 999;
|
int.tryParse(a.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||||
return numA.compareTo(numB);
|
int numB =
|
||||||
});
|
int.tryParse(b.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||||
} else {
|
return numA.compareTo(numB);
|
||||||
_penjadwalans = [];
|
});
|
||||||
}
|
} else {
|
||||||
_isLoading = false;
|
_penjadwalans = [];
|
||||||
notifyListeners();
|
}
|
||||||
});
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error parsing penjadwalan data: $e');
|
||||||
|
}
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error listening penjadwalan data: $error');
|
||||||
|
}
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureInitialPenjadwalanData() async {
|
||||||
|
final snapshot = await _penjadwalanRef.get();
|
||||||
|
if (snapshot.exists) return;
|
||||||
|
|
||||||
|
await _penjadwalanRef.set({
|
||||||
|
'penjadwalan1': {
|
||||||
|
'kandangId': 'kandang1',
|
||||||
|
'kandangNama': 'Kandang 1',
|
||||||
|
'jam': '09:00',
|
||||||
|
'durasi': '30 menit',
|
||||||
|
'keterangan': 'Panen pagi',
|
||||||
|
'aktif': true,
|
||||||
|
},
|
||||||
|
'penjadwalan2': {
|
||||||
|
'kandangId': 'kandang2',
|
||||||
|
'kandangNama': 'Kandang 2',
|
||||||
|
'jam': '15:00',
|
||||||
|
'durasi': '30 menit',
|
||||||
|
'keterangan': 'Panen sore',
|
||||||
|
'aktif': true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Get next penjadwalan number (penjadwalan1, penjadwalan2, dst)
|
/// Get next penjadwalan number (penjadwalan1, penjadwalan2, dst)
|
||||||
String _getNextPenjadwalanKey() {
|
String _getNextPenjadwalanKey() {
|
||||||
if (_penjadwalans.isEmpty) return 'penjadwalan1';
|
if (_penjadwalans.isEmpty) return 'penjadwalan1';
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,20 @@ class TelurProvider extends ChangeNotifier {
|
||||||
late DatabaseReference _riwayatRef;
|
late DatabaseReference _riwayatRef;
|
||||||
|
|
||||||
int _telurHariIni = 0;
|
int _telurHariIni = 0;
|
||||||
|
int _kandang1HariIni = 0;
|
||||||
|
int _kandang2HariIni = 0;
|
||||||
int _totalTelur = 0;
|
int _totalTelur = 0;
|
||||||
String _lastResetDate = '';
|
String _lastResetDate = '';
|
||||||
bool _isSynced = false;
|
bool _isSynced = false;
|
||||||
|
|
||||||
int get telurHariIni => _telurHariIni;
|
int get telurHariIni => _telurHariIni;
|
||||||
|
int get kandang1HariIni => _kandang1HariIni;
|
||||||
|
int get kandang2HariIni => _kandang2HariIni;
|
||||||
int get totalTelur => _totalTelur;
|
int get totalTelur => _totalTelur;
|
||||||
bool get isSynced => _isSynced;
|
bool get isSynced => _isSynced;
|
||||||
|
|
||||||
TelurProvider() {
|
TelurProvider() {
|
||||||
_riwayatRef = _database.ref('riwayat');
|
_riwayatRef = _database.ref('riwayat/summary');
|
||||||
_initializeRiwayat();
|
_initializeRiwayat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,6 +32,8 @@ class TelurProvider extends ChangeNotifier {
|
||||||
// Create initial structure
|
// Create initial structure
|
||||||
await _riwayatRef.set({
|
await _riwayatRef.set({
|
||||||
'telur_hari_ini': 0,
|
'telur_hari_ini': 0,
|
||||||
|
'kandang1_hari_ini': 0,
|
||||||
|
'kandang2_hari_ini': 0,
|
||||||
'total_telur': 0,
|
'total_telur': 0,
|
||||||
'last_reset_date': DateTime.now().toIso8601String().split('T')[0],
|
'last_reset_date': DateTime.now().toIso8601String().split('T')[0],
|
||||||
});
|
});
|
||||||
|
|
@ -41,6 +47,8 @@ class TelurProvider extends ChangeNotifier {
|
||||||
if (event.snapshot.exists) {
|
if (event.snapshot.exists) {
|
||||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
||||||
_telurHariIni = data['telur_hari_ini'] ?? 0;
|
_telurHariIni = data['telur_hari_ini'] ?? 0;
|
||||||
|
_kandang1HariIni = data['kandang1_hari_ini'] ?? 0;
|
||||||
|
_kandang2HariIni = data['kandang2_hari_ini'] ?? 0;
|
||||||
_totalTelur = data['total_telur'] ?? 0;
|
_totalTelur = data['total_telur'] ?? 0;
|
||||||
_lastResetDate = data['last_reset_date'] ?? '';
|
_lastResetDate = data['last_reset_date'] ?? '';
|
||||||
|
|
||||||
|
|
@ -65,6 +73,8 @@ class TelurProvider extends ChangeNotifier {
|
||||||
if (snapshot.exists) {
|
if (snapshot.exists) {
|
||||||
final data = snapshot.value as Map<dynamic, dynamic>;
|
final data = snapshot.value as Map<dynamic, dynamic>;
|
||||||
_telurHariIni = data['telur_hari_ini'] ?? 0;
|
_telurHariIni = data['telur_hari_ini'] ?? 0;
|
||||||
|
_kandang1HariIni = data['kandang1_hari_ini'] ?? 0;
|
||||||
|
_kandang2HariIni = data['kandang2_hari_ini'] ?? 0;
|
||||||
_totalTelur = data['total_telur'] ?? 0;
|
_totalTelur = data['total_telur'] ?? 0;
|
||||||
_lastResetDate = data['last_reset_date'] ?? '';
|
_lastResetDate = data['last_reset_date'] ?? '';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -82,9 +92,13 @@ class TelurProvider extends ChangeNotifier {
|
||||||
// Reset telur_hari_ini for new day
|
// Reset telur_hari_ini for new day
|
||||||
await _riwayatRef.update({
|
await _riwayatRef.update({
|
||||||
'telur_hari_ini': 0,
|
'telur_hari_ini': 0,
|
||||||
|
'kandang1_hari_ini': 0,
|
||||||
|
'kandang2_hari_ini': 0,
|
||||||
'last_reset_date': todayDate,
|
'last_reset_date': todayDate,
|
||||||
});
|
});
|
||||||
_telurHariIni = 0;
|
_telurHariIni = 0;
|
||||||
|
_kandang1HariIni = 0;
|
||||||
|
_kandang2HariIni = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -121,9 +135,13 @@ class TelurProvider extends ChangeNotifier {
|
||||||
final todayDate = DateTime.now().toIso8601String().split('T')[0];
|
final todayDate = DateTime.now().toIso8601String().split('T')[0];
|
||||||
await _riwayatRef.update({
|
await _riwayatRef.update({
|
||||||
'telur_hari_ini': 0,
|
'telur_hari_ini': 0,
|
||||||
|
'kandang1_hari_ini': 0,
|
||||||
|
'kandang2_hari_ini': 0,
|
||||||
'last_reset_date': todayDate,
|
'last_reset_date': todayDate,
|
||||||
});
|
});
|
||||||
_telurHariIni = 0;
|
_telurHariIni = 0;
|
||||||
|
_kandang1HariIni = 0;
|
||||||
|
_kandang2HariIni = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error resetting telur hari ini: $e');
|
print('Error resetting telur hari ini: $e');
|
||||||
|
|
@ -134,10 +152,14 @@ class TelurProvider extends ChangeNotifier {
|
||||||
try {
|
try {
|
||||||
await _riwayatRef.set({
|
await _riwayatRef.set({
|
||||||
'telur_hari_ini': 0,
|
'telur_hari_ini': 0,
|
||||||
|
'kandang1_hari_ini': 0,
|
||||||
|
'kandang2_hari_ini': 0,
|
||||||
'total_telur': 0,
|
'total_telur': 0,
|
||||||
'last_reset_date': DateTime.now().toIso8601String().split('T')[0],
|
'last_reset_date': DateTime.now().toIso8601String().split('T')[0],
|
||||||
});
|
});
|
||||||
_telurHariIni = 0;
|
_telurHariIni = 0;
|
||||||
|
_kandang1HariIni = 0;
|
||||||
|
_kandang2HariIni = 0;
|
||||||
_totalTelur = 0;
|
_totalTelur = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../providers/kandang_provider.dart';
|
import '../providers/kandang_provider.dart';
|
||||||
import '../providers/panen_provider.dart';
|
import '../providers/panen_provider.dart';
|
||||||
import '../providers/penjadwalan_provider.dart';
|
import '../providers/penjadwalan_provider.dart';
|
||||||
|
import '../providers/riwayat_provider.dart';
|
||||||
|
|
||||||
class DashboardPage extends StatelessWidget {
|
class DashboardPage extends StatelessWidget {
|
||||||
const DashboardPage({super.key});
|
const DashboardPage({super.key});
|
||||||
|
|
@ -10,6 +11,7 @@ class DashboardPage extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final kandangProvider = context.watch<KandangProvider>();
|
final kandangProvider = context.watch<KandangProvider>();
|
||||||
|
final telurProvider = context.watch<TelurProvider>();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
|
|
@ -77,7 +79,7 @@ class DashboardPage extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${kandangProvider.infra1Value + kandangProvider.infra2Value}',
|
'${telurProvider.telurHariIni}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -88,6 +90,32 @@ class DashboardPage extends StatelessWidget {
|
||||||
Text('🥚', style: TextStyle(fontSize: 40)),
|
Text('🥚', style: TextStyle(fontSize: 40)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Kandang 1: ${telurProvider.kandang1HariIni}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Kandang 2: ${telurProvider.kandang2HariIni}',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/penjadwalan_provider.dart';
|
import '../providers/penjadwalan_provider.dart';
|
||||||
import '../providers/kandang_provider.dart';
|
import '../providers/kandang_provider.dart';
|
||||||
|
import '../providers/panen_provider.dart';
|
||||||
import 'dashboard_page.dart';
|
import 'dashboard_page.dart';
|
||||||
import 'kontrol_page.dart';
|
import 'kontrol_page.dart';
|
||||||
import 'riwayat_page.dart';
|
import 'riwayat_page.dart';
|
||||||
|
|
@ -17,6 +18,7 @@ class HomePage extends StatefulWidget {
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
int _selectedIndex = 0;
|
int _selectedIndex = 0;
|
||||||
|
String? _initializedForUserId;
|
||||||
|
|
||||||
final List<Widget> _pages = [
|
final List<Widget> _pages = [
|
||||||
const DashboardPage(),
|
const DashboardPage(),
|
||||||
|
|
@ -24,6 +26,38 @@ class _HomePageState extends State<HomePage> {
|
||||||
const RiwayatPage(),
|
const RiwayatPage(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
|
||||||
|
final authProvider = context.read<AuthProvider>();
|
||||||
|
final userId = authProvider.user?.uid;
|
||||||
|
|
||||||
|
if (userId == null || userId == _initializedForUserId) return;
|
||||||
|
_initializedForUserId = userId;
|
||||||
|
|
||||||
|
// Re-init provider saat app restart/hot restart ketika user masih login.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
try {
|
||||||
|
await context.read<PenjadwalanProvider>().initializeWithUser(userId);
|
||||||
|
if (!mounted) return;
|
||||||
|
await context.read<KandangProvider>().initializeWithUser(userId);
|
||||||
|
if (!mounted) return;
|
||||||
|
final panenProvider = context.read<PanenProvider>();
|
||||||
|
await panenProvider.loadTodaySnapshots();
|
||||||
|
await panenProvider.restorePanenHistoryFromFirebase();
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Sinkronisasi data sedang bermasalah, coba lagi.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authProvider = context.watch<AuthProvider>();
|
final authProvider = context.watch<AuthProvider>();
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,19 @@ class _LoginPageState extends State<LoginPage> {
|
||||||
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
||||||
final kandangProvider = context.read<KandangProvider>();
|
final kandangProvider = context.read<KandangProvider>();
|
||||||
|
|
||||||
await penjadwalanProvider.initializeWithUser(userId);
|
try {
|
||||||
await kandangProvider.initializeWithUser(userId);
|
await penjadwalanProvider.initializeWithUser(userId);
|
||||||
|
await kandangProvider.initializeWithUser(userId);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Login berhasil, tetapi sinkronisasi data belum sempurna.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
|
||||||
|
|
@ -468,7 +468,7 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,47 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (panen.jenisPanen ?? '').toLowerCase() ==
|
||||||
|
'sore'
|
||||||
|
? Colors.blue.shade50
|
||||||
|
: Colors.green.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
(panen.jenisPanen ?? 'manual').toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: (panen.jenisPanen ?? '').toLowerCase() ==
|
||||||
|
'sore'
|
||||||
|
? Colors.blue.shade700
|
||||||
|
: Colors.green.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
panen.catatan,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,19 @@ class _SignupPageState extends State<SignupPage> {
|
||||||
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
||||||
final kandangProvider = context.read<KandangProvider>();
|
final kandangProvider = context.read<KandangProvider>();
|
||||||
|
|
||||||
await penjadwalanProvider.initializeWithUser(userId);
|
try {
|
||||||
await kandangProvider.initializeWithUser(userId);
|
await penjadwalanProvider.initializeWithUser(userId);
|
||||||
|
await kandangProvider.initializeWithUser(userId);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Akun berhasil dibuat, tetapi sinkronisasi data belum sempurna.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Railway Runtime
|
||||||
|
PORT=3000
|
||||||
|
TZ=Asia/Jakarta
|
||||||
|
SCHEDULER_ENABLED=true
|
||||||
|
COMPACT_MODE=true
|
||||||
|
RIWAYAT_MAX_RECORDS=500
|
||||||
|
SCHEDULER_RUNS_RETAIN_DAYS=14
|
||||||
|
|
||||||
|
# Firebase Admin SDK
|
||||||
|
# Opsi A (disarankan): tempel JSON service account penuh ke satu variable.
|
||||||
|
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n","client_email":"..."}
|
||||||
|
|
||||||
|
# Opsi B: pakai field terpisah.
|
||||||
|
FIREBASE_PROJECT_ID=telurku-fa78c
|
||||||
|
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@telurku-fa78c.iam.gserviceaccount.com
|
||||||
|
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\nREPLACE_ME\\n-----END PRIVATE KEY-----\\n"
|
||||||
|
FIREBASE_DATABASE_URL=https://telurku-fa78c-default-rtdb.asia-southeast1.firebasedatabase.app
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
# TelurKu Railway Scheduler
|
||||||
|
|
||||||
|
Worker Node.js untuk menjalankan penjadwalan panen otomatis di Railway.
|
||||||
|
|
||||||
|
## Fungsi Utama
|
||||||
|
|
||||||
|
- Menjalankan cek jadwal setiap menit (timezone default `Asia/Jakarta`).
|
||||||
|
- Membaca jadwal aktif dari `kontrol/penjadwalan`.
|
||||||
|
- Membaca sensor dari `data` berdasarkan `infraPath` kandang (`kontrol/kandang`).
|
||||||
|
- Menyimpan hasil ke:
|
||||||
|
- `panen_snapshot/{yyyy-MM-dd}`
|
||||||
|
- `riwayat/records`
|
||||||
|
- `riwayat/summary`
|
||||||
|
- Mencegah duplikasi eksekusi dengan lock harian di `scheduler_runs/{yyyy-MM-dd}`.
|
||||||
|
|
||||||
|
## Struktur Folder
|
||||||
|
|
||||||
|
- `worker.js` : engine scheduler + health endpoint
|
||||||
|
- `package.json` : dependency dan start command
|
||||||
|
- `Dockerfile` : container build untuk Railway
|
||||||
|
- `.dockerignore` : optimasi image build
|
||||||
|
- `.env.example` : template environment variables
|
||||||
|
- `railway.json` : config deploy Railway
|
||||||
|
|
||||||
|
## Environment Variables (Railway)
|
||||||
|
|
||||||
|
Gunakan nilai ini di Railway Variables:
|
||||||
|
|
||||||
|
- `PORT=3000`
|
||||||
|
- `TZ=Asia/Jakarta`
|
||||||
|
- `SCHEDULER_ENABLED=true`
|
||||||
|
- `FIREBASE_SERVICE_ACCOUNT_JSON=<json-service-account-penuh>` (opsi paling mudah)
|
||||||
|
- `FIREBASE_PROJECT_ID=telurku-fa78c`
|
||||||
|
- `FIREBASE_CLIENT_EMAIL=<service-account-email>`
|
||||||
|
- `FIREBASE_PRIVATE_KEY=<private-key-dengan-escaped-newline>`
|
||||||
|
- `FIREBASE_DATABASE_URL=https://telurku-fa78c-default-rtdb.asia-southeast1.firebasedatabase.app`
|
||||||
|
|
||||||
|
Catatan untuk `FIREBASE_PRIVATE_KEY`:
|
||||||
|
- Gunakan format satu baris dengan `\n` (lihat `.env.example`).
|
||||||
|
- Jangan commit private key ke Git.
|
||||||
|
|
||||||
|
Catatan untuk `FIREBASE_SERVICE_ACCOUNT_JSON`:
|
||||||
|
- Bisa isi seluruh JSON service account dalam satu variable Railway.
|
||||||
|
- Tetap jangan taruh nilainya di source code atau file yang di-commit.
|
||||||
|
|
||||||
|
## Deploy di Railway
|
||||||
|
|
||||||
|
1. Buat service baru dari repo ini.
|
||||||
|
2. Set **Root Directory** ke `railway-scheduler`.
|
||||||
|
3. Railway bisa build via `Dockerfile` atau Nixpacks.
|
||||||
|
4. Isi semua environment variables.
|
||||||
|
5. Deploy.
|
||||||
|
6. Cek log, pastikan muncul:
|
||||||
|
- `[init] Firebase Admin connected`
|
||||||
|
- `[init] Scheduler active`
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
- Endpoint: `/health`
|
||||||
|
- Response: `{ "ok": true, "service": "telurku-railway-scheduler" }`
|
||||||
|
|
||||||
|
## Local Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd railway-scheduler
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# isi env yang benar
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Path yang Dipakai
|
||||||
|
|
||||||
|
- Read: `kontrol/penjadwalan`
|
||||||
|
- Read: `kontrol/kandang`
|
||||||
|
- Read: `data`
|
||||||
|
- Write: `riwayat/records`
|
||||||
|
- Write: `riwayat/summary`
|
||||||
|
- Write: `panen_snapshot/{date}`
|
||||||
|
- Write lock: `scheduler_runs/{date}`
|
||||||
|
|
||||||
|
## Reset Otomatis Sore
|
||||||
|
|
||||||
|
Setelah jadwal sore berhasil dieksekusi, worker akan me-reset:
|
||||||
|
|
||||||
|
- `data/infra1 = 0`
|
||||||
|
- `data/infra2 = 0`
|
||||||
|
|
||||||
|
Ringkasan harian di `riwayat/summary` tetap disimpan untuk kebutuhan tampilan Flutter.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "telurku-railway-scheduler",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Railway worker for automatic egg harvest scheduling",
|
||||||
|
"main": "worker.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node worker.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"firebase-admin": "^12.7.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://railway.app/railway.schema.json",
|
||||||
|
"build": {
|
||||||
|
"builder": "NIXPACKS"
|
||||||
|
},
|
||||||
|
"deploy": {
|
||||||
|
"restartPolicyType": "ON_FAILURE",
|
||||||
|
"restartPolicyMaxRetries": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,728 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
const http = require('http');
|
||||||
|
const cron = require('node-cron');
|
||||||
|
const admin = require('firebase-admin');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
|
const TZ = process.env.TZ || 'Asia/Jakarta';
|
||||||
|
const SCHEDULER_ENABLED = String(process.env.SCHEDULER_ENABLED || 'true') === 'true';
|
||||||
|
const COMPACT_MODE = String(process.env.COMPACT_MODE || 'true') === 'true';
|
||||||
|
const RIWAYAT_MAX_RECORDS = Number(process.env.RIWAYAT_MAX_RECORDS || 500);
|
||||||
|
const SCHEDULER_RUNS_RETAIN_DAYS = Number(process.env.SCHEDULER_RUNS_RETAIN_DAYS || 14);
|
||||||
|
let motorOffTimer = null;
|
||||||
|
|
||||||
|
function getEnvOrThrow(key) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing env var: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PERBAIKAN:
|
||||||
|
function parseServiceAccountFromEnv() {
|
||||||
|
const rawJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
|
||||||
|
if (rawJson) {
|
||||||
|
const parsed = JSON.parse(rawJson);
|
||||||
|
return {
|
||||||
|
projectId: parsed.project_id,
|
||||||
|
clientEmail: parsed.client_email,
|
||||||
|
privateKey: String(parsed.private_key || '').replace(/\\n/g, '\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panggil NAMA VARIABEL yang didaftarkan di Railway
|
||||||
|
return {
|
||||||
|
projectId: getEnvOrThrow('FIREBASE_PROJECT_ID'),
|
||||||
|
clientEmail: getEnvOrThrow('FIREBASE_CLIENT_EMAIL'),
|
||||||
|
privateKey: getEnvOrThrow('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFirebase() {
|
||||||
|
const { projectId, clientEmail, privateKey } = parseServiceAccountFromEnv();
|
||||||
|
// Panggil NAMA VARIABEL untuk URL database
|
||||||
|
const databaseURL = getEnvOrThrow('FIREBASE_DATABASE_URL');
|
||||||
|
|
||||||
|
admin.initializeApp({
|
||||||
|
credential: admin.credential.cert({
|
||||||
|
projectId,
|
||||||
|
clientEmail,
|
||||||
|
privateKey,
|
||||||
|
}),
|
||||||
|
databaseURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[init] Firebase Admin connected:', projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJakartaNow() {
|
||||||
|
return new Date(new Date().toLocaleString('en-US', { timeZone: TZ }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateKey(date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHHMM(date) {
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(value) {
|
||||||
|
const d = new Date(String(value || ''));
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKandangKey(kandangId) {
|
||||||
|
const id = String(kandangId || '').toLowerCase();
|
||||||
|
if (id === 'kandang1' || id === 'kandang_1') return 'kandang1';
|
||||||
|
if (id === 'kandang2' || id === 'kandang_2') return 'kandang2';
|
||||||
|
return 'lainnya';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDurasiMs(durasiRaw) {
|
||||||
|
const text = String(durasiRaw || '').toLowerCase().trim();
|
||||||
|
if (!text) return 30 * 60 * 1000;
|
||||||
|
|
||||||
|
const number = Number.parseInt(text.replace(/[^0-9]/g, ''), 10);
|
||||||
|
if (!Number.isFinite(number) || number <= 0) return 30 * 60 * 1000;
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.includes('jam') ||
|
||||||
|
text.includes('hour') ||
|
||||||
|
text.includes('hours') ||
|
||||||
|
text.includes('hr')
|
||||||
|
) {
|
||||||
|
return number * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return number * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllKandangSchedule(jadwal) {
|
||||||
|
const kandangId = String(jadwal.kandangId || '').toLowerCase().trim();
|
||||||
|
const kandangNama = String(jadwal.kandangNama || '').toLowerCase().trim();
|
||||||
|
return (
|
||||||
|
kandangId === 'all' ||
|
||||||
|
kandangId === 'global' ||
|
||||||
|
kandangId === 'semua' ||
|
||||||
|
kandangNama.includes('global') ||
|
||||||
|
kandangNama.includes('semua') ||
|
||||||
|
kandangNama.includes('all')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargetKandangIds(jadwal, kandangMap) {
|
||||||
|
if (isAllKandangSchedule(jadwal)) {
|
||||||
|
const ids = Object.keys(kandangMap || {});
|
||||||
|
if (ids.length > 0) return ids;
|
||||||
|
return ['kandang1', 'kandang2'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const kandangId = String(jadwal.kandangId || '').trim();
|
||||||
|
if (!kandangId) return [];
|
||||||
|
return [kandangId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerMotorForDuration(durasiMs, jadwalId) {
|
||||||
|
const ref = admin.database().ref('aktuator');
|
||||||
|
await ref.update({
|
||||||
|
motor: true,
|
||||||
|
last_trigger_by_scheduler: new Date().toISOString(),
|
||||||
|
last_trigger_jadwal: jadwalId,
|
||||||
|
durasi_ms: durasiMs,
|
||||||
|
});
|
||||||
|
console.log(`[ok] Motor ON untuk jadwal ${jadwalId}, durasi ${Math.round(durasiMs / 1000)} detik`);
|
||||||
|
|
||||||
|
if (motorOffTimer) {
|
||||||
|
clearTimeout(motorOffTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
motorOffTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await ref.update({
|
||||||
|
motor: false,
|
||||||
|
last_off_by_scheduler: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
console.log(`[ok] Motor OFF otomatis setelah durasi jadwal ${jadwalId}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error] Gagal mematikan motor otomatis:', e.message);
|
||||||
|
}
|
||||||
|
}, durasiMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectJenisPanen(jadwal) {
|
||||||
|
const jam = String(jadwal.jam || '09:00');
|
||||||
|
const hour = Number(jam.split(':')[0] || 9);
|
||||||
|
const ket = String(jadwal.keterangan || '').toLowerCase();
|
||||||
|
|
||||||
|
if (ket.includes('sore')) return 'sore';
|
||||||
|
if (ket.includes('pagi')) return 'pagi';
|
||||||
|
return hour >= 12 ? 'sore' : 'pagi';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInfraPath(kandangId, kandangData) {
|
||||||
|
if (kandangData && kandangData.infraPath) {
|
||||||
|
return String(kandangData.infraPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = String(kandangId || '').toLowerCase();
|
||||||
|
if (id.includes('1')) return 'infra1';
|
||||||
|
if (id.includes('2')) return 'infra2';
|
||||||
|
return 'infra1';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireRunLock(todayKey, lockKey) {
|
||||||
|
const lockRef = admin.database().ref(`scheduler_runs/${todayKey}/${lockKey}`);
|
||||||
|
const tx = await lockRef.transaction((current) => {
|
||||||
|
if (current) return current;
|
||||||
|
return {
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
source: 'railway-scheduler',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.committed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRiwayatSummary({ kandangId, jumlahTelur, dateKey }) {
|
||||||
|
const summaryRef = admin.database().ref('riwayat/summary');
|
||||||
|
const kandangKey = normalizeKandangKey(kandangId);
|
||||||
|
const totalAdd = toInt(jumlahTelur);
|
||||||
|
|
||||||
|
await summaryRef.transaction((current) => {
|
||||||
|
const nowData = current || {};
|
||||||
|
const lastDate = String(nowData.last_reset_date || '');
|
||||||
|
|
||||||
|
let telurHariIni = toInt(nowData.telur_hari_ini);
|
||||||
|
let kandang1 = toInt(nowData.kandang1_hari_ini);
|
||||||
|
let kandang2 = toInt(nowData.kandang2_hari_ini);
|
||||||
|
let totalTelur = toInt(nowData.total_telur);
|
||||||
|
|
||||||
|
if (lastDate !== dateKey) {
|
||||||
|
telurHariIni = 0;
|
||||||
|
kandang1 = 0;
|
||||||
|
kandang2 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
telurHariIni += totalAdd;
|
||||||
|
totalTelur += totalAdd;
|
||||||
|
|
||||||
|
if (kandangKey === 'kandang1') kandang1 += totalAdd;
|
||||||
|
if (kandangKey === 'kandang2') kandang2 += totalAdd;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...nowData,
|
||||||
|
telur_hari_ini: telurHariIni,
|
||||||
|
kandang1_hari_ini: kandang1,
|
||||||
|
kandang2_hari_ini: kandang2,
|
||||||
|
total_telur: totalTelur,
|
||||||
|
last_reset_date: dateKey,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetSensorAfterEvening(todayKey) {
|
||||||
|
const lockKey = 'evening_sensor_reset';
|
||||||
|
const gotLock = await acquireRunLock(todayKey, lockKey);
|
||||||
|
if (!gotLock) {
|
||||||
|
console.log('[skip] Sensor reset sudah dieksekusi hari ini');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await admin.database().ref('data').update({
|
||||||
|
infra1: 0,
|
||||||
|
infra2: 0,
|
||||||
|
last_reset_by_scheduler: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[ok] Sensor infra1/infra2 di-reset otomatis setelah jadwal sore');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRiwayat({
|
||||||
|
kandangId,
|
||||||
|
kandangNama,
|
||||||
|
jumlahTelur,
|
||||||
|
jam,
|
||||||
|
jenisPanen,
|
||||||
|
sensorSnapshot,
|
||||||
|
panenSebelumnya,
|
||||||
|
catatan,
|
||||||
|
dateKey,
|
||||||
|
}) {
|
||||||
|
const riwayatRef = admin.database().ref('riwayat/records').push();
|
||||||
|
const now = new Date();
|
||||||
|
const jumlah = toInt(jumlahTelur);
|
||||||
|
const sensor = toInt(sensorSnapshot);
|
||||||
|
|
||||||
|
await riwayatRef.set({
|
||||||
|
id: `railway_${Date.now()}`,
|
||||||
|
kandang_id: kandangId,
|
||||||
|
kandang_nama: kandangNama,
|
||||||
|
jumlah_telur: jumlah,
|
||||||
|
tanggal_panen: now.toISOString(),
|
||||||
|
jam,
|
||||||
|
jenis_panen: jenisPanen,
|
||||||
|
sensor_snapshot: sensor,
|
||||||
|
panen_sebelumnya: panenSebelumnya == null ? null : Number(panenSebelumnya),
|
||||||
|
catatan,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateRiwayatSummary({
|
||||||
|
kandangId,
|
||||||
|
jumlahTelur: jumlah,
|
||||||
|
dateKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shouldRunMaintenance() {
|
||||||
|
const lockRef = admin.database().ref('maintenance/migration_v2_global_alias');
|
||||||
|
const tx = await lockRef.transaction((current) => {
|
||||||
|
if (current && current.done === true) return current;
|
||||||
|
return {
|
||||||
|
done: true,
|
||||||
|
done_at: new Date().toISOString(),
|
||||||
|
source: 'railway-scheduler',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return tx.committed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLegacyRecord(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||||
|
return 'kandang_id' in value && 'tanggal_panen' in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKandangNameMap(kandangMap) {
|
||||||
|
const map = {};
|
||||||
|
for (const kandangId of Object.keys(kandangMap || {})) {
|
||||||
|
const kandang = kandangMap[kandangId];
|
||||||
|
map[kandangId] = String((kandang && kandang.nama) || kandangId);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateKeyFromIso(iso) {
|
||||||
|
const date = new Date(String(iso || ''));
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return formatDateKey(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inferTargetForGlobalRecord(record, kandangMap) {
|
||||||
|
const dateKey = parseDateKeyFromIso(record.tanggal_panen);
|
||||||
|
if (!dateKey) return null;
|
||||||
|
|
||||||
|
const jenis = String(record.jenis_panen || '').toLowerCase();
|
||||||
|
const sessionKey = jenis === 'sore' ? 'sore' : 'pagi';
|
||||||
|
const sessionSnapRef = admin.database().ref(`panen_snapshot/${dateKey}/${sessionKey}`);
|
||||||
|
const sessionSnap = await sessionSnapRef.get();
|
||||||
|
if (!sessionSnap.exists() || typeof sessionSnap.val() !== 'object') return null;
|
||||||
|
|
||||||
|
const snapData = sessionSnap.val();
|
||||||
|
const candidates = [];
|
||||||
|
for (const kandangId of Object.keys(kandangMap || {})) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(snapData, kandangId)) continue;
|
||||||
|
const snapVal = toInt(snapData[kandangId]);
|
||||||
|
if (jenis === 'sore') {
|
||||||
|
const deltaKey = `delta_${kandangId}`;
|
||||||
|
const deltaVal = toInt(snapData[deltaKey]);
|
||||||
|
if (deltaVal === toInt(record.jumlah_telur) || snapVal === toInt(record.sensor_snapshot)) {
|
||||||
|
candidates.push(kandangId);
|
||||||
|
}
|
||||||
|
} else if (snapVal === toInt(record.sensor_snapshot) || snapVal === toInt(record.jumlah_telur)) {
|
||||||
|
candidates.push(kandangId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.length === 1 ? candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyRiwayatData(kandangMap) {
|
||||||
|
const riwayatRef = admin.database().ref('riwayat');
|
||||||
|
const rootSnap = await riwayatRef.get();
|
||||||
|
if (!rootSnap.exists() || typeof rootSnap.val() !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = rootSnap.val();
|
||||||
|
const recordsRef = admin.database().ref('riwayat/records');
|
||||||
|
const summaryRef = admin.database().ref('riwayat/summary');
|
||||||
|
const kandangNameMap = buildKandangNameMap(kandangMap);
|
||||||
|
|
||||||
|
if (!root.summary && (root.telur_hari_ini != null || root.total_telur != null)) {
|
||||||
|
await summaryRef.update({
|
||||||
|
telur_hari_ini: toInt(root.telur_hari_ini),
|
||||||
|
total_telur: toInt(root.total_telur),
|
||||||
|
last_reset_date: String(root.last_reset_date || formatDateKey(getJakartaNow())),
|
||||||
|
kandang1_hari_ini: toInt(root.kandang1_hari_ini),
|
||||||
|
kandang2_hari_ini: toInt(root.kandang2_hari_ini),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
migrated_from_legacy: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupUpdates = {};
|
||||||
|
|
||||||
|
for (const [legacyKey, value] of Object.entries(root)) {
|
||||||
|
if (legacyKey === 'records' || legacyKey === 'summary') continue;
|
||||||
|
if (!looksLikeLegacyRecord(value)) {
|
||||||
|
// Hapus field summary lama atau node lama non-record.
|
||||||
|
cleanupUpdates[legacyKey] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = { ...value };
|
||||||
|
const kandangIdRaw = String(record.kandang_id || '').toLowerCase();
|
||||||
|
const kandangNamaRaw = String(record.kandang_nama || '').toLowerCase();
|
||||||
|
const isGlobal =
|
||||||
|
kandangIdRaw === 'global' ||
|
||||||
|
kandangIdRaw === 'all' ||
|
||||||
|
kandangIdRaw === 'semua' ||
|
||||||
|
kandangNamaRaw.includes('global') ||
|
||||||
|
kandangNamaRaw.includes('semua') ||
|
||||||
|
kandangNamaRaw.includes('all');
|
||||||
|
|
||||||
|
if (!isGlobal) {
|
||||||
|
await recordsRef.push().set({
|
||||||
|
...record,
|
||||||
|
migrated_from_legacy: true,
|
||||||
|
migrated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
cleanupUpdates[legacyKey] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap);
|
||||||
|
if (inferredKandangId) {
|
||||||
|
await recordsRef.push().set({
|
||||||
|
...record,
|
||||||
|
kandang_id: inferredKandangId,
|
||||||
|
kandang_nama: kandangNameMap[inferredKandangId] || inferredKandangId,
|
||||||
|
migrated_from_global: true,
|
||||||
|
migrated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
cleanupUpdates[legacyKey] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: jika tidak bisa infer unik, split ke semua kandang aktif.
|
||||||
|
const kandangIds = Object.keys(kandangMap || {});
|
||||||
|
if (kandangIds.length === 0) {
|
||||||
|
cleanupUpdates[legacyKey] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = toInt(record.jumlah_telur);
|
||||||
|
const base = Math.floor(total / kandangIds.length);
|
||||||
|
let remainder = total % kandangIds.length;
|
||||||
|
|
||||||
|
for (const kandangId of kandangIds) {
|
||||||
|
const porsi = base + (remainder > 0 ? 1 : 0);
|
||||||
|
if (remainder > 0) remainder -= 1;
|
||||||
|
|
||||||
|
await recordsRef.push().set({
|
||||||
|
...record,
|
||||||
|
kandang_id: kandangId,
|
||||||
|
kandang_nama: kandangNameMap[kandangId] || kandangId,
|
||||||
|
jumlah_telur: porsi,
|
||||||
|
migrated_from_global_split: true,
|
||||||
|
migrated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupUpdates[legacyKey] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(cleanupUpdates).length > 0) {
|
||||||
|
await riwayatRef.update(cleanupUpdates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeRecordsNode() {
|
||||||
|
const nestedRef = admin.database().ref('riwayat/records/records');
|
||||||
|
const nestedSnap = await nestedRef.get();
|
||||||
|
if (!nestedSnap.exists() || typeof nestedSnap.val() !== 'object') return;
|
||||||
|
|
||||||
|
const recordsRef = admin.database().ref('riwayat/records');
|
||||||
|
const nestedData = nestedSnap.val();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(nestedData)) {
|
||||||
|
if (!value || typeof value !== 'object') continue;
|
||||||
|
await recordsRef.child(key).set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nestedRef.remove();
|
||||||
|
console.log('[maintenance] Flattened riwayat/records/records into riwayat/records');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneOldRiwayatRecords(maxRecords) {
|
||||||
|
if (!Number.isFinite(maxRecords) || maxRecords <= 0) return;
|
||||||
|
|
||||||
|
const recordsRef = admin.database().ref('riwayat/records');
|
||||||
|
const snap = await recordsRef.get();
|
||||||
|
if (!snap.exists() || typeof snap.val() !== 'object') return;
|
||||||
|
|
||||||
|
const recordsData = snap.val();
|
||||||
|
const entries = Object.entries(recordsData)
|
||||||
|
.filter(([_, value]) => value && typeof value === 'object')
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const date = toDate(value.tanggal_panen);
|
||||||
|
return { key, time: date ? date.getTime() : 0 };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.time - a.time);
|
||||||
|
|
||||||
|
if (entries.length <= maxRecords) return;
|
||||||
|
|
||||||
|
const removeUpdates = {};
|
||||||
|
for (const item of entries.slice(maxRecords)) {
|
||||||
|
removeUpdates[item.key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordsRef.update(removeUpdates);
|
||||||
|
console.log(`[maintenance] Pruned ${entries.length - maxRecords} old riwayat records (keep ${maxRecords})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneOldSchedulerRuns(retainDays) {
|
||||||
|
if (!Number.isFinite(retainDays) || retainDays < 1) return;
|
||||||
|
|
||||||
|
const runsRef = admin.database().ref('scheduler_runs');
|
||||||
|
const snap = await runsRef.get();
|
||||||
|
if (!snap.exists() || typeof snap.val() !== 'object') return;
|
||||||
|
|
||||||
|
const runs = snap.val();
|
||||||
|
const now = getJakartaNow();
|
||||||
|
const cutoff = new Date(now.getTime() - retainDays * 24 * 60 * 60 * 1000);
|
||||||
|
const removeUpdates = {};
|
||||||
|
|
||||||
|
for (const dateKey of Object.keys(runs)) {
|
||||||
|
const date = toDate(`${dateKey}T00:00:00+07:00`);
|
||||||
|
if (!date) continue;
|
||||||
|
if (date < cutoff) {
|
||||||
|
removeUpdates[dateKey] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(removeUpdates).length > 0) {
|
||||||
|
await runsRef.update(removeUpdates);
|
||||||
|
console.log(`[maintenance] Pruned old scheduler_runs older than ${retainDays} days`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCompactMaintenance() {
|
||||||
|
await normalizeRecordsNode();
|
||||||
|
await pruneOldRiwayatRecords(RIWAYAT_MAX_RECORDS);
|
||||||
|
await pruneOldSchedulerRuns(SCHEDULER_RUNS_RETAIN_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey) {
|
||||||
|
const jam = String(jadwal.jam || '09:00');
|
||||||
|
const jenisPanen = detectJenisPanen(jadwal);
|
||||||
|
const durasiMs = parseDurasiMs(jadwal.durasi);
|
||||||
|
|
||||||
|
await triggerMotorForDuration(durasiMs, jadwalId);
|
||||||
|
|
||||||
|
const targetKandangIds = resolveTargetKandangIds(jadwal, kandangMap);
|
||||||
|
|
||||||
|
if (targetKandangIds.length === 0) {
|
||||||
|
console.log(`[skip] ${jadwalId}: target kandang tidak ditemukan`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kandangId of targetKandangIds) {
|
||||||
|
const lockKey = `${jadwalId}_${jenisPanen}_${kandangId}`;
|
||||||
|
const gotLock = await acquireRunLock(todayKey, lockKey);
|
||||||
|
if (!gotLock) {
|
||||||
|
console.log(`[skip] ${jadwalId}/${kandangId}: sudah dieksekusi hari ini (${jenisPanen})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kandangData = kandangMap[kandangId] || null;
|
||||||
|
const kandangNama = String(
|
||||||
|
(kandangData && kandangData.nama) || jadwal.kandangNama || kandangId || 'Kandang',
|
||||||
|
);
|
||||||
|
const infraPath = resolveInfraPath(kandangId, kandangData);
|
||||||
|
const sensorValue = Number(dataSensor[infraPath] || 0);
|
||||||
|
|
||||||
|
if (jenisPanen === 'pagi') {
|
||||||
|
await admin.database().ref(`panen_snapshot/${todayKey}/pagi`).update({
|
||||||
|
[kandangId]: sensorValue,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeRiwayat({
|
||||||
|
kandangId,
|
||||||
|
kandangNama,
|
||||||
|
jumlahTelur: sensorValue,
|
||||||
|
jam,
|
||||||
|
jenisPanen: 'pagi',
|
||||||
|
sensorSnapshot: sensorValue,
|
||||||
|
panenSebelumnya: null,
|
||||||
|
catatan: 'Auto-capture PAGI dari Railway scheduler',
|
||||||
|
dateKey: todayKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[ok] Pagi ${kandangNama}: ${sensorValue} telur (${infraPath})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagiSnapRef = admin.database().ref(`panen_snapshot/${todayKey}/pagi/${kandangId}`);
|
||||||
|
const pagiSnap = await pagiSnapRef.get();
|
||||||
|
const nilaiPagi = pagiSnap.exists() ? Number(pagiSnap.val() || 0) : 0;
|
||||||
|
const delta = Math.max(sensorValue - nilaiPagi, 0);
|
||||||
|
|
||||||
|
await admin.database().ref(`panen_snapshot/${todayKey}/sore`).update({
|
||||||
|
[kandangId]: sensorValue,
|
||||||
|
[`delta_${kandangId}`]: delta,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeRiwayat({
|
||||||
|
kandangId,
|
||||||
|
kandangNama,
|
||||||
|
jumlahTelur: delta,
|
||||||
|
jam,
|
||||||
|
jenisPanen: 'sore',
|
||||||
|
sensorSnapshot: sensorValue,
|
||||||
|
panenSebelumnya: nilaiPagi,
|
||||||
|
catatan: `Auto-capture SORE dari Railway scheduler (delta: ${sensorValue} - ${nilaiPagi})`,
|
||||||
|
dateKey: todayKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[ok] Sore ${kandangNama}: ${delta} telur (${sensorValue}-${nilaiPagi})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTick() {
|
||||||
|
const now = getJakartaNow();
|
||||||
|
const nowHHMM = formatHHMM(now);
|
||||||
|
const todayKey = formatDateKey(now);
|
||||||
|
|
||||||
|
const db = admin.database();
|
||||||
|
const [jadwalSnap, dataSnap, kandangSnap] = await Promise.all([
|
||||||
|
db.ref('kontrol/penjadwalan').get(),
|
||||||
|
db.ref('data').get(),
|
||||||
|
db.ref('kontrol/kandang').get(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!jadwalSnap.exists()) {
|
||||||
|
console.log('[tick] Tidak ada data kontrol/penjadwalan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jadwalMap = jadwalSnap.val() || {};
|
||||||
|
const dataSensor = dataSnap.exists() ? dataSnap.val() : {};
|
||||||
|
const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {};
|
||||||
|
|
||||||
|
const aktifSekarang = Object.entries(jadwalMap).filter(([_, jadwal]) => {
|
||||||
|
if (!jadwal || jadwal.aktif !== true) return false;
|
||||||
|
const jam = String(jadwal.jam || '').slice(0, 5);
|
||||||
|
return jam === nowHHMM;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aktifSekarang.length === 0) {
|
||||||
|
console.log(`[tick] ${nowHHMM} tidak ada jadwal aktif`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[tick] ${nowHHMM} eksekusi ${aktifSekarang.length} jadwal`);
|
||||||
|
let hasEveningRun = false;
|
||||||
|
|
||||||
|
for (const [jadwalId, jadwal] of aktifSekarang) {
|
||||||
|
try {
|
||||||
|
await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey);
|
||||||
|
if (detectJenisPanen(jadwal) === 'sore') {
|
||||||
|
hasEveningRun = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[error] Jadwal ${jadwalId} gagal:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasEveningRun) {
|
||||||
|
await resetSensorAfterEvening(todayKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHealthServer() {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url === '/health') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true, service: 'telurku-railway-scheduler' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('TelurKu Railway Scheduler is running');
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[init] Health server listening on :${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
initFirebase();
|
||||||
|
startHealthServer();
|
||||||
|
|
||||||
|
const kandangSnap = await admin.database().ref('kontrol/kandang').get();
|
||||||
|
const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {};
|
||||||
|
|
||||||
|
const runMaintenance = await shouldRunMaintenance();
|
||||||
|
if (runMaintenance) {
|
||||||
|
try {
|
||||||
|
await migrateLegacyRiwayatData(kandangMap);
|
||||||
|
console.log('[init] Legacy riwayat migration completed');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error] Legacy riwayat migration failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (COMPACT_MODE) {
|
||||||
|
try {
|
||||||
|
await runCompactMaintenance();
|
||||||
|
console.log('[init] Compact maintenance completed');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error] Compact maintenance failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SCHEDULER_ENABLED) {
|
||||||
|
console.log('[init] Scheduler disabled by SCHEDULER_ENABLED=false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once at startup for visibility, then every minute.
|
||||||
|
try {
|
||||||
|
await runTick();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error] Initial tick failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
cron.schedule(
|
||||||
|
'* * * * *',
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await runTick();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[error] Tick failed:', e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timezone: TZ },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[init] Scheduler active (timezone=${TZ})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((e) => {
|
||||||
|
console.error('[fatal] bootstrap failed:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue