Update Monitoring dan Verifikasi Petani

This commit is contained in:
2026-11-05 11:01:12 +07:00
parent fd93e0d265
commit a3ec431d49
22 changed files with 1780 additions and 76 deletions
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Web\Concerns;
use App\Models\Pertanian\Petani;
use App\Services\Produktivitas\KelengkapanPetani;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
/**
* Bantuan bersama menu Verifikasi > Petani (tab Monitoring & Verifikasi).
* Hanya super_admin, admin, dan koordinator; koordinator dibatasi komoditas yang ditugaskan.
*/
trait AksesMonitoringPetani
{
protected const PER_PAGE = [10, 25, 50];
protected function izinkan(): void
{
abort_unless(Gate::any(['super_admin', 'admin', 'koordinator']), 403);
}
protected function izinkanPetani(Petani $petani): void
{
$this->izinkan();
abort_if(auth()->user()->isKoordinator() && ! auth()->user()->bolehAksesPetani($petani), 403);
}
/** Query petani + relasi untuk hitung kelengkapan (eager load, tanpa N+1), sudah dibatasi komoditas koordinator. */
protected function queryPetani(): Builder
{
return $this->batasiKomoditas(Petani::query()->with(KelengkapanPetani::RELASI));
}
protected function batasiKomoditas(Builder $query): Builder
{
$user = auth()->user();
return $query->when($user->isKoordinator(), fn ($q) => $q->whereIn('komoditas_id', $user->komoditasKoordinatorIds()));
}
/** Ringkasan 3 hitungan input (skor, wajib, lainnya) per petani, dikunci id petani. */
protected function ringkasan(Collection $petani): BaseCollection
{
return $petani->mapWithKeys(fn (Petani $p) => [$p->id => KelengkapanPetani::untuk($p)->ringkasanInput()]);
}
protected function perPage(Request $request, string $kunci): int
{
$n = (int) $request->query($kunci);
return in_array($n, self::PER_PAGE, true) ? $n : self::PER_PAGE[0];
}
protected function kataCari(Request $request, string $kunci): ?string
{
return $request->filled($kunci) ? Str::lower($request->query($kunci)) : null;
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Web\Monitoring;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AksesMonitoringPetani;
use App\Models\Pertanian\Petani;
use App\Models\User;
use App\Services\Produktivitas\KelengkapanPetani;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
/**
* Menu Verifikasi > Petani, tab Monitoring (sub-tab Petani & Enumerator).
*/
class MonitoringPetaniController extends Controller
{
use AksesMonitoringPetani;
public function index(Request $request)
{
$this->izinkan();
// Sub-tab Petani
$petani = $this->queryPetani()
->with('creator:id,name')
->when($this->kataCari($request, 'search'), fn ($q, $cari) => $q->whereRaw('lower(nama) like ?', ["%{$cari}%"]))
->latest()
->paginate($this->perPage($request, 'per_page'))
->withQueryString()
->appends('sub', 'petani');
// Sub-tab Enumerator: jumlah petani yang diinput (created_by)
$enumerator = User::query()
->where('role', Role::ENUMERATOR)
->withCount(['petaniDibuat as jumlah_petani' => fn (Builder $q) => $this->batasiKomoditas($q)])
->when($this->kataCari($request, 'search_enumerator'), fn ($q, $cari) => $q->whereRaw('lower(name) like ?', ["%{$cari}%"]))
->orderByDesc('jumlah_petani')
->orderBy('name')
->paginate($this->perPage($request, 'per_page_enumerator'), ['*'], 'page_enumerator')
->withQueryString()
->appends('sub', 'enumerator');
return view('monitoring-verifikasi.monitoring', [
'menu' => 'petani',
'petani' => $petani,
'ringkasan' => $this->ringkasan($petani->getCollection()),
'enumerator' => $enumerator,
'perPage' => self::PER_PAGE,
]);
}
/** Kartu rincian saat badge Input Skor / Wajib / Lainnya diklik (HTML, dimuat lewat fetch). */
public function detail(Petani $petani, string $jenis)
{
$this->izinkanPetani($petani);
$petani->loadMissing(['desaKelurahan.kecamatan.kabupatenKota', 'skor:id,petani_id,skor_kesiapan_adopsi']);
$kelengkapan = KelengkapanPetani::untuk($petani);
return view('monitoring-verifikasi.petani-monitoring', [
'bagian' => 'rincian',
'petani' => $petani,
'ringkasan' => $kelengkapan->ringkasanInput()[$jenis],
'rincian' => $kelengkapan->rincian($jenis)->groupBy('grup'),
]);
}
/** Kartu rincian saat jumlah petani seorang enumerator diklik: daftar petani + 3 hitungan input. */
public function enumerator(User $user)
{
$this->izinkan();
abort_unless($user->role === Role::ENUMERATOR, 404);
$daftarPetani = $this->queryPetani()
->where('created_by', $user->id)
->orderBy('nama')
->get();
return view('monitoring-verifikasi.enumerator', [
'bagian' => 'rincian',
'user' => $user,
'daftarPetani' => $daftarPetani,
'ringkasan' => $this->ringkasan($daftarPetani),
]);
}
}
@@ -21,10 +21,10 @@ class PetaniStatusController extends Controller
$validated = $request->validate([
'status' => ['required', Rule::in([StatusDataPetani::LENGKAP->value, StatusDataPetani::DRAFT->value])],
// Enumerator wajib menyetujui pernyataan kelengkapan saat menandai lengkap.
// Enumerator menyetujui pernyataan kelengkapan (tombol konfirmasi di popup) saat menandai lengkap.
'pernyataan' => ['exclude_unless:status,' . StatusDataPetani::LENGKAP->value, 'accepted'],
], [
'pernyataan.accepted' => 'Centang pernyataan kelengkapan data terlebih dahulu.',
'pernyataan.accepted' => 'Setujui pernyataan kelengkapan data terlebih dahulu.',
]);
$tujuan = StatusDataPetani::from($validated['status']);
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Web\Verifikasi;
use App\Enums\StatusDataPetani;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AksesMonitoringPetani;
use App\Models\Pertanian\Petani;
use App\Services\Produktivitas\KelengkapanPetani;
use Illuminate\Http\Request;
/**
* Menu Verifikasi > Petani, tab Verifikasi (sub-tab Data Lengkap & Terverifikasi).
* Koordinator/admin memeriksa data berstatus Lengkap lalu menandainya Terverifikasi.
*/
class VerifikasiPetaniController extends Controller
{
use AksesMonitoringPetani;
public function index(Request $request)
{
$this->izinkan();
$cariNama = fn (string $kunci) => fn ($q) => $q->when(
$this->kataCari($request, $kunci),
fn ($q, $cari) => $q->whereRaw('lower(nama) like ?', ["%{$cari}%"])
);
// Sub-tab Data Lengkap: menunggu verifikasi
$petaniLengkap = $this->queryPetani()
->with('creator:id,name')
->where('status', StatusDataPetani::LENGKAP->value)
->tap($cariNama('search'))
->latest('updated_at')
->paginate($this->perPage($request, 'per_page'))
->withQueryString()
->appends('sub', 'lengkap');
// Sub-tab Terverifikasi
$petaniTerverifikasi = $this->batasiKomoditas(Petani::query())
->with(['creator:id,name', 'updater:id,name,role', 'komoditas:id,nama', 'desaKelurahan:id,nama'])
->where('status', StatusDataPetani::TERVERIFIKASI->value)
->tap($cariNama('search_terverifikasi'))
->latest('updated_at')
->paginate($this->perPage($request, 'per_page_terverifikasi'), ['*'], 'page_terverifikasi')
->withQueryString()
->appends('sub', 'terverifikasi');
return view('monitoring-verifikasi.verifikasi', [
'menu' => 'petani',
'petaniLengkap' => $petaniLengkap,
'ringkasan' => $this->ringkasan($petaniLengkap->getCollection()),
'petaniTerverifikasi' => $petaniTerverifikasi,
'perPage' => self::PER_PAGE,
]);
}
/** Isi kartu verifikasi (3 langkah: Input Skor → Input Wajib → Input Lainnya), dimuat lewat fetch. */
public function show(Petani $petani)
{
$this->izinkanPetani($petani);
abort_unless(in_array($petani->status, [StatusDataPetani::LENGKAP, StatusDataPetani::TERVERIFIKASI], true), 404);
$petani->loadMissing(['desaKelurahan.kecamatan.kabupatenKota', 'skor:id,petani_id,skor_kesiapan_adopsi', 'creator:id,name']);
// Sudah terverifikasi: kartu ringkas (siapa & kapan diverifikasi) + Kembalikan ke Draf.
if ($petani->status === StatusDataPetani::TERVERIFIKASI) {
$petani->loadMissing(['komoditas:id,nama', 'updater:id,name,role']);
return view('monitoring-verifikasi.petani-terverifikasi', ['bagian' => 'rincian', 'petani' => $petani]);
}
$kelengkapan = KelengkapanPetani::untuk($petani);
return view('monitoring-verifikasi.petani-lengkap', [
'bagian' => 'rincian',
'petani' => $petani,
'ringkasan' => $kelengkapan->ringkasanInput(),
'rincian' => collect(['skor', 'wajib', 'lainnya'])
->mapWithKeys(fn (string $jenis) => [$jenis => $kelengkapan->rincian($jenis)->groupBy('grup')]),
]);
}
/** Tandai Terverifikasi (dari langkah terakhir kartu verifikasi). */
public function update(Petani $petani)
{
$this->izinkanPetani($petani);
$kembali = redirect()->route('monitoring-verifikasi.petani.verifikasi', ['sub' => 'lengkap']);
if ($petani->status !== StatusDataPetani::LENGKAP) {
return $kembali->with('verifikasi_error', "Data petani {$petani->nama} tidak berstatus Lengkap, tidak dapat diverifikasi.");
}
$petani->status = StatusDataPetani::TERVERIFIKASI;
$petani->updated_by = auth()->id();
$petani->save();
return $kembali->with('verifikasi_success', "Data petani {$petani->nama} berhasil diverifikasi.");
}
/**
* Kembalikan ke Draf dengan catatan untuk enumerator:
* - dari Lengkap → tombol "Belum Lengkap" di kartu verifikasi
* - dari Terverifikasi → ada temuan setelah diverifikasi
*/
public function kembalikanKeDraf(Request $request, Petani $petani)
{
$this->izinkanPetani($petani);
$data = $request->validate(
['catatan' => ['required', 'string', 'max:2000']],
['catatan.required' => 'Catatan wajib diisi.', 'catatan.max' => 'Catatan maksimal 2000 karakter.']
);
$dariTerverifikasi = $petani->status === StatusDataPetani::TERVERIFIKASI;
$kembali = redirect()->route('monitoring-verifikasi.petani.verifikasi', ['sub' => $dariTerverifikasi ? 'terverifikasi' : 'lengkap']);
if (! $dariTerverifikasi && $petani->status !== StatusDataPetani::LENGKAP) {
return $kembali->with('verifikasi_error', "Data petani {$petani->nama} sudah berstatus Draf.");
}
$petani->status = StatusDataPetani::DRAFT;
$petani->catatan = trim($data['catatan']);
$petani->updated_by = auth()->id();
$petani->save();
return $kembali->with('verifikasi_success', "Data petani {$petani->nama} dikembalikan ke Draf dengan catatan untuk enumerator.");
}
}
+23 -2
View File
@@ -3,6 +3,7 @@
namespace App\Http\Middleware;
use App\Enums\Role;
use App\Enums\StatusDataPetani;
use App\Models\Pertanian\Petani;
use Closure;
use Illuminate\Http\Request;
@@ -12,6 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
* Dipasang di semua route modul data petani (petani/{petani}/...).
* - Hanya role pengelola data petani yang boleh masuk.
* - Koordinator hanya boleh mengakses petani dengan komoditas utama yang ditugaskan.
* - Petani berstatus Terverifikasi hanya bisa dilihat (simpan/hapus ditolak).
*/
class BatasiAksesPetani
{
@@ -30,9 +32,18 @@ class BatasiAksesPetani
$petani = $request->route('petani');
if ($petani !== null && $user->isKoordinator()) {
$petani = $petani instanceof Petani ? $petani : Petani::find($petani);
// Data terverifikasi dikunci: tidak bisa diubah, ditambah, atau dihapus (hanya dilihat).
// Perubahan status ditangani sendiri oleh PetaniStatusController.
$permintaanUbah = ! $request->isMethodSafe() && ! $request->routeIs('petani.status.*');
// Petani hanya dimuat kalau perlu dicek (koordinator, atau permintaan simpan/hapus).
if ($petani === null || (! $user->isKoordinator() && ! $permintaanUbah)) {
return $next($request);
}
$petani = $petani instanceof Petani ? $petani : Petani::find($petani);
if ($user->isKoordinator()) {
abort_unless(
$petani && $user->bolehAksesPetani($petani),
403,
@@ -40,6 +51,16 @@ class BatasiAksesPetani
);
}
if ($permintaanUbah && $petani?->status === StatusDataPetani::TERVERIFIKASI) {
$pesan = 'Data petani sudah terverifikasi dan tidak dapat diubah atau dihapus.';
if ($request->expectsJson()) {
abort(403, $pesan);
}
return back()->with('status_petani_error', $pesan);
}
return $next($request);
}
}
@@ -15,9 +15,9 @@ use Closure;
*/
trait KembalikanStatusPetaniKeDraf
{
// Kolom otomatis/audit yang tidak dihitung sebagai perubahan data.
// Kolom otomatis/audit (dan catatan koordinator) yang tidak dihitung sebagai perubahan data.
private static array $kolomBukanPerubahanData = [
'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'deleted_by', 'status',
'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'deleted_by', 'status', 'catatan',
];
public static function bootKembalikanStatusPetaniKeDraf(): void
+5
View File
@@ -422,6 +422,11 @@ class Petani extends Model
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// Untuk trait KembalikanStatusPetaniKeDraf: petani ini sendiri.
protected function petaniIdUntukStatus(): ?int
{
+439 -61
View File
@@ -3,12 +3,24 @@
namespace App\Services\Produktivitas;
use App\Enums\AksesPasar\PraktikFermentasi;
use App\Enums\AksesPasar\SkorKesadaranPremium;
use App\Enums\AksesPasar\SkorPengalaman;
use App\Enums\AksesPasar\TingkatKepercayaanPembeli;
use App\Enums\PraktikBudidaya\DasarPenentuanPanen;
use App\Enums\PraktikBudidaya\FrekuensiPemangkasanUtama;
use App\Enums\PraktikBudidaya\FrekuensiSanitasiLahan;
use App\Enums\PraktikBudidaya\KondisiPengelolaanAir;
use App\Enums\PraktikBudidaya\MetodePenangananHamaPenyakit;
use App\Enums\PraktikBudidaya\PemberianPupuk;
use App\Enums\PraktikBudidaya\PenangananGulma;
use App\Enums\PraktikBudidaya\PraktikSortasi;
use App\Enums\PraktikBudidaya\TingkatPanenReject;
use App\Enums\SosialEkonomi\TerdaftarKelompokTani;
use App\Models\Pertanian\Lahan;
use App\Models\Pertanian\Petani;
use Closure;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Daftar isian Profil Petani yang WAJIB (dipakai skor Kesiapan Adopsi)
@@ -113,30 +125,100 @@ class KelengkapanPetani
];
private const ISIAN_HARGA_JUAL = ['bentuk_produk', 'harga_panen_terakhir', 'harga_tahun_lalu', 'harga_dua_tahun_lalu'];
/*
* Kolom yang sumber isian skor / dashboard (lihat profil(), lahan(), ..., pertanyaanSkor()).
* Kolom di luar daftar ini dihitung sebagai "isian lainnya".
*/
private const PRIORITAS_PROFIL = ['jenis_kelamin', 'tanggal_lahir', 'pendidikan_terakhir', 'komoditas_id', 'desa_kelurahan_id'];
private const PRIORITAS_LAHAN = [
'desa_kelurahan_id', 'path', 'luas_lahan', 'jumlah_tanaman', 'kondisi_tanaman', 'keparahan_serangan',
'jenis_hama', 'jenis_penyakit', 'umur_tanaman',
];
private const PRIORITAS_PRAKTIK_BUDIDAYA = [
'frekuensi_pemangkasan_utama', 'pemberian_pupuk', 'metode_penanganan_opt', 'jenis_hama', 'jenis_penyakit',
'frekuensi_sanitasi_lahan', 'penanganan_gulma', 'kondisi_pengelolaan_air', 'sumber_air',
'kebiasaan_konservasi_tanah', 'pemahaman_manfaat_konservasi_tanah', 'total_estimasi_biaya_produksi',
];
private const PRIORITAS_PANEN = ['dasar_penentuan_panen', 'hasil_komoditas_utama_permusim', 'praktik_sortasi', 'hasil_panen_reject'];
private const PRIORITAS_SOSIAL_EKONOMI = [
'jumlah_tanggungan', 'skor_periode_paceklik', 'skor_kemampuan_menabung', 'skor_kecukupan_penghasilan',
'skor_kejelasan_status_lahan', 'kemauan_mencoba_praktik_baru', 'pemahaman_mutu_harga', 'panutan_petani_sukses',
'kemauan_ikut_program', 'orientasi_rencana_jangka_panjang', 'terdaftar_kelompok_tani',
'keaktifan_pertemuan_kelompok', 'peran_dalam_kelompok', 'manfaat_kelompok_tani', 'status_rekening_tabungan',
'pengalaman_kredit_kebun', 'kondisi_aset_produktif', 'keterlibatan_program_keuangan_formal',
'keterikatan_hutang_panen', 'praktik_ijon', 'kebebasan_memilih_pembeli',
];
private const PRIORITAS_PENDAPATAN_KOMODITAS = ['pendapatan'];
private const PRIORITAS_AKSES_PASAR = [
'tujuan_penjualan', 'sifat_hubungan_bisnis', 'frekuensi_penjualan', 'skor_pengalaman', 'praktik_fermentasi',
'skor_kesadaran_premium', 'tingkat_kepercayaan_pembeli_baru',
];
/** Kolom angka tanpa cast enum di model -> enum untuk teks jawabannya. */
private const ENUM_JAWABAN = [
'frekuensi_pemangkasan_utama' => FrekuensiPemangkasanUtama::class,
'pemberian_pupuk' => PemberianPupuk::class,
'metode_penanganan_opt' => MetodePenangananHamaPenyakit::class,
'frekuensi_sanitasi_lahan' => FrekuensiSanitasiLahan::class,
'penanganan_gulma' => PenangananGulma::class,
'kondisi_pengelolaan_air' => KondisiPengelolaanAir::class,
'dasar_penentuan_panen' => DasarPenentuanPanen::class,
'praktik_sortasi' => PraktikSortasi::class,
'hasil_panen_reject' => TingkatPanenReject::class,
'skor_pengalaman' => SkorPengalaman::class,
'skor_kesadaran_premium' => SkorKesadaranPremium::class,
'tingkat_kepercayaan_pembeli_baru' => TingkatKepercayaanPembeli::class,
];
/** Kolom berkas/peta: cukup ditampilkan "Sudah diunggah". */
private const KOLOM_BERKAS = ['foto', 'path', 'path_kk', 'path_ktp'];
/** Label isian lainnya yang tidak cukup dibaca dari nama kolom. */
private const LABEL_KOLOM = [
'ktp' => 'NIK', 'kk' => 'No. KK', 'path_kk' => 'Berkas KK', 'path_ktp' => 'Berkas KTP',
'path' => 'Peta/polygon lahan', 'komoditas_id' => 'Komoditas', 'jenis_komoditas_id' => 'Jenis komoditas',
'varietas_id' => 'Varietas', 'desa_kelurahan_id' => 'Desa/kelurahan', 'is_aktif' => 'Sertifikat masih aktif',
];
/** Jumlah pertanyaan yang dinilai di skor Kesiapan Adopsi (lihat pertanyaanSkor()). */
public const JUMLAH_PERTANYAAN_SKOR = 32;
/** Relasi yang dibaca; eager-load ini di daftar petani supaya tidak N+1. */
public const RELASI = [
'komoditas',
'lahans',
'praktikBudidaya',
'surveyPanen',
'surveyProduktivitas',
'surveyPelatihan',
'surveySertifikasi',
'sosialEkonomi.pendapatanKomoditas',
'sosialEkonomi.pendapatanLuarPertanian',
'aksesPasar',
'hargaJualPanen',
];
/**
* Satu instance per objek Petani, supaya header dan ringkasan tidak memuat/menghitung ulang.
* WeakMap: otomatis dibuang saat objek Petani tidak dipakai lagi (tidak menumpuk antar-request).
*/
private static ?\WeakMap $cache = null;
// Hasil hitung disimpan per instance (header, tabel, & kartu rincian memakai instance yang sama).
private ?Collection $semuaItem = null;
private ?Collection $semuaKosong = null;
private ?Collection $isianDashboard = null;
private ?array $hitungIsian = null;
private ?array $pertanyaanSkor = null;
private ?array $ringkasanInput = null;
/** Diisi hanya saat rincianLainnya() dipanggil (side panel), supaya daftar petani tetap ringan. */
private ?array $rincianLainnya = null;
private string $grupRincian = '';
public function __construct(private Petani $petani)
{
$petani->loadMissing([
'komoditas',
'lahans',
'praktikBudidaya',
'surveyPanen',
'surveyProduktivitas',
'surveyPelatihan',
'surveySertifikasi',
'sosialEkonomi.pendapatanKomoditas',
'sosialEkonomi.pendapatanLuarPertanian',
'aksesPasar',
'hargaJualPanen',
]);
$petani->loadMissing(self::RELASI);
}
public static function untuk(Petani $petani): self
@@ -161,19 +243,256 @@ class KelengkapanPetani
return $this->hitungIsian ??= $this->hitungSemuaIsian();
}
/**
* Ringkasan untuk tabel Monitoring: masing-masing ['terisi' => int, 'total' => int].
* - skor: 32 pertanyaan skor Kesiapan Adopsi
* - wajib: isian yang dipakai dashboard (prioritas PERLU)
* - lainnya: isian lain di luar keduanya (yang tidak berlaku tidak dihitung)
*/
public function ringkasanInput(): array
{
if ($this->ringkasanInput !== null) {
return $this->ringkasanInput;
}
$skor = collect($this->pertanyaanSkor());
$dashboard = $this->isianDashboard();
$lainnya = $this->hitungIsian()['lainnya'];
return $this->ringkasanInput = [
'skor' => ['terisi' => $skor->where('terisi', true)->count(), 'total' => $skor->count()],
'wajib' => ['terisi' => $dashboard->where('terisi', true)->count(), 'total' => $dashboard->count()],
'lainnya' => ['terisi' => $lainnya['terisi'], 'total' => $lainnya['total'] - $lainnya['tidak_berlaku']],
];
}
/** Warna badge hitungan ['terisi','total']: hijau lengkap, kuning sebagian, merah kosong. */
public static function warna(array $hitungan): string
{
return match (true) {
$hitungan['total'] > 0 && $hitungan['terisi'] >= $hitungan['total'] => 'success',
$hitungan['terisi'] > 0 => 'warning',
default => 'danger',
};
}
/**
* Rincian pertanyaan → jawaban untuk side panel Monitoring.
* Setiap baris: grup, label, jawaban (string|null = belum diisi), terisi.
*
* @param 'skor'|'wajib'|'lainnya' $jenis
*/
public function rincian(string $jenis): Collection
{
return match ($jenis) {
'skor' => collect($this->pertanyaanSkor()),
'wajib' => $this->isianDashboard()->map(fn (array $item) => [
'grup' => $item['grup'] ?? self::TAB_LABEL[$item['tab']],
'label' => $item['label'],
'jawaban' => isset($item['jawaban']) ? ($item['jawaban'])() : self::jawaban($item['nilai'], $item['input']),
'terisi' => $item['terisi'],
]),
'lainnya' => $this->rincianLainnya(),
};
}
/** Isian PERLU (dashboard) dari semua tab, tiap item diberi kunci 'tab'. */
private function isianDashboard(): Collection
{
return $this->isianDashboard ??= $this->semuaItem()
->flatMap(fn (array $items, string $tab) => array_map(fn ($item) => [...$item, 'tab' => $tab], $items))
->where('prioritas', self::PERLU)
->values();
}
/**
* 32 pertanyaan skor (petani_skor): sudah bisa dinilai atau belum, beserta jawabannya.
* Pertanyaan lanjutan yang tidak berlaku (mis. data kelompok kalau bukan anggota) dianggap sudah terjawab.
*/
private function pertanyaanSkor(): array
{
return $this->pertanyaanSkor ??= $this->susunPertanyaanSkor();
}
private function susunPertanyaanSkor(): array
{
$p = $this->petani;
$pb = $p->praktikBudidaya;
$panen = $p->surveyPanen;
$se = $p->sosialEkonomi;
$ap = $p->aksesPasar;
$tanya = fn (string $grup, string $label, ?object $model, string $kolom) => [
'grup' => $grup,
'label' => $label,
'jawaban' => self::jawaban($model?->{$kolom}, $kolom),
'terisi' => $model && self::terisi($model->{$kolom}),
];
// Data kelompok hanya ditanyakan ke anggota.
$terdaftar = self::nilaiEnum($se?->terdaftar_kelompok_tani);
$bukanAnggota = self::terisi($terdaftar) && $terdaftar !== TerdaftarKelompokTani::YA->value;
$kelompok = fn (string $label, string $kolom) => $bukanAnggota
? ['grup' => 'Sosial Ekonomi', 'label' => $label, 'jawaban' => 'Tidak berlaku (bukan anggota kelompok tani)', 'terisi' => true]
: [...$tanya('Sosial Ekonomi', $label, $se, $kolom), 'terisi' => self::terisi($terdaftar) && $se && self::terisi($se->{$kolom})];
// Konservasi: pemahaman manfaat hanya ditanyakan kalau ada praktik konservasi.
$konservasi = (array) ($pb?->kebiasaan_konservasi_tanah ?? []);
$adaKonservasi = collect($konservasi)->contains(fn ($v) => $v !== self::KONSERVASI_TIDAK_ADA);
// Rasio biaya produksi : pendapatan.
$biaya = $pb?->total_estimasi_biaya_produksi;
$pendapatan = (int) ($se?->pendapatanKomoditas->sum('pendapatan') ?? 0);
return [
// Sosial Ekonomi (20)
$tanya('Sosial Ekonomi', 'Kemauan mencoba praktik baru', $se, 'kemauan_mencoba_praktik_baru'),
$tanya('Sosial Ekonomi', 'Pemahaman mutu & harga', $se, 'pemahaman_mutu_harga'),
$tanya('Sosial Ekonomi', 'Panutan petani sukses', $se, 'panutan_petani_sukses'),
$tanya('Sosial Ekonomi', 'Kemauan ikut program', $se, 'kemauan_ikut_program'),
$tanya('Sosial Ekonomi', 'Orientasi rencana jangka panjang', $se, 'orientasi_rencana_jangka_panjang'),
$tanya('Sosial Ekonomi', 'Periode paceklik', $se, 'skor_periode_paceklik'),
$tanya('Sosial Ekonomi', 'Kemampuan menabung', $se, 'skor_kemampuan_menabung'),
$tanya('Sosial Ekonomi', 'Kecukupan penghasilan', $se, 'skor_kecukupan_penghasilan'),
[
'grup' => 'Sosial Ekonomi',
'label' => 'Rasio biaya produksi terhadap pendapatan',
'jawaban' => self::terisi($biaya) || $pendapatan > 0
? 'Biaya ' . self::rupiah($biaya) . ' / Pendapatan ' . self::rupiah($pendapatan)
: null,
'terisi' => self::terisi($biaya) && $pendapatan > 0,
],
$tanya('Sosial Ekonomi', 'Kejelasan status lahan', $se, 'skor_kejelasan_status_lahan'),
$kelompok('Keaktifan pertemuan kelompok', 'keaktifan_pertemuan_kelompok'),
$kelompok('Peran dalam kelompok', 'peran_dalam_kelompok'),
$kelompok('Manfaat kelompok tani', 'manfaat_kelompok_tani'),
$tanya('Sosial Ekonomi', 'Status rekening tabungan', $se, 'status_rekening_tabungan'),
$tanya('Sosial Ekonomi', 'Pengalaman kredit kebun', $se, 'pengalaman_kredit_kebun'),
$tanya('Sosial Ekonomi', 'Kondisi aset produktif', $se, 'kondisi_aset_produktif'),
$tanya('Sosial Ekonomi', 'Keterlibatan program keuangan formal', $se, 'keterlibatan_program_keuangan_formal'),
$tanya('Sosial Ekonomi', 'Keterikatan hutang panen', $se, 'keterikatan_hutang_panen'),
$tanya('Sosial Ekonomi', 'Praktik ijon', $se, 'praktik_ijon'),
$tanya('Sosial Ekonomi', 'Kebebasan memilih pembeli', $se, 'kebebasan_memilih_pembeli'),
// Praktik Budidaya (7)
$tanya('Praktik Budidaya', 'Frekuensi pemangkasan', $pb, 'frekuensi_pemangkasan_utama'),
$tanya('Praktik Budidaya', 'Pemberian pupuk', $pb, 'pemberian_pupuk'),
$tanya('Praktik Budidaya', 'Metode penanganan hama & penyakit', $pb, 'metode_penanganan_opt'),
$tanya('Praktik Budidaya', 'Frekuensi sanitasi lahan', $pb, 'frekuensi_sanitasi_lahan'),
$tanya('Praktik Budidaya', 'Penanganan gulma', $pb, 'penanganan_gulma'),
$tanya('Praktik Budidaya', 'Kondisi pengelolaan air & irigasi', $pb, 'kondisi_pengelolaan_air'),
[
'grup' => 'Praktik Budidaya',
'label' => 'Praktik konservasi tanah',
'jawaban' => self::jawaban(array_filter([
self::jawaban($konservasi),
$adaKonservasi ? self::jawaban($pb?->pemahaman_manfaat_konservasi_tanah) : null,
]), null, ' · '),
'terisi' => self::terisi($konservasi) && (! $adaKonservasi || self::terisi($pb?->pemahaman_manfaat_konservasi_tanah)),
],
// Panen (2)
$tanya('Panen', 'Dasar penentuan panen', $panen, 'dasar_penentuan_panen'),
$tanya('Panen', 'Praktik sortasi', $panen, 'praktik_sortasi'),
// Akses Pasar (3); hambatan mutu dari tingkat hasil panen ditolak pembeli
$tanya('Akses Pasar', 'Pengalaman menjual ke saluran lain', $ap, 'skor_pengalaman'),
$tanya('Akses Pasar', 'Kesadaran pasar premium', $ap, 'skor_kesadaran_premium'),
$tanya('Akses Pasar', 'Hambatan mutu (hasil panen ditolak pembeli)', $panen, 'hasil_panen_reject'),
];
}
/**
* Ubah nilai isian jadi teks jawaban. null = belum diisi.
* Kolom yang disimpan sebagai angka (tanpa cast enum di model) diterjemahkan lewat ENUM_JAWABAN.
*/
public static function jawaban(mixed $nilai, ?string $kolom = null, string $pemisah = ', '): ?string
{
if (! self::terisi($nilai)) {
return null;
}
if ($kolom && in_array($kolom, self::KOLOM_BERKAS, true)) {
return 'Sudah diunggah';
}
if ($nilai instanceof Collection) {
$nilai = $nilai->all();
}
if (is_array($nilai)) {
$isi = array_filter(array_map(fn ($v) => self::jawaban($v, $kolom), $nilai));
return $isi ? implode($pemisah, $isi) : null;
}
if ($kolom && isset(self::ENUM_JAWABAN[$kolom]) && is_numeric($nilai)) {
$nilai = self::ENUM_JAWABAN[$kolom]::tryFrom((int) $nilai) ?? $nilai;
}
// Kolom uang (biaya, upah, harga, hutang, tabungan, pendapatan) → "Rp 1.500.000".
if ($kolom && is_numeric($nilai) && preg_match('/biaya|upah|harga|hutang_rupiah|tabungan|pendapatan|estimasi_per_bulan/', $kolom)) {
return self::rupiah($nilai);
}
return match (true) {
$nilai instanceof \BackedEnum => self::labelEnum($nilai),
$nilai instanceof \DateTimeInterface => $nilai->format('d-m-Y'),
is_bool($nilai) => $nilai ? 'Ya' : 'Tidak',
is_object($nilai) => json_encode($nilai),
default => (string) $nilai,
};
}
/** Label enum: dari label(), atau dari options() untuk enum yang hanya punya daftar opsi. */
private static function labelEnum(\BackedEnum $enum): string
{
if (method_exists($enum, 'label')) {
return $enum->label();
}
$opsi = method_exists($enum, 'options') ? collect($enum::options())->firstWhere('value', $enum->value) : null;
return $opsi['label'] ?? (string) $enum->value;
}
private static function rupiah(mixed $nilai): string
{
return self::terisi($nilai) ? 'Rp ' . number_format((float) $nilai, 0, ',', '.') : '-';
}
/** "Biji kering: Rp 65.000, Fermentasi: Rp 80.000" dari baris harga jual yang terisi. */
private static function jawabanHarga(Collection $harga, string $kolom): ?string
{
return self::jawaban($harga->filter(fn ($h) => (float) $h->{$kolom} > 0)
->map(fn ($h) => trim(($h->bentuk_produk ? $h->bentuk_produk . ': ' : '') . self::rupiah($h->{$kolom})))
->all());
}
/** Isian lainnya (di luar skor & dashboard) beserta jawabannya; tidak termasuk yang tidak berlaku. */
private function rincianLainnya(): Collection
{
$this->rincianLainnya = [];
$this->hitungSemuaIsian();
$hasil = collect($this->rincianLainnya)->map(fn (array $r) => [...$r, 'jawaban' => ($r['jawaban'])()]);
$this->rincianLainnya = null;
return $hasil;
}
private function hitungSemuaIsian(): array
{
$hasil = ['terisi' => 0, 'total' => 0, 'tidak_berlaku' => 0];
$hasil = [
'terisi' => 0, 'total' => 0, 'tidak_berlaku' => 0,
'lainnya' => ['terisi' => 0, 'total' => 0, 'tidak_berlaku' => 0],
];
$p = $this->petani;
// Profil (+ provinsi/kabupaten/kecamatan yang mengikuti desa)
$this->hitungKolom($hasil, $p, self::ISIAN_PROFIL);
// Profil (+ provinsi/kabupaten/kecamatan yang mengikuti desa; termasuk prioritas)
$this->grupRincian = 'Profil';
$this->hitungKolom($hasil, $p, self::ISIAN_PROFIL, [], self::PRIORITAS_PROFIL);
$this->hitungNilai($hasil, 3, self::terisi($p->desa_kelurahan_id));
// Lahan & Tanaman, per lahan. Belum ada lahan = dihitung 1 lahan kosong.
$daftarLahan = $p->lahans->isEmpty() ? collect([null]) : $p->lahans;
foreach ($daftarLahan as $lahan) {
$this->hitungKolom($hasil, $lahan, self::ISIAN_LAHAN);
foreach ($daftarLahan->values() as $i => $lahan) {
$this->grupRincian = $lahan?->nama ?: 'Lahan ' . ($i + 1);
$this->hitungKolom($hasil, $lahan, self::ISIAN_LAHAN, [], self::PRIORITAS_LAHAN);
$this->hitungNilai($hasil, 3, self::terisi($lahan?->desa_kelurahan_id));
}
@@ -190,11 +509,14 @@ class KelengkapanPetani
if ((int) $pb?->metode_penanganan_opt === MetodePenangananHamaPenyakit::TIDAK_ADA->value) {
array_push($tidakBerlakuPb, 'jenis_hama', 'jenis_penyakit');
}
$this->hitungKolom($hasil, $pb, self::ISIAN_PRAKTIK_BUDIDAYA, $tidakBerlakuPb);
$this->hitungKolom($hasil, $p->surveyPanen, self::ISIAN_PANEN);
$this->grupRincian = 'Praktik Budidaya';
$this->hitungKolom($hasil, $pb, self::ISIAN_PRAKTIK_BUDIDAYA, $tidakBerlakuPb, self::PRIORITAS_PRAKTIK_BUDIDAYA);
$this->grupRincian = 'Panen & Produktivitas';
$this->hitungKolom($hasil, $p->surveyPanen, self::ISIAN_PANEN, [], self::PRIORITAS_PANEN);
$this->hitungRepeater($hasil, $p->surveyProduktivitas, self::ISIAN_PRODUKTIVITAS);
// Pendidikan & Pelatihan
$this->grupRincian = 'Pendidikan & Pelatihan';
$this->hitungRepeater($hasil, $p->surveyPelatihan, self::ISIAN_PELATIHAN);
$this->hitungRepeater($hasil, $p->surveySertifikasi, self::ISIAN_SERTIFIKASI);
@@ -204,9 +526,10 @@ class KelengkapanPetani
? ['nama_kelompok_tani', 'lama_bergabung_kelompok_tahun', 'kedekatan_sesama_anggota',
'keaktifan_pertemuan_kelompok', 'topik_biasa_dibahas', 'peran_dalam_kelompok', 'manfaat_kelompok_tani']
: [];
$this->hitungKolom($hasil, $se, self::ISIAN_SOSIAL_EKONOMI, $tidakBerlakuSe);
$this->grupRincian = 'Sosial Ekonomi';
$this->hitungKolom($hasil, $se, self::ISIAN_SOSIAL_EKONOMI, $tidakBerlakuSe, self::PRIORITAS_SOSIAL_EKONOMI);
$this->hitungRepeater($hasil, collect($se?->detail_bantuan_ngo_perusahaan ?? []), self::ISIAN_BANTUAN_NGO);
$this->hitungRepeater($hasil, $se?->pendapatanKomoditas ?? collect(), self::ISIAN_PENDAPATAN_KOMODITAS);
$this->hitungRepeater($hasil, $se?->pendapatanKomoditas ?? collect(), self::ISIAN_PENDAPATAN_KOMODITAS, self::PRIORITAS_PENDAPATAN_KOMODITAS);
$this->hitungRepeater($hasil, $se?->pendapatanLuarPertanian ?? collect(), self::ISIAN_PENDAPATAN_LUAR);
// Akses Pasar (detail fermentasi tidak berlaku kalau belum pernah)
@@ -214,8 +537,9 @@ class KelengkapanPetani
$tidakBerlakuAp = self::nilaiEnum($ap?->praktik_fermentasi) === PraktikFermentasi::BELUM_PERNAH->value
? ['proses_fermentasi', 'model_fermentasi_memungkinkan']
: [];
$this->hitungKolom($hasil, $ap, self::ISIAN_AKSES_PASAR, $tidakBerlakuAp);
$this->hitungRepeater($hasil, $p->hargaJualPanen, self::ISIAN_HARGA_JUAL);
$this->grupRincian = 'Akses Pasar';
$this->hitungKolom($hasil, $ap, self::ISIAN_AKSES_PASAR, $tidakBerlakuAp, self::PRIORITAS_AKSES_PASAR);
$this->hitungRepeater($hasil, $p->hargaJualPanen, self::ISIAN_HARGA_JUAL, self::ISIAN_HARGA_JUAL);
$hasil['berlaku'] = $hasil['total'] - $hasil['tidak_berlaku'];
$hasil['persen'] = $hasil['berlaku'] > 0 ? (int) floor($hasil['terisi'] / $hasil['berlaku'] * 100) : 0;
@@ -228,34 +552,79 @@ class KelengkapanPetani
return $nilai instanceof \BackedEnum ? $nilai->value : $nilai;
}
private function hitungKolom(array &$hasil, ?object $model, array $kolom, array $tidakBerlaku = []): void
/**
* $prioritas: kolom sumber skor/dashboard. Kolom lain juga dicatat di $hasil['lainnya'].
*/
private function hitungKolom(array &$hasil, ?object $model, array $kolom, array $tidakBerlaku = [], array $prioritas = []): void
{
foreach ($kolom as $k) {
$hasil['total']++;
if (in_array($k, $tidakBerlaku, true)) {
$hasil['tidak_berlaku']++;
} elseif ($model && self::terisi($model->{$k})) {
$hasil['terisi']++;
}
$status = match (true) {
in_array($k, $tidakBerlaku, true) => 'tidak_berlaku',
$model && self::terisi($model->{$k}) => 'terisi',
default => null,
};
$this->catat($hasil, $status, ! in_array($k, $prioritas, true), $k, fn () => self::jawabanKolom($model, $k));
}
}
/** Wilayah (provinsi/kabupaten/kecamatan) yang mengikuti desa; termasuk isian prioritas. */
private function hitungNilai(array &$hasil, int $jumlah, bool $terisi): void
{
$hasil['total'] += $jumlah;
$hasil['terisi'] += $terisi ? $jumlah : 0;
for ($i = 0; $i < $jumlah; $i++) {
$this->catat($hasil, $terisi ? 'terisi' : null, false);
}
}
/** Kolom repeater dianggap terisi kalau minimal satu baris mengisinya. */
private function hitungRepeater(array &$hasil, Collection $baris, array $kolom): void
private function hitungRepeater(array &$hasil, Collection $baris, array $kolom, array $prioritas = []): void
{
foreach ($kolom as $k) {
$hasil['total']++;
$hasil['terisi'] += $baris->contains(fn ($row) => self::terisi(data_get($row, $k))) ? 1 : 0;
$terisi = $baris->contains(fn ($row) => self::terisi(data_get($row, $k)));
$this->catat($hasil, $terisi ? 'terisi' : null, ! in_array($k, $prioritas, true), $k, fn () => self::jawaban(
$baris->map(fn ($row) => is_object($row) ? self::jawabanKolom($row, $k) : self::jawaban(data_get($row, $k), $k))->unique()->all()
));
}
}
/**
* $status: 'terisi', 'tidak_berlaku', atau null (kosong).
* $jawaban dipanggil hanya saat rincian lainnya dikumpulkan.
*/
private function catat(array &$hasil, ?string $status, bool $lainnya, ?string $kolom = null, ?Closure $jawaban = null): void
{
$hasil['total']++;
$lainnya && $hasil['lainnya']['total']++;
if ($status) {
$hasil[$status]++;
$lainnya && $hasil['lainnya'][$status]++;
}
if ($lainnya && $kolom && $this->rincianLainnya !== null && $status !== 'tidak_berlaku') {
$this->rincianLainnya[] = [
'grup' => $this->grupRincian,
'label' => self::LABEL_KOLOM[$kolom] ?? Str::ucfirst(str_replace('_', ' ', $kolom)),
'jawaban' => $jawaban,
'terisi' => $status === 'terisi',
];
}
}
/** Jawaban satu kolom model; kolom *_id ditampilkan nama relasinya (mis. komoditas_id -> komoditas). */
private static function jawabanKolom(?object $model, string $kolom): ?string
{
if (! $model) {
return null;
}
$relasi = str_ends_with($kolom, '_id') ? Str::camel(substr($kolom, 0, -3)) : null;
if ($relasi && self::terisi($model->{$kolom}) && method_exists($model, $relasi)) {
return $model->{$relasi}?->nama ?? (string) $model->{$kolom};
}
return self::jawaban($model->{$kolom}, $kolom);
}
/**
* Isian yang masih kosong di satu tab.
* Setiap item: label, prioritas (wajib/perlu), input (atribut name di form),
@@ -266,33 +635,30 @@ class KelengkapanPetani
return $this->semuaKosong()->get($tab, collect());
}
private function hitungKosong(string $tab): Collection
/** Isian kosong semua tab, dikelompokkan per tab (untuk tab Kesiapan Adopsi). */
public function semuaKosong(): Collection
{
$isian = match ($tab) {
return $this->semuaKosong ??= $this->semuaItem()->map(fn (array $isian) => collect($isian)
->reject(fn (array $item) => $item['terisi'])
->map(fn (array $item) => collect($item)->except('terisi')->all())
->values());
}
/** Semua isian WAJIB/PERLU (terisi maupun kosong), per tab. */
private function semuaItem(): Collection
{
return $this->semuaItem ??= collect([
'profil' => $this->profil(),
'lahan' => $this->lahan(),
'praktik-budidaya' => $this->praktikBudidaya(),
'sosial-ekonomi' => $this->sosialEkonomi(),
'akses-pasar' => $this->aksesPasar(),
default => [],
};
return collect($isian)
->reject(fn (array $item) => $item['terisi'])
->map(fn (array $item) => collect($item)->except('terisi')->all())
->values();
}
/** Isian kosong semua tab, dikelompokkan per tab (untuk tab Kesiapan Adopsi). */
public function semuaKosong(): Collection
{
return $this->semuaKosong ??= collect(array_keys(self::TAB_LABEL))
->mapWithKeys(fn (string $tab) => [$tab => $this->hitungKosong($tab)]);
])->map(fn (array $isian) => array_values($isian));
}
private function item(string $label, string $prioritas, string $input, mixed $nilai, array $tambahan = []): array
{
return $this->itemCek($label, $prioritas, $input, self::terisi($nilai), $tambahan);
return $this->itemCek($label, $prioritas, $input, self::terisi($nilai), ['nilai' => $nilai, ...$tambahan]);
}
/** Untuk isian yang status terisinya dihitung sendiri (mis. repeater). */
@@ -303,6 +669,7 @@ class KelengkapanPetani
'prioritas' => $prioritas,
'input' => $input,
'terisi' => $terisi,
'nilai' => null,
'grup' => null,
'lahan_id' => null,
'url' => null,
@@ -324,11 +691,17 @@ class KelengkapanPetani
$p = $this->petani;
return [
$this->item('Jenis kelamin', self::PERLU, 'jenis_kelamin', $p->jenis_kelamin),
$this->item('Jenis kelamin', self::PERLU, 'jenis_kelamin', $p->jenis_kelamin, [
'jawaban' => fn () => is_null($p->jenis_kelamin) ? null : ($p->jenis_kelamin ? 'Laki-laki' : 'Perempuan'),
]),
$this->item('Tanggal lahir', self::PERLU, 'tanggal_lahir', $p->tanggal_lahir),
$this->item('Pendidikan terakhir', self::PERLU, 'pendidikan_terakhir', $p->pendidikan_terakhir),
$this->item('Komoditas utama', self::PERLU, 'komoditas_id', $p->komoditas_id),
$this->item('Desa/kelurahan', self::PERLU, 'desa_kelurahan_id', $p->desa_kelurahan_id),
$this->item('Komoditas utama', self::PERLU, 'komoditas_id', $p->komoditas_id, [
'jawaban' => fn () => self::jawabanKolom($p, 'komoditas_id'),
]),
$this->item('Desa/kelurahan', self::PERLU, 'desa_kelurahan_id', $p->desa_kelurahan_id, [
'jawaban' => fn () => self::jawabanKolom($p, 'desa_kelurahan_id'),
]),
];
}
@@ -356,7 +729,9 @@ class KelengkapanPetani
return array_filter([
$this->item('Luas lahan', self::PERLU, 'luas_lahan', $l->luas_lahan, $tambahan),
$this->item('Peta/polygon lahan', self::PERLU, 'path', $l->path, $tambahan),
$this->item('Desa lahan', self::PERLU, 'desa_kelurahan_id', $l->desa_kelurahan_id, $tambahan),
$this->item('Desa lahan', self::PERLU, 'desa_kelurahan_id', $l->desa_kelurahan_id, [
...$tambahan, 'jawaban' => fn () => self::jawabanKolom($l, 'desa_kelurahan_id'),
]),
$this->item('Umur tanaman', self::PERLU, 'umur_tanaman', $l->umur_tanaman, $tambahan),
$this->item('Jumlah tanaman', self::PERLU, 'jumlah_tanaman', $l->jumlah_tanaman, $tambahan),
$this->item('Kondisi tanaman', self::PERLU, 'kondisi_tanaman', $l->kondisi_tanaman, $tambahan),
@@ -457,11 +832,14 @@ class KelengkapanPetani
$this->item('Kesadaran pasar premium', self::WAJIB, 'skor_kesadaran_premium', $ap?->skor_kesadaran_premium),
$this->item('Kepercayaan pada pembeli baru', self::WAJIB, 'tingkat_kepercayaan_pembeli_baru', $ap?->tingkat_kepercayaan_pembeli_baru),
$this->itemCek('Harga jual panen terakhir', self::PERLU, 'harga_jual',
$harga->contains(fn ($h) => self::terisi($h->bentuk_produk) && (float) $h->harga_panen_terakhir > 0)),
$harga->contains(fn ($h) => self::terisi($h->bentuk_produk) && (float) $h->harga_panen_terakhir > 0),
['jawaban' => fn () => self::jawabanHarga($harga, 'harga_panen_terakhir')]),
$this->itemCek('Harga jual tahun lalu', self::PERLU, 'harga_jual',
$harga->contains(fn ($h) => (float) $h->harga_tahun_lalu > 0)),
$harga->contains(fn ($h) => (float) $h->harga_tahun_lalu > 0),
['jawaban' => fn () => self::jawabanHarga($harga, 'harga_tahun_lalu')]),
$this->itemCek('Harga jual dua tahun lalu', self::PERLU, 'harga_jual',
$harga->contains(fn ($h) => (float) $h->harga_dua_tahun_lalu > 0)),
$harga->contains(fn ($h) => (float) $h->harga_dua_tahun_lalu > 0),
['jawaban' => fn () => self::jawabanHarga($harga, 'harga_dua_tahun_lalu')]),
];
}
}