id(); $table->string('nama'); $table->softDeletes(); }); foreach ([ 'master_kabupaten_kota' => 'provinsi_id', 'master_kecamatan' => 'kabupaten_kota_id', 'master_desa_kelurahan' => 'kecamatan_id', ] as $name => $parent) { Schema::create($name, function (Blueprint $table) use ($parent) { $table->id(); $table->integer($parent); }); } Schema::create('petani', function (Blueprint $table) { $table->id(); $table->integer('komoditas_id'); $table->integer('desa_kelurahan_id'); $table->softDeletes(); }); Schema::create('harga_jual_panen', function (Blueprint $table) { $table->id(); $table->integer('petani_id'); $table->string('bentuk_produk'); $table->integer('harga_panen_terakhir')->nullable(); $table->integer('harga_tahun_lalu')->nullable(); $table->integer('harga_dua_tahun_lalu')->nullable(); }); Schema::create('petani_skor', function (Blueprint $table) { $table->id(); $table->integer('petani_id'); $table->float('skor_kesiapan_adopsi'); $table->softDeletes(); }); Schema::create('akses_pasar', function (Blueprint $table) { $table->id(); $table->integer('petani_id'); $table->text('tujuan_penjualan')->nullable(); $table->text('sifat_hubungan_bisnis')->nullable(); $table->string('frekuensi_penjualan')->nullable(); $table->integer('skor_pengalaman')->nullable(); $table->integer('skor_kesadaran_premium')->nullable(); $table->softDeletes(); }); DB::table('master_komoditas')->insert([ ['id' => 1, 'nama' => 'Kakao'], ['id' => 2, 'nama' => 'Vanili'], ['id' => 3, 'nama' => 'Kopi'], ['id' => 4, 'nama' => 'Kemiri'], ]); DB::table('master_kabupaten_kota')->insert(['id' => 1, 'provinsi_id' => 1]); DB::table('master_kecamatan')->insert(['id' => 1, 'kabupaten_kota_id' => 1]); DB::table('master_desa_kelurahan')->insert([ ['id' => 1, 'kecamatan_id' => 1], ['id' => 2, 'kecamatan_id' => 1], ]); DB::table('petani')->insert([ ['id' => 1, 'komoditas_id' => 1, 'desa_kelurahan_id' => 1, 'deleted_at' => null], ['id' => 2, 'komoditas_id' => 1, 'desa_kelurahan_id' => 2, 'deleted_at' => null], ['id' => 3, 'komoditas_id' => 2, 'desa_kelurahan_id' => 1, 'deleted_at' => null], ['id' => 4, 'komoditas_id' => 3, 'desa_kelurahan_id' => 1, 'deleted_at' => null], ['id' => 5, 'komoditas_id' => 4, 'desa_kelurahan_id' => 1, 'deleted_at' => null], ['id' => 6, 'komoditas_id' => 1, 'desa_kelurahan_id' => 1, 'deleted_at' => '2026-01-01'], ]); foreach ([ [1, 'Biji kering/Biji Kupas', 10000], [2, 'Biji kering/Biji Kupas', 20000], [1, 'Biji kering/Biji Kupas', 0], [1, 'Biji kering/Biji Kupas', null], [1, 'Biji kering/Biji Kupas', -1000], [1, 'Biji basah', 90000], [1, 'Buah segar hasil petik', 5000], [3, 'Buah segar hasil petik', 30000], [3, 'Biji kering/Biji Kupas', 500000], [3, 'Terfermentasi (Rp/kg)', 900000], [4, 'Buah segar hasil petik', 12000], [5, 'Buah segar hasil petik', 8000], [5, 'Biji kering/Biji Kupas', 40000], [6, 'Biji kering/Biji Kupas', 1000000], ] as [$farmer, $product, $price]) { DB::table('harga_jual_panen')->insert([ 'petani_id' => $farmer, 'bentuk_produk' => $product, 'harga_panen_terakhir' => $price, ]); } DB::table('petani_skor')->insert([ ['petani_id' => 1, 'skor_kesiapan_adopsi' => 4.2, 'deleted_at' => null], ['petani_id' => 2, 'skor_kesiapan_adopsi' => 2.5, 'deleted_at' => null], ['petani_id' => 3, 'skor_kesiapan_adopsi' => 4.5, 'deleted_at' => '2026-01-01'], ]); $controller = app(SummaryController::class); $method = new ReflectionMethod($controller, 'marketDashboardData'); $this->marketCards = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['priceCards']; $this->marketCharts = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['marketCharts']; $this->priceTrend = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['priceTrend']; $this->premiumAwareness = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['premiumAwareness']; }); test('market shows all four prices using the appropriate product and positive active farmer prices', function () { $cards = ($this->marketCards)(); expect(array_column($cards, 'label'))->toBe([ 'Kakao - Harga Biji Kering', 'Vanili - Harga Buah Segar', 'Kopi - Harga Ceri Merah', 'Kemiri - Harga Gelondong', ]); expect(array_column($cards, 'value'))->toBe(['Rp 15.000', 'Rp 30.000', 'Rp 12.000', 'Rp 8.000']); expect(array_column($cards, 'records'))->toBe([2, 1, 1, 1]); }); test('market prices respect each region level and commodity selection', function () { foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) { expect(($this->marketCards)([$region => 1])[0]['value']) ->toBe($region === 'desaKelurahan_id' ? 'Rp 10.000' : 'Rp 15.000'); expect(array_column(($this->marketCards)([$region => 99]), 'records'))->toBe([0, 0, 0, 0]); } $cards = ($this->marketCards)(['komoditas_id' => 2]); expect(array_column($cards, 'records'))->toBe([0, 1, 0, 0]); expect(array_column($cards, 'value'))->toBe(['Rp 0', 'Rp 30.000', 'Rp 0', 'Rp 0']); }); test('market prices respect segment selection and ignore deleted scores', function () { $cards = ($this->marketCards)(['segmen' => 'Penggerak']); expect(array_column($cards, 'records'))->toBe([1, 0, 0, 0]); expect($cards[0]['value'])->toBe('Rp 10.000'); expect(($this->marketCards)(['segmen' => 'Pemerhati'])[0]['value'])->toBe('Rp 20.000'); }); test('market keeps four empty cards when no prices or active commodities are available', function () { DB::table('master_komoditas')->update(['deleted_at' => '2026-01-01']); $cards = ($this->marketCards)(); expect(array_column($cards, 'records'))->toBe([0, 0, 0, 0]); expect(array_column($cards, 'value'))->toBe(['Rp 0', 'Rp 0', 'Rp 0', 'Rp 0']); }); test('market charts count multiple channels once per respondent and use answered question denominators', function () { DB::table('akses_pasar')->insert([ [ 'petani_id' => 1, 'tujuan_penjualan' => json_encode(['Koperasi', 'BUMDes', 'Koperasi', 'Lainnya']), 'sifat_hubungan_bisnis' => 'Bebas, bisa pindah pembeli kapan saja', 'frekuensi_penjualan' => 'Langsung setelah panen', 'skor_pengalaman' => 5, ], [ 'petani_id' => 2, 'tujuan_penjualan' => json_encode(['Koperasi']), 'sifat_hubungan_bisnis' => 'Titip jual,dibayar setelah produk terjual', 'frekuensi_penjualan' => null, 'skor_pengalaman' => 1, ], [ 'petani_id' => 3, 'tujuan_penjualan' => '[]', 'sifat_hubungan_bisnis' => '', 'frekuensi_penjualan' => 'Saat butuh uang mendesak', 'skor_pengalaman' => 3, ], ]); $charts = ($this->marketCharts)(); expect($charts['saluranPenjualan']['series'])->toBe([0, 2, 1, 0, 1]); expect($charts['saluranPenjualan']['respondents'])->toBe(2); expect($charts['saluranPenjualan']['percentages'])->toBe([0.0, 100.0, 50.0, 0.0, 50.0]); expect($charts['hubunganPembeli']['series'])->toBe([1, 0, 0, 1, 0, 0]); expect($charts['hubunganPembeli']['respondents'])->toBe(2); expect($charts['waktuPenjualan']['series'])->toBe([1, 0, 1]); expect($charts['waktuPenjualan']['respondents'])->toBe(2); expect($charts['aksesSaluranLain']['series'])->toBe([1, 0, 1, 0, 1]); expect($charts['aksesSaluranLain']['percentages'])->toBe([33.3, 0.0, 33.3, 0.0, 33.3]); }); test('market charts respect all dashboard filters and exclude deleted farmers and responses', function () { foreach ([1, 2, 3, 6] as $farmer) { DB::table('akses_pasar')->insert([ 'petani_id' => $farmer, 'tujuan_penjualan' => json_encode(['Koperasi']), 'sifat_hubungan_bisnis' => 'Langganan tetap, tapi tidak terikat', 'frekuensi_penjualan' => 'Ditumpuk dulu beberapa waktu', 'skor_pengalaman' => 4, ]); } DB::table('akses_pasar')->insert([ 'petani_id' => 4, 'tujuan_penjualan' => json_encode(['BUMDes']), 'deleted_at' => '2026-01-01', ]); foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) { foreach (($this->marketCharts)([$region => 1]) as $chart) { expect($chart['respondents'])->toBe($region === 'desaKelurahan_id' ? 2 : 3); } foreach (($this->marketCharts)([$region => 99]) as $chart) { expect($chart['respondents'])->toBe(0); } } foreach ([['komoditas_id' => 1], ['komoditas_id' => 2], ['segmen' => 'Penggerak'], ['segmen' => 'Pemerhati']] as $filters) { foreach (($this->marketCharts)($filters) as $chart) { expect($chart['respondents'])->toBe($filters === ['komoditas_id' => 1] ? 2 : 1); } } foreach (($this->marketCharts)(['komoditas_id' => 2, 'segmen' => 'Penggerak']) as $chart) { expect($chart['respondents'])->toBe(0); } }); test('market charts keep ordered zero categories when answers are missing or invalid', function () { DB::table('akses_pasar')->insert([ 'petani_id' => 1, 'tujuan_penjualan' => '[null, {}, "Unknown"]', 'sifat_hubungan_bisnis' => 'Unknown', 'frekuensi_penjualan' => '', 'skor_pengalaman' => 9, ]); $charts = ($this->marketCharts)(); expect(array_map(fn ($chart) => count($chart['series']), $charts))->toBe([ 'saluranPenjualan' => 5, 'hubunganPembeli' => 6, 'waktuPenjualan' => 3, 'aksesSaluranLain' => 5, ]); foreach ($charts as $chart) { expect($chart['respondents'])->toBe(0); expect(array_sum($chart['series']))->toBe(0); expect(array_sum($chart['percentages']))->toBe(0); } }); test('price trend averages each product and commodity chronologically without treating missing prices as zero', function () { DB::table('harga_jual_panen')->delete(); foreach ([ [1, 'Buah segar hasil petik', 10000, 8000, 6000], [2, 'Buah segar hasil petik', 20000, 12000, null], [3, 'Buah segar hasil petik', 30000, 20000, 10000], [1, 'Biji basah', 0, 7000, 4000], [1, 'Biji kering/Biji Kupas', -1000, null, 0], [1, 'Terfermentasi (Rp/kg)', null, 20000, 10000], [6, 'Buah segar hasil petik', 1000000, 1000000, 1000000], ] as [$farmer, $product, $latest, $lastYear, $twoYearsAgo]) { DB::table('harga_jual_panen')->insert([ 'petani_id' => $farmer, 'bentuk_produk' => $product, 'harga_panen_terakhir' => $latest, 'harga_tahun_lalu' => $lastYear, 'harga_dua_tahun_lalu' => $twoYearsAgo, ]); } $trend = ($this->priceTrend)(); $commodities = array_column($trend['commodities'], null, 'id'); expect($trend['periods'])->toBe(['Dua Tahun Lalu', 'Tahun Lalu', 'Panen Terakhir']); expect(array_column($commodities[1]['series'], 'name'))->toBe([ 'Buah segar hasil petik', 'Biji basah', 'Biji kering/Biji Kupas', 'Terfermentasi', ]); expect(array_column($commodities[1]['series'], 'data'))->toBe([ [6000.0, 10000.0, 15000.0], [4000.0, 7000.0, null], [null, null, null], [10000.0, 20000.0, null], ]); expect($commodities[2]['series'][0]['data'])->toBe([10000.0, 20000.0, 30000.0]); }); test('price trend respects dashboard region commodity and segment filters', function () { // Historical prices must follow the same filters as the latest harvest. DB::table('harga_jual_panen')->where('harga_panen_terakhir', '>', 0) ->update(['harga_tahun_lalu' => DB::raw('harga_panen_terakhir / 2')]); foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) { $commodities = array_column(($this->priceTrend)([$region => 1])['commodities'], null, 'id'); $price = $region === 'desaKelurahan_id' ? 10000.0 : 15000.0; expect($commodities[1]['series'][2]['data'])->toBe([null, $price / 2, $price]); foreach (($this->priceTrend)([$region => 99])['commodities'] as $commodity) { expect(array_column($commodity['series'], 'data'))->toBe(array_fill(0, 4, [null, null, null])); } } $trend = ($this->priceTrend)(['komoditas_id' => 2]); expect(array_column($trend['commodities'], 'id'))->toBe([2]); expect($trend['commodities'][0]['series'][0]['data'])->toBe([null, 15000.0, 30000.0]); foreach (['Penggerak' => 10000.0, 'Pemerhati' => 20000.0] as $segment => $price) { $commodities = array_column(($this->priceTrend)(['segmen' => $segment])['commodities'], null, 'id'); expect($commodities[1]['series'][2]['data'])->toBe([null, $price / 2, $price]); expect($commodities[2]['series'][0]['data'])->toBe([null, null, null]); } }); test('price trend retains missing points and handles empty or deleted commodity selections', function () { DB::table('harga_jual_panen')->delete(); $trend = ($this->priceTrend)(); expect($trend['commodities'])->toHaveCount(4); foreach ($trend['commodities'] as $commodity) { expect(array_column($commodity['series'], 'data'))->toBe(array_fill(0, 4, [null, null, null])); } expect(($this->priceTrend)(['komoditas_id' => 99])['commodities'])->toBe([]); DB::table('master_komoditas')->where('id', 1)->update(['deleted_at' => '2026-01-01']); expect(($this->priceTrend)(['komoditas_id' => 1])['commodities'])->toBe([]); }); test('premium awareness counts each active farmer once and preserves the five ordered levels', function () { DB::table('akses_pasar')->insert([ 'petani_id' => 4, 'skor_kesadaran_premium' => 4, 'deleted_at' => '2026-01-01', ]); foreach ([1 => 1, 2 => 3, 3 => 3, 4 => 5, 5 => null, 6 => 5] as $farmer => $score) { DB::table('akses_pasar')->insert(['petani_id' => $farmer, 'skor_kesadaran_premium' => $score]); } DB::table('akses_pasar')->insert(['petani_id' => 1, 'skor_kesadaran_premium' => 2]); $chart = ($this->premiumAwareness)(); expect($chart['series'])->toBe([1, 0, 2, 0, 1]); expect($chart['percentages'])->toBe([25.0, 0.0, 50.0, 0.0, 25.0]); expect($chart['respondents'])->toBe(4); expect($chart['fullLabels'][0])->toBe('Belum pernah mendengar sama sekali'); expect($chart['fullLabels'][4])->toBe('Sudah mengakses dan mendapat harga premium secara konsisten'); }); test('premium awareness respects all dashboard region commodity and segment filters', function () { foreach ([1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 5] as $farmer => $score) { DB::table('akses_pasar')->insert(['petani_id' => $farmer, 'skor_kesadaran_premium' => $score]); } foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) { $chart = ($this->premiumAwareness)([$region => 1]); expect($chart['respondents'])->toBe($region === 'desaKelurahan_id' ? 4 : 5); expect(($this->premiumAwareness)([$region => 99])['respondents'])->toBe(0); } expect(($this->premiumAwareness)(['komoditas_id' => 1])['series'])->toBe([1, 1, 0, 0, 0]); expect(($this->premiumAwareness)(['komoditas_id' => 2])['series'])->toBe([0, 0, 1, 0, 0]); expect(($this->premiumAwareness)(['segmen' => 'Penggerak'])['series'])->toBe([1, 0, 0, 0, 0]); expect(($this->premiumAwareness)(['segmen' => 'Pemerhati'])['series'])->toBe([0, 1, 0, 0, 0]); expect(($this->premiumAwareness)(['komoditas_id' => 2, 'segmen' => 'Penggerak'])['respondents'])->toBe(0); }); test('premium awareness retains empty categories and excludes missing or invalid scores', function () { $emptyChart = ($this->premiumAwareness)(); foreach ([1 => 0, 2 => 6, 3 => null, 4 => -1, 5 => null] as $farmer => $score) { DB::table('akses_pasar')->insert(['petani_id' => $farmer, 'skor_kesadaran_premium' => $score]); } $chart = ($this->premiumAwareness)(); expect($chart)->toBe($emptyChart); expect($chart['series'])->toBe([0, 0, 0, 0, 0]); expect($chart['respondents'])->toBe(0); expect($chart['labels'])->toHaveCount(5); expect(array_sum($chart['percentages']))->toBe(0); });