diff --git a/DOC-BACKEND.md b/Documentation/Backen.md similarity index 100% rename from DOC-BACKEND.md rename to Documentation/Backen.md diff --git a/DOC-FRONTEND.md b/Documentation/Frontend.md similarity index 100% rename from DOC-FRONTEND.md rename to Documentation/Frontend.md diff --git a/Documentation/Laporan.md b/Documentation/Laporan.md new file mode 100644 index 0000000..5390466 --- /dev/null +++ b/Documentation/Laporan.md @@ -0,0 +1,172 @@ +# Dokumentasi Refactoring LaporanController + +## 📋 Ringkasan Refactoring + +File `LaporanController` yang awalnya berukuran **~600 baris** telah dipecah menjadi **6 file** yang lebih terorganisir dan mudah dipelihara: + +1. **LaporanController** - Controller utama yang ramping +2. **LaporanService** - Business logic layer +3. **TransaksiRepository** - Data access layer +4. **LaporanHelper** - Utility functions +5. **DetailLaporanRequest** - Validation untuk detail laporan +6. **ExportLaporanRequest** - Validation untuk export + +## 🏗️ Struktur Baru + +### 1. LaporanController (~80 baris) + +- **Tanggung jawab**: Menangani HTTP requests dan responses +- **Fitur**: Error handling, logging, delegasi ke service layer +- **Prinsip**: Single Responsibility - hanya menangani concerns HTTP + +### 2. LaporanService (~180 baris) + +- **Tanggung jawab**: Business logic dan orchestration +- **Fitur**: + - Caching logic + - Data processing coordination + - Export functionality + - Input validation bisnis +- **Prinsip**: Service layer yang mengkoordinasi antara repository dan helper + +### 3. TransaksiRepository (~120 baris) + +- **Tanggung jawab**: Data access dan query operations +- **Fitur**: + - Complex database queries + - Data aggregation + - Pagination logic untuk laporan +- **Prinsip**: Repository pattern untuk data abstraction + +### 4. LaporanHelper (~180 baris) + +- **Tanggung jawab**: Utility functions dan data formatting +- **Fitur**: + - Data formatting (currency, weight) + - Data mapping dan transformation + - Pagination info building + - Filter info building +- **Prinsip**: Helper class untuk fungsi-fungsi utility yang reusable + +### 5. DetailLaporanRequest (~60 baris) + +- **Tanggung jawab**: Validation rules untuk detail laporan +- **Fitur**: + - Input validation + - Custom error messages + - Data preparation +- **Prinsip**: Form Request untuk clean validation + +### 6. ExportLaporanRequest (~40 baris) + +- **Tanggung jawab**: Validation rules untuk export +- **Fitur**: + - Export format validation + - Filter validation +- **Prinsip**: Separated concerns untuk different validation needs + +## 🎯 Keuntungan Refactoring + +### ✅ Maintainability + +- **Sebelum**: 1 file besar (~600 baris) sulit untuk debug dan modify +- **Sesudah**: 6 file kecil dengan tanggung jawab yang jelas + +### ✅ Testability + +- **Sebelum**: Sulit untuk unit test karena semua logic tercampur +- **Sesudah**: Setiap layer dapat di-test secara terpisah + - Service layer dapat di-mock + - Repository dapat di-test dengan database + - Helper functions dapat di-unit test + +### ✅ Reusability + +- **LaporanHelper** dapat digunakan di controller/service lain +- **TransaksiRepository** dapat digunakan untuk keperluan transaksi lain +- **Form Requests** dapat digunakan di route lain + +### ✅ SOLID Principles + +- **S** - Single Responsibility: Setiap class punya satu tanggung jawab +- **O** - Open/Closed: Mudah untuk extend tanpa modify existing code +- **L** - Liskov Substitution: Repository dapat di-substitute dengan implementasi lain +- **I** - Interface Segregation: Dependencies yang spesifik +- **D** - Dependency Inversion: Controller depend pada abstraction (Service), bukan concrete class + +### ✅ Performance + +- Caching logic tetap terjaga di Service layer +- Query optimization tetap di Repository layer +- No performance degradation dari refactoring + +## 🔧 Cara Implementasi + +### 1. Buat file-file baru: + +``` +app/ +├── Http/ +│ ├── Controllers/ +│ │ └── LaporanController.php +│ └── Requests/ +│ ├── DetailLaporanRequest.php +│ └── ExportLaporanRequest.php +├── Services/ +│ └── LaporanService.php +├── Repositories/ +│ └── TransaksiRepository.php +└── Helpers/ + └── LaporanHelper.php +``` + +### 2. Register dependencies di Service Provider: + +```php +// AppServiceProvider.php +public function register() +{ + $this->app->bind(TransaksiRepository::class, TransaksiRepository::class); + $this->app->bind(LaporanHelper::class, LaporanHelper::class); + $this->app->bind(LaporanService::class, LaporanService::class); +} +``` + +### 3. Update routes (tidak ada perubahan): + +```php +// Routes tetap sama, hanya implementasi internal yang berubah +Route::get('/laporan/ringkasan', [LaporanController::class, 'ringkasan']); +Route::get('/laporan/detail-per-produk', [LaporanController::class, 'detailPerProduk']); +Route::get('/laporan/detail-per-nampan', [LaporanController::class, 'detailPerNampan']); +Route::post('/laporan/export', [LaporanController::class, 'exportRingkasan']); +``` + +## 📊 Perbandingan Ukuran File + +| File Original | Baris | File Baru | Baris | Pengurangan | +| --------------------- | ------- | ------------------------ | ------- | ----------- | +| LaporanController.php | ~600 | LaporanController.php | ~80 | 87% | +| | | LaporanService.php | ~180 | | +| | | TransaksiRepository.php | ~120 | | +| | | LaporanHelper.php | ~180 | | +| | | DetailLaporanRequest.php | ~60 | | +| | | ExportLaporanRequest.php | ~40 | | +| **Total** | **600** | **Total** | **660** | **+60** | + +_Note: Sedikit penambahan baris karena struktur class yang lebih terorganisir dan dokumentasi yang lebih baik_ + +## 🚀 Langkah Selanjutnya (Optional) + +1. **Interface Implementation**: Buat interface untuk Service dan Repository +2. **Unit Tests**: Tambahkan comprehensive unit tests untuk setiap layer +3. **API Documentation**: Update API documentation +4. **Caching Strategy**: Implement more sophisticated caching dengan Redis +5. **Query Optimization**: Review dan optimize database queries di Repository + +## ⚠️ Catatan Penting + +- **Backward Compatibility**: API endpoints dan response format tetap sama +- **Dependencies**: Pastikan semua dependencies di-register di Service Provider +- **Testing**: Lakukan thorough testing sebelum deploy ke production +- **Migration**: Bisa dilakukan secara bertahap jika diperlukan diff --git a/DOC-PRODUK.md b/Documentation/Produk.md similarity index 100% rename from DOC-PRODUK.md rename to Documentation/Produk.md diff --git a/app/Exports/DetailNampanExport.php b/app/Exports/DetailNampanExport.php new file mode 100644 index 0000000..bfdeb05 --- /dev/null +++ b/app/Exports/DetailNampanExport.php @@ -0,0 +1,63 @@ +data = $data; + $this->page = $page; + } + + public function collection() + { + $collection = collect(); + + if (isset($this->data['nampan'])) { + foreach ($this->data['nampan'] as $item) { + $collection->push([ + 'Nama Nampan' => $item['nama_nampan'], + 'Jumlah Item Terjual' => $item['jumlah_item_terjual'], + 'Berat Terjual' => $item['berat_terjual'], + 'Pendapatan' => $item['pendapatan'], + ]); + } + } + + return $collection; + } + + public function headings(): array + { + return [ + 'Nama Nampan', + 'Jumlah Item Terjual', + 'Berat Terjual', + 'Pendapatan' + ]; + } + + public function title(): string + { + $filterInfo = $this->data['filter'] ?? []; + $tanggal = $filterInfo['tanggal'] ?? 'Unknown'; + return "Detail Nampan {$tanggal} - Hal {$this->page}"; + } + + public function styles(Worksheet $sheet) + { + return [ + 1 => ['font' => ['bold' => true]], + ]; + } +} \ No newline at end of file diff --git a/app/Exports/DetailProdukExport.php b/app/Exports/DetailProdukExport.php new file mode 100644 index 0000000..b44d463 --- /dev/null +++ b/app/Exports/DetailProdukExport.php @@ -0,0 +1,63 @@ +data = $data; + $this->page = $page; + } + + public function collection() + { + $collection = collect(); + + if (isset($this->data['produk'])) { + foreach ($this->data['produk'] as $item) { + $collection->push([ + 'Nama Produk' => $item['nama_produk'], + 'Jumlah Item Terjual' => $item['jumlah_item_terjual'], + 'Berat Terjual' => $item['berat_terjual'], + 'Pendapatan' => $item['pendapatan'], + ]); + } + } + + return $collection; + } + + public function headings(): array + { + return [ + 'Nama Produk', + 'Jumlah Item Terjual', + 'Berat Terjual', + 'Pendapatan' + ]; + } + + public function title(): string + { + $filterInfo = $this->data['filter'] ?? []; + $tanggal = $filterInfo['tanggal'] ?? 'Unknown'; + return "Detail Produk {$tanggal} - Hal {$this->page}"; + } + + public function styles(Worksheet $sheet) + { + return [ + 1 => ['font' => ['bold' => true]], + ]; + } +} diff --git a/app/Exports/RingkasanExport.php b/app/Exports/RingkasanExport.php index 7722984..d8ed659 100644 --- a/app/Exports/RingkasanExport.php +++ b/app/Exports/RingkasanExport.php @@ -2,68 +2,80 @@ namespace App\Exports; -use Maatwebsite\Excel\Concerns\FromArray; +use Maatwebsite\Excel\Concerns\FromCollection; use Maatwebsite\Excel\Concerns\WithHeadings; -use Maatwebsite\Excel\Concerns\ShouldAutoSize; +use Maatwebsite\Excel\Concerns\WithTitle; +use Maatwebsite\Excel\Concerns\WithStyles; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -class RingkasanExport implements FromArray, WithHeadings, ShouldAutoSize +class RingkasanExport implements FromCollection, WithHeadings, WithTitle, WithStyles { - protected $data; + private $data; + private $page; - public function __construct(iterable $data) + public function __construct(iterable $data, $page = 1) { $this->data = $data; + $this->page = $page; } - public function array(): array + + public function collection() { - $rows = []; + $collection = collect(); + $items = method_exists($this->data, 'items') ? $this->data->items() : $this->data; - // Iterasi setiap hari/bulan - foreach ($this->data as $item) { - // Baris pertama untuk entri sales pertama - if (count($item['sales']) > 0) { - foreach ($item['sales'] as $index => $sales) { - $rows[] = [ - 'Tanggal' => $item['tanggal'], - 'Nama Sales' => $sales['nama'], - 'Item Terjual' => $sales['item_terjual'], - 'Berat Terjual' => $sales['berat_terjual'], - 'Pendapatan' => $sales['pendapatan'], - ]; - } - } else { - // Baris jika tidak ada sales hari itu - $rows[] = [ - 'Tanggal' => $item['tanggal'], - 'Nama Sales' => 'N/A', - 'Item Terjual' => 0, - 'Berat Terjual' => 0, - 'Pendapatan' => 0, - ]; - } - - // Baris Total Harian/Bulanan - $rows[] = [ - 'Tanggal' => $item['tanggal'], - 'Nama Sales' => '** TOTAL **', // Tandai sebagai baris total - 'Item Terjual' => $item['total_item_terjual'], - 'Berat Terjual' => $item['total_berat'], - 'Pendapatan' => $item['total_pendapatan'], - ]; + foreach ($items as $item) { + $collection->push([ + 'Tanggal' => $item['tanggal'] ?? '-', + 'Total Item Terjual' => $item['total_item_terjual'] ?? 0, + 'Total Berat' => $item['total_berat'] ?? 0, + 'Total Pendapatan' => $item['total_pendapatan'] ?? 0, + 'Detail Sales' => $this->formatSalesData($item['sales'] ?? []), + ]); } - return $rows; + return $collection; } public function headings(): array { return [ - 'Periode', - 'Nama Sales/Keterangan', - 'Item Terjual', - 'Total Berat Terjual', + 'Tanggal', + 'Total Item Terjual', + 'Total Berat', 'Total Pendapatan', + 'Detail Sales' ]; } -} \ No newline at end of file + + public function title(): string + { + return "Ringkasan Halaman {$this->page}"; + } + + public function styles(Worksheet $sheet) + { + return [ + 1 => ['font' => ['bold' => true]], + ]; + } + + private function formatSalesData($sales): string + { + if (empty($sales)) { + return '-'; + } + + $formatted = []; + foreach ($sales as $sale) { + $nama = $sale['nama'] ?? 'Sales Tidak Dikenal'; + $itemTerjual = $sale['item_terjual'] ?? 0; + $pendapatan = $sale['pendapatan'] ?? '-'; + + $formatted[] = "{$nama}: {$itemTerjual} item, {$pendapatan}"; + } + + return implode('; ', $formatted); + } +} diff --git a/app/Helpers/LaporanHelper.php b/app/Helpers/LaporanHelper.php new file mode 100644 index 0000000..7ab5cf7 --- /dev/null +++ b/app/Helpers/LaporanHelper.php @@ -0,0 +1,204 @@ +sum('pendapatan'); + $totalItemTerjual = $data->sum('jumlah_item_terjual'); + $totalBeratTerjual = $data->sum('berat_terjual'); + + return [ + 'total_item_terjual' => $totalItemTerjual, + 'total_berat_terjual' => $this->formatWeight($totalBeratTerjual), + 'total_pendapatan' => $this->formatCurrency($totalPendapatan), + ]; + } + + public function getAllNampanWithPagination(int $page, int $perPage): LengthAwarePaginator + { + $semuaNampan = Nampan::select('id', 'nama')->orderBy('nama')->get(); + $brankasEntry = (object) ['id' => 0, 'nama' => 'Brankas']; + $semuaNampanCollection = $semuaNampan->prepend($brankasEntry); + + $offset = ($page - 1) * $perPage; + $itemsForCurrentPage = $semuaNampanCollection->slice($offset, $perPage); + + return new LengthAwarePaginator( + $itemsForCurrentPage, + $semuaNampanCollection->count(), + $perPage, + $page, + ['path' => request()->url(), 'query' => request()->query()] + ); + } + + public function mapProductsWithSalesData($paginatedData, Collection $salesData): Collection + { + return $paginatedData->getCollection()->map(function ($item) use ($salesData) { + if ($salesData->has($item->id)) { + $dataTerjual = $salesData->get($item->id); + return [ + 'nama_produk' => $item->nama, + 'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual, + 'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual), + 'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan), + ]; + } + + return [ + 'nama_produk' => $item->nama, + 'jumlah_item_terjual' => self::DEFAULT_DISPLAY, + 'berat_terjual' => self::DEFAULT_DISPLAY, + 'pendapatan' => self::DEFAULT_DISPLAY, + ]; + }); + } + + public function mapNampanWithSalesData($paginatedData, Collection $salesData): Collection + { + return $paginatedData->getCollection()->map(function ($item) use ($salesData) { + if ($salesData->has($item->id)) { + $dataTerjual = $salesData->get($item->id); + return [ + 'nama_nampan' => $item->nama, + 'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual, + 'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual), + 'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan), + ]; + } + + return [ + 'nama_nampan' => $item->nama, + 'jumlah_item_terjual' => self::DEFAULT_DISPLAY, + 'berat_terjual' => self::DEFAULT_DISPLAY, + 'pendapatan' => self::DEFAULT_DISPLAY, + ]; + }); + } + + public function buildProdukFilterInfo(Carbon $carbonDate, array $params): array + { + $filterInfo = [ + 'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'), + 'nama_sales' => null, + 'nampan' => null, + 'nama_pembeli' => $params['nama_pembeli'] ?? null, + ]; + + if (!empty($params['sales_id'])) { + $sales = Sales::find($params['sales_id']); + $filterInfo['nama_sales'] = $sales?->nama; + } + + if (isset($params['nampan_id'])) { + if ($params['nampan_id'] == 0) { + $filterInfo['nampan'] = 'Brankas'; + } else { + $nampan = Nampan::find($params['nampan_id']); + $filterInfo['nampan'] = $nampan?->nama; + } + } + + return $filterInfo; + } + + public function buildNampanFilterInfo(Carbon $carbonDate, array $params): array + { + $filterInfo = [ + 'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'), + 'nama_sales' => null, + 'produk' => null, + 'nama_pembeli' => $params['nama_pembeli'] ?? null, + ]; + + if (!empty($params['sales_id'])) { + $sales = Sales::find($params['sales_id']); + $filterInfo['nama_sales'] = $sales?->nama; + } + + if (!empty($params['produk_id'])) { + $produk = Produk::find($params['produk_id']); + $filterInfo['produk'] = $produk?->nama; + } + + return $filterInfo; + } + + public function buildPaginationInfo($paginatedData): array + { + return [ + 'current_page' => $paginatedData->currentPage(), + 'last_page' => $paginatedData->lastPage(), + 'per_page' => $paginatedData->perPage(), + 'total' => $paginatedData->total(), + 'from' => $paginatedData->firstItem(), + 'to' => $paginatedData->lastItem(), + ]; + } + + public function hitungDataSales(Collection $transaksisPerSales): array + { + $itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count()); + + $beratTerjual = $transaksisPerSales->sum( + fn($t) => $t->itemTransaksi->sum(fn($it) => $it->item?->produk?->berat ?? 0) + ); + + $pendapatan = $transaksisPerSales->sum('total_harga'); + + return [ + 'nama' => $transaksisPerSales->first()->nama_sales, + 'item_terjual' => $itemTerjual, + 'berat_terjual_raw' => $beratTerjual, + 'pendapatan_raw' => $pendapatan, + ]; + } + + public function defaultSalesData(string $namaSales): array + { + return [ + 'nama' => $namaSales, + 'item_terjual' => 0, + 'berat_terjual_raw' => 0, + 'pendapatan_raw' => 0, + ]; + } + + public function formatSalesDataValues(Collection $salesData): Collection + { + return $salesData->map(function ($sale) { + $sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : self::DEFAULT_DISPLAY; + $sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ? + $this->formatWeight($sale['berat_terjual_raw']) : self::DEFAULT_DISPLAY; + $sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ? + $this->formatCurrency($sale['pendapatan_raw']) : self::DEFAULT_DISPLAY; + + unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']); + return $sale; + }); + } + + public function formatCurrency(float $amount): string + { + return self::CURRENCY_SYMBOL . number_format($amount, 2, ',', '.'); + } + + public function formatWeight(float $weight): string + { + return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/LaporanController.php b/app/Http/Controllers/LaporanController.php index 80e3327..bc6b77c 100644 --- a/app/Http/Controllers/LaporanController.php +++ b/app/Http/Controllers/LaporanController.php @@ -2,34 +2,20 @@ namespace App\Http\Controllers; -use App\Models\ItemTransaksi; -use App\Models\Produk; -use App\Models\Transaksi; -use App\Models\Sales; -use App\Models\Nampan; -use Carbon\Carbon; -use Carbon\CarbonPeriod; +use App\Services\LaporanService; +use App\Http\Requests\DetailLaporanRequest; +use App\Http\Requests\ExportLaporanRequest; use Illuminate\Http\Request; -use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; -use Maatwebsite\Excel\Facades\Excel; -use Barryvdh\DomPDF\Facade\Pdf; -use App\Exports\RingkasanExport; class LaporanController extends Controller { - private const CURRENCY_SYMBOL = 'Rp '; - private const WEIGHT_UNIT = ' g'; - private const DEFAULT_DISPLAY = '-'; - private const CACHE_TTL = 300; // 5 menit - private const DEFAULT_PER_PAGE = 15; - private const MAX_PER_PAGE = 100; - private const DAILY_PER_PAGE = 7; - private const MONTHLY_PER_PAGE = 12; - private const PAGINATION_DAYS_LIMIT = 365; + private LaporanService $laporanService; + + public function __construct(LaporanService $laporanService) + { + $this->laporanService = $laporanService; + } /** * Endpoint untuk ringkasan laporan dengan caching @@ -45,18 +31,7 @@ class LaporanController extends Controller return response()->json(['error' => 'Filter harus "hari" atau "bulan"'], 400); } - // Cache key berdasarkan filter dan page - $cacheKey = "laporan_ringkasan_{$filter}_page_{$page}"; - - $data = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($filter, $page) { - $allSalesNames = $this->getAllSalesNames(); - - if ($filter === 'hari') { - return $this->processLaporanHarian($allSalesNames, $page, true); - } - - return $this->processLaporanBulanan($allSalesNames, $page, true); - }); + $data = $this->laporanService->getRingkasan($filter, $page); return response()->json($data); @@ -67,65 +42,13 @@ class LaporanController extends Controller } /** - * Detail laporan per produk dengan validasi dan error handling yang lebih baik + * Detail laporan per produk */ - public function detailPerProduk(Request $request) + public function detailPerProduk(DetailLaporanRequest $request) { try { - $validatedData = $request->validate([ - 'tanggal' => 'required|date_format:Y-m-d|before_or_equal:today', - 'sales_id' => 'nullable|integer|exists:sales,id', - 'nampan_id' => 'nullable|integer', - 'nama_pembeli' => 'nullable|string|max:255', - 'page' => 'nullable|integer|min:1', - 'per_page' => 'nullable|integer|min:1|max:' . self::MAX_PER_PAGE, - ]); - - $tanggal = $validatedData['tanggal']; - $salesId = $request->query('sales_id'); - $nampanId = $request->query('nampan_id'); - $namaPembeli = $request->query('nama_pembeli'); - $page = (int) $request->query('page', 1); - $perPage = (int) $request->query('per_page', self::DEFAULT_PER_PAGE); - - $carbonDate = Carbon::parse($tanggal); - - // Validasi nampan_id jika ada - if ($nampanId && $nampanId != 0) { - if (!Nampan::where('id', $nampanId)->exists()) { - return response()->json(['error' => 'Nampan tidak ditemukan'], 404); - } - } - - $produkTerjualQuery = $this->buildBaseItemQuery($carbonDate); - $this->applyFilters($produkTerjualQuery, $salesId, $nampanId, $namaPembeli); - - $produkTerjual = $produkTerjualQuery - ->select( - 'produks.id as id_produk', - 'produks.nama as nama_produk', - DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'), - DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'), - DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan') - ) - ->groupBy('produks.id', 'produks.nama') - ->get() - ->keyBy('id_produk'); - - $totals = $this->calculateTotals($produkTerjual); - $semuaProdukPaginated = Produk::select('id', 'nama') - ->orderBy('nama') - ->paginate($perPage, ['*'], 'page', $page); - - $detailItem = $this->mapProductsWithSalesData($semuaProdukPaginated, $produkTerjual); - $filterInfo = $this->buildFilterInfo($carbonDate, $salesId, $nampanId, $namaPembeli); - - return response()->json([ - 'filter' => $filterInfo, - 'rekap_harian' => $totals, - 'produk' => $detailItem->values(), - 'pagination' => $this->buildPaginationInfo($semuaProdukPaginated), - ]); + $data = $this->laporanService->getDetailPerProduk($request->validated()); + return response()->json($data); } catch (\Exception $e) { Log::error('Error in detailPerProduk method: ' . $e->getMessage()); @@ -134,56 +57,13 @@ class LaporanController extends Controller } /** - * Detail laporan per nampan dengan perbaikan validasi dan error handling + * Detail laporan per nampan */ - public function detailPerNampan(Request $request) + public function detailPerNampan(DetailLaporanRequest $request) { try { - $validatedData = $request->validate([ - 'tanggal' => 'required|date_format:Y-m-d|before_or_equal:today', - 'sales_id' => 'nullable|integer|exists:sales,id', - 'produk_id' => 'nullable|integer|exists:produks,id', - 'nama_pembeli' => 'nullable|string|max:255', - 'page' => 'nullable|integer|min:1', - 'per_page' => 'nullable|integer|min:1|max:' . self::MAX_PER_PAGE, - ]); - - $tanggal = $validatedData['tanggal']; - $salesId = $request->query('sales_id'); - $produkId = $request->query('produk_id'); - $namaPembeli = $request->query('nama_pembeli'); - $page = (int) $request->query('page', 1); - $perPage = (int) $request->query('per_page', self::DEFAULT_PER_PAGE); - - $carbonDate = Carbon::parse($tanggal); - - $nampanTerjualQuery = $this->buildBaseItemQuery($carbonDate); - $this->applyNampanFilters($nampanTerjualQuery, $salesId, $produkId, $namaPembeli); - - $nampanTerjual = $nampanTerjualQuery - ->leftJoin('nampans', 'items.id_nampan', '=', 'nampans.id') - ->select( - DB::raw('COALESCE(items.id_nampan, 0) as id_nampan'), - DB::raw('COALESCE(nampans.nama, "Brankas") as nama_nampan'), - DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'), - DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'), - DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan') - ) - ->groupBy('id_nampan', 'nama_nampan') - ->get() - ->keyBy('id_nampan'); - - $totals = $this->calculateTotals($nampanTerjual); - $semuaNampanPaginated = $this->getAllNampanWithPagination($page, $perPage); - $detailItem = $this->mapNampanWithSalesData($semuaNampanPaginated, $nampanTerjual); - $filterInfo = $this->buildNampanFilterInfo($carbonDate, $salesId, $produkId, $namaPembeli); - - return response()->json([ - 'filter' => $filterInfo, - 'rekap_harian' => $totals, - 'nampan' => $detailItem->values(), - 'pagination' => $this->buildPaginationInfo($semuaNampanPaginated), - ]); + $data = $this->laporanService->getDetailPerNampan($request->validated()); + return response()->json($data); } catch (\Exception $e) { Log::error('Error in detailPerNampan method: ' . $e->getMessage()); @@ -192,454 +72,16 @@ class LaporanController extends Controller } /** - * Export laporan ringkasan dengan validasi format + * Export laporan ringkasan */ - public function exportRingkasan(Request $request) + public function exportRingkasan(ExportLaporanRequest $request) { try { - $validatedData = $request->validate([ - 'filter' => 'required|in:hari,bulan', - 'format' => 'required|in:pdf,xlsx,csv', - ]); - - $filter = $validatedData['filter']; - $format = $validatedData['format']; - - $allSalesNames = $this->getAllSalesNames(); - - if ($filter === 'hari') { - $data = $this->processLaporanHarian($allSalesNames, 1, false); - } else { - $data = $this->processLaporanBulanan($allSalesNames, 1, false); - } - - $fileName = "laporan_ringkasan_{$filter}_" . Carbon::now()->format('Ymd') . ".{$format}"; - - if ($format === 'pdf') { - $pdf = PDF::loadView('exports.ringkasan_pdf', [ - 'data' => $data, - 'filter' => $filter - ]); - $pdf->setPaper('a4', 'landscape'); - return $pdf->download($fileName); - } - - // Format XLSX atau CSV - return Excel::download(new RingkasanExport($data), $fileName); + return $this->laporanService->exportRingkasan($request->validated()); } catch (\Exception $e) { Log::error('Error in exportRingkasan method: ' . $e->getMessage()); return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500); } } - - /** - * Helper method untuk mendapatkan semua nama sales dengan caching - */ - private function getAllSalesNames(): Collection - { - return Cache::remember('all_sales_names', self::CACHE_TTL, function () { - return Transaksi::select('nama_sales')->distinct()->pluck('nama_sales'); - }); - } - - /** - * Helper method untuk mendapatkan semua nampan dengan pagination - */ - private function getAllNampanWithPagination(int $page, int $perPage): LengthAwarePaginator - { - $semuaNampan = Nampan::select('id', 'nama')->orderBy('nama')->get(); - $brankasEntry = (object) ['id' => 0, 'nama' => 'Brankas']; - $semuaNampanCollection = $semuaNampan->prepend($brankasEntry); - - $offset = ($page - 1) * $perPage; - $itemsForCurrentPage = $semuaNampanCollection->slice($offset, $perPage); - - return new LengthAwarePaginator( - $itemsForCurrentPage, - $semuaNampanCollection->count(), - $perPage, - $page, - ['path' => request()->url(), 'query' => request()->query()] - ); - } - - /** - * Logika inti untuk menghasilkan data laporan harian yang sudah dioptimasi - */ - private function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true) - { - $perPage = self::DAILY_PER_PAGE; - - if ($limitPagination) { - $endDate = Carbon::today()->subDays(($page - 1) * $perPage); - $startDate = $endDate->copy()->subDays($perPage - 1); - $totalHariUntukPaginasi = self::PAGINATION_DAYS_LIMIT; - } else { - $endDate = Carbon::today(); - $startDate = $endDate->copy()->subYear()->addDay(); - $totalHariUntukPaginasi = $endDate->diffInDays($startDate) + 1; - } - - $transaksis = Transaksi::with(['itemTransaksi.item.produk']) - ->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()]) - ->orderBy('created_at', 'desc') - ->get(); - - $transaksisByDay = $transaksis->groupBy(function ($transaksi) { - return Carbon::parse($transaksi->created_at)->format('Y-m-d'); - }); - - $period = CarbonPeriod::create($startDate, $endDate); - $laporan = []; - - foreach ($period as $date) { - $dateString = $date->format('Y-m-d'); - $tanggalFormatted = $date->isoFormat('dddd, D MMMM Y'); - - if (isset($transaksisByDay[$dateString])) { - $transaksisPerTanggal = $transaksisByDay[$dateString]; - $salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales') - ->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales)); - - $fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) { - return $salesDataTransaksi->get($namaSales) ?? $this->defaultSalesData($namaSales); - }); - - $totalItem = $fullSalesData->sum('item_terjual'); - $totalBerat = $fullSalesData->sum('berat_terjual_raw'); - $totalPendapatan = $fullSalesData->sum('pendapatan_raw'); - - $laporan[$dateString] = [ - 'tanggal' => $tanggalFormatted, - 'total_item_terjual' => $totalItem > 0 ? $totalItem : self::DEFAULT_DISPLAY, - 'total_berat' => $totalBerat > 0 ? $this->formatWeight($totalBerat) : self::DEFAULT_DISPLAY, - 'total_pendapatan' => $totalPendapatan > 0 ? $this->formatCurrency($totalPendapatan) : self::DEFAULT_DISPLAY, - 'sales' => $this->formatSalesDataValues($fullSalesData)->values(), - ]; - } else { - $laporan[$dateString] = [ - 'tanggal' => $tanggalFormatted, - 'total_item_terjual' => self::DEFAULT_DISPLAY, - 'total_berat' => self::DEFAULT_DISPLAY, - 'total_pendapatan' => self::DEFAULT_DISPLAY, - 'sales' => [], - ]; - } - } - - if ($limitPagination) { - return new LengthAwarePaginator( - array_reverse(array_values($laporan)), - $totalHariUntukPaginasi, - $perPage, - $page, - ['path' => request()->url(), 'query' => request()->query()] - ); - } - - return collect(array_reverse(array_values($laporan))); - } - - /** - * Logika inti untuk menghasilkan data laporan bulanan yang sudah dioptimasi - */ - private function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true) - { - $perPage = self::MONTHLY_PER_PAGE; - - $transaksis = Transaksi::with(['itemTransaksi.item.produk']) - ->orderBy('created_at', 'desc') - ->get(); - - $laporan = $transaksis->groupBy(function ($transaksi) { - return Carbon::parse($transaksi->created_at)->format('F Y'); - })->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) { - $salesDataTransaksi = $transaksisPerTanggal - ->groupBy('nama_sales') - ->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales)); - - $fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) { - return $salesDataTransaksi->get($namaSales) ?? $this->defaultSalesData($namaSales); - }); - - $totalItem = $fullSalesData->sum('item_terjual'); - $totalBerat = $fullSalesData->sum('berat_terjual_raw'); - $totalPendapatan = $fullSalesData->sum('pendapatan_raw'); - - return [ - 'tanggal' => $tanggal, - 'total_item_terjual' => $totalItem > 0 ? $totalItem : self::DEFAULT_DISPLAY, - 'total_berat' => $totalBerat > 0 ? $this->formatWeight($totalBerat) : self::DEFAULT_DISPLAY, - 'total_pendapatan' => $totalPendapatan > 0 ? $this->formatCurrency($totalPendapatan) : self::DEFAULT_DISPLAY, - 'sales' => $this->formatSalesDataValues($fullSalesData)->values(), - ]; - }); - - if ($limitPagination) { - return new LengthAwarePaginator( - $laporan->forPage($page, $perPage)->values(), - $laporan->count(), - $perPage, - $page, - ['path' => request()->url(), 'query' => request()->query()] - ); - } - - return $laporan->values(); - } - - /** - * Membangun query dasar untuk item transaksi - */ - private function buildBaseItemQuery(Carbon $carbonDate) - { - return ItemTransaksi::query() - ->join('items', 'item_transaksis.id_item', '=', 'items.id') - ->join('produks', 'items.id_produk', '=', 'produks.id') - ->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id') - ->whereDate('transaksis.created_at', $carbonDate); - } - - /** - * Menerapkan filter untuk query produk - */ - private function applyFilters($query, $salesId, $nampanId, $namaPembeli): void - { - if ($salesId) { - $query->join('sales', 'transaksis.id_sales', '=', 'sales.id') - ->where('sales.id', $salesId); - } - - if ($nampanId !== null) { - if ($nampanId == 0) { - $query->whereNull('items.id_nampan'); - } else { - $query->where('items.id_nampan', $nampanId); - } - } - - if ($namaPembeli) { - $query->where('transaksis.nama_pembeli', 'like', "%{$namaPembeli}%"); - } - } - - /** - * Menerapkan filter untuk query nampan - */ - private function applyNampanFilters($query, $salesId, $produkId, $namaPembeli): void - { - if ($salesId) { - $query->join('sales', 'transaksis.id_sales', '=', 'sales.id') - ->where('sales.id', $salesId); - } - - if ($produkId) { - $query->where('produks.id', $produkId); - } - - if ($namaPembeli) { - $query->where('transaksis.nama_pembeli', 'like', "%{$namaPembeli}%"); - } - } - - /** - * Menghitung total dari data penjualan - */ - private function calculateTotals(Collection $data): array - { - $totalPendapatan = $data->sum('pendapatan'); - $totalItemTerjual = $data->sum('jumlah_item_terjual'); - $totalBeratTerjual = $data->sum('berat_terjual'); - - return [ - 'total_item_terjual' => $totalItemTerjual, - 'total_berat_terjual' => $this->formatWeight($totalBeratTerjual), - 'total_pendapatan' => $this->formatCurrency($totalPendapatan), - ]; - } - - /** - * Memetakan produk dengan data penjualan - */ - private function mapProductsWithSalesData($paginatedData, Collection $salesData): Collection - { - return $paginatedData->getCollection()->map(function ($item) use ($salesData) { - if ($salesData->has($item->id)) { - $dataTerjual = $salesData->get($item->id); - return [ - 'nama_produk' => $item->nama, - 'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual, - 'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual), - 'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan), - ]; - } - - return [ - 'nama_produk' => $item->nama, - 'jumlah_item_terjual' => self::DEFAULT_DISPLAY, - 'berat_terjual' => self::DEFAULT_DISPLAY, - 'pendapatan' => self::DEFAULT_DISPLAY, - ]; - }); - } - - /** - * Memetakan nampan dengan data penjualan - */ - private function mapNampanWithSalesData($paginatedData, Collection $salesData): Collection - { - return $paginatedData->getCollection()->map(function ($item) use ($salesData) { - if ($salesData->has($item->id)) { - $dataTerjual = $salesData->get($item->id); - return [ - 'nama_nampan' => $item->nama, - 'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual, - 'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual), - 'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan), - ]; - } - - return [ - 'nama_nampan' => $item->nama, - 'jumlah_item_terjual' => self::DEFAULT_DISPLAY, - 'berat_terjual' => self::DEFAULT_DISPLAY, - 'pendapatan' => self::DEFAULT_DISPLAY, - ]; - }); - } - - /** - * Membangun informasi filter untuk produk - */ - private function buildFilterInfo(Carbon $carbonDate, $salesId, $nampanId, $namaPembeli): array - { - $filterInfo = [ - 'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'), - 'nama_sales' => null, - 'nampan' => null, - 'nama_pembeli' => $namaPembeli, - ]; - - if ($salesId) { - $sales = Sales::find($salesId); - $filterInfo['nama_sales'] = $sales?->nama; - } - - if ($nampanId !== null) { - if ($nampanId == 0) { - $filterInfo['nampan'] = 'Brankas'; - } else { - $nampan = Nampan::find($nampanId); - $filterInfo['nampan'] = $nampan?->nama; - } - } - - return $filterInfo; - } - - /** - * Membangun informasi filter untuk nampan - */ - private function buildNampanFilterInfo(Carbon $carbonDate, $salesId, $produkId, $namaPembeli): array - { - $filterInfo = [ - 'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'), - 'nama_sales' => null, - 'produk' => null, - 'nama_pembeli' => $namaPembeli, - ]; - - if ($salesId) { - $sales = Sales::find($salesId); - $filterInfo['nama_sales'] = $sales?->nama; - } - - if ($produkId) { - $produk = Produk::find($produkId); - $filterInfo['produk'] = $produk?->nama; - } - - return $filterInfo; - } - - /** - * Membangun informasi pagination - */ - private function buildPaginationInfo($paginatedData): array - { - return [ - 'current_page' => $paginatedData->currentPage(), - 'last_page' => $paginatedData->lastPage(), - 'per_page' => $paginatedData->perPage(), - 'total' => $paginatedData->total(), - 'from' => $paginatedData->firstItem(), - 'to' => $paginatedData->lastItem(), - ]; - } - - /** - * Menghitung data sales dari transaksi - */ - private function hitungDataSales(Collection $transaksisPerSales): array - { - $itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count()); - $beratTerjual = $transaksisPerSales->sum( - fn($t) => $t->itemTransaksi->sum(fn($it) => $it->item->produk->berat ?? 0) - ); - $pendapatan = $transaksisPerSales->sum('total_harga'); - - return [ - 'nama' => $transaksisPerSales->first()->nama_sales, - 'item_terjual' => $itemTerjual, - 'berat_terjual_raw' => $beratTerjual, - 'pendapatan_raw' => $pendapatan, - ]; - } - - /** - * Default data untuk sales yang tidak ada transaksi - */ - private function defaultSalesData(string $namaSales): array - { - return [ - 'nama' => $namaSales, - 'item_terjual' => 0, - 'berat_terjual_raw' => 0, - 'pendapatan_raw' => 0, - ]; - } - - /** - * Format nilai data sales untuk tampilan - */ - private function formatSalesDataValues(Collection $salesData): Collection - { - return $salesData->map(function ($sale) { - $sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : self::DEFAULT_DISPLAY; - $sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ? - $this->formatWeight($sale['berat_terjual_raw']) : self::DEFAULT_DISPLAY; - $sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ? - $this->formatCurrency($sale['pendapatan_raw']) : self::DEFAULT_DISPLAY; - - unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']); - return $sale; - }); - } - - /** - * Format mata uang - */ - private function formatCurrency(float $amount): string - { - return self::CURRENCY_SYMBOL . number_format($amount, 2, ',', '.'); - } - - /** - * Format berat - */ - private function formatWeight(float $weight): string - { - return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT; - } } diff --git a/app/Http/Requests/DetailLaporanRequest.php b/app/Http/Requests/DetailLaporanRequest.php new file mode 100644 index 0000000..5a2f308 --- /dev/null +++ b/app/Http/Requests/DetailLaporanRequest.php @@ -0,0 +1,61 @@ + 'required|date_format:Y-m-d|before_or_equal:today', + 'sales_id' => 'nullable|integer|exists:sales,id', + 'nampan_id' => 'nullable|integer', + 'produk_id' => 'nullable|integer|exists:produks,id', + 'nama_pembeli' => 'nullable|string|max:255', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'tanggal.required' => 'Tanggal harus diisi', + 'tanggal.date_format' => 'Format tanggal harus Y-m-d', + 'tanggal.before_or_equal' => 'Tanggal tidak boleh lebih dari hari ini', + 'sales_id.exists' => 'Sales tidak ditemukan', + 'produk_id.exists' => 'Produk tidak ditemukan', + 'nama_pembeli.max' => 'Nama pembeli maksimal 255 karakter', + 'page.min' => 'Page minimal 1', + 'per_page.min' => 'Per page minimal 1', + 'per_page.max' => 'Per page maksimal 100', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'page' => $this->query('page', 1), + 'per_page' => $this->query('per_page', 15), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Requests/ExportLaporanRequest.php b/app/Http/Requests/ExportLaporanRequest.php new file mode 100644 index 0000000..54eb29b --- /dev/null +++ b/app/Http/Requests/ExportLaporanRequest.php @@ -0,0 +1,41 @@ + 'required|in:hari,bulan', + 'format' => 'required|in:pdf,xlsx,csv', + 'page' => 'nullable', + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'filter.required' => 'Filter harus diisi', + 'filter.in' => 'Filter harus berupa "hari" atau "bulan"', + 'format.required' => 'Format export harus diisi', + 'format.in' => 'Format export harus berupa "pdf", "xlsx", atau "csv"', + ]; + } +} \ No newline at end of file diff --git a/app/Repositories/TransaksiRepository.php b/app/Repositories/TransaksiRepository.php new file mode 100644 index 0000000..edc13fe --- /dev/null +++ b/app/Repositories/TransaksiRepository.php @@ -0,0 +1,143 @@ +helper = $helper; + } + + public function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true) + { + $perPage = self::DAILY_PER_PAGE; + + if ($limitPagination) { + $endDate = Carbon::today()->subDays(($page - 1) * $perPage); + $startDate = $endDate->copy()->subDays($perPage - 1); + $totalHariUntukPaginasi = self::PAGINATION_DAYS_LIMIT; + } else { + $endDate = Carbon::today(); + $startDate = $endDate->copy()->subYear()->addDay(); + $totalHariUntukPaginasi = $endDate->diffInDays($startDate) + 1; + } + + $transaksis = Transaksi::with(['itemTransaksi.item.produk']) + ->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()]) + ->orderBy('created_at', 'desc') + ->get(); + + $transaksisByDay = $transaksis->groupBy(function ($transaksi) { + return Carbon::parse($transaksi->created_at)->format('Y-m-d'); + }); + + $period = CarbonPeriod::create($startDate, $endDate); + $laporan = []; + + foreach ($period as $date) { + $dateString = $date->format('Y-m-d'); + $tanggalFormatted = $date->isoFormat('dddd, D MMMM Y'); + + if (isset($transaksisByDay[$dateString])) { + $transaksisPerTanggal = $transaksisByDay[$dateString]; + $salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales') + ->map(fn($transaksisPerSales) => $this->helper->hitungDataSales($transaksisPerSales)); + + $fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) { + return $salesDataTransaksi->get($namaSales) ?? $this->helper->defaultSalesData($namaSales); + }); + + $totalItem = $fullSalesData->sum('item_terjual'); + $totalBerat = $fullSalesData->sum('berat_terjual_raw'); + $totalPendapatan = $fullSalesData->sum('pendapatan_raw'); + + $laporan[$dateString] = [ + 'tanggal' => $tanggalFormatted, + 'total_item_terjual' => $totalItem > 0 ? $totalItem : LaporanHelper::DEFAULT_DISPLAY, + 'total_berat' => $totalBerat > 0 ? $this->helper->formatWeight($totalBerat) : LaporanHelper::DEFAULT_DISPLAY, + 'total_pendapatan' => $totalPendapatan > 0 ? $this->helper->formatCurrency($totalPendapatan) : LaporanHelper::DEFAULT_DISPLAY, + 'sales' => $this->helper->formatSalesDataValues($fullSalesData)->values(), + ]; + } else { + $laporan[$dateString] = [ + 'tanggal' => $tanggalFormatted, + 'total_item_terjual' => LaporanHelper::DEFAULT_DISPLAY, + 'total_berat' => LaporanHelper::DEFAULT_DISPLAY, + 'total_pendapatan' => LaporanHelper::DEFAULT_DISPLAY, + 'sales' => [], + ]; + } + } + + if ($limitPagination) { + return new LengthAwarePaginator( + array_reverse(array_values($laporan)), + $totalHariUntukPaginasi, + $perPage, + $page, + ['path' => request()->url(), 'query' => request()->query()] + ); + } + + return collect(array_reverse(array_values($laporan))); + } + + public function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true) + { + $perPage = self::MONTHLY_PER_PAGE; + + $transaksis = Transaksi::with(['itemTransaksi.item.produk']) + ->orderBy('created_at', 'desc') + ->get(); + + $laporan = $transaksis->groupBy(function ($transaksi) { + return Carbon::parse($transaksi->created_at)->format('F Y'); + })->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) { + $salesDataTransaksi = $transaksisPerTanggal + ->groupBy('nama_sales') + ->map(fn($transaksisPerSales) => $this->helper->hitungDataSales($transaksisPerSales)); + + $fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) { + return $salesDataTransaksi->get($namaSales) ?? $this->helper->defaultSalesData($namaSales); + }); + + $totalItem = $fullSalesData->sum('item_terjual'); + $totalBerat = $fullSalesData->sum('berat_terjual_raw'); + $totalPendapatan = $fullSalesData->sum('pendapatan_raw'); + + return [ + 'tanggal' => $tanggal, + 'total_item_terjual' => $totalItem > 0 ? $totalItem : LaporanHelper::DEFAULT_DISPLAY, + 'total_berat' => $totalBerat > 0 ? $this->helper->formatWeight($totalBerat) : LaporanHelper::DEFAULT_DISPLAY, + 'total_pendapatan' => $totalPendapatan > 0 ? $this->helper->formatCurrency($totalPendapatan) : LaporanHelper::DEFAULT_DISPLAY, + 'sales' => $this->helper->formatSalesDataValues($fullSalesData)->values(), + ]; + }); + + if ($limitPagination) { + return new LengthAwarePaginator( + $laporan->forPage($page, $perPage)->values(), + $laporan->count(), + $perPage, + $page, + ['path' => request()->url(), 'query' => request()->query()] + ); + } + + return $laporan->values(); + } +} \ No newline at end of file diff --git a/app/Services/LaporanService.php b/app/Services/LaporanService.php new file mode 100644 index 0000000..80eff1c --- /dev/null +++ b/app/Services/LaporanService.php @@ -0,0 +1,227 @@ +transaksiRepo = $transaksiRepo; + $this->helper = $helper; + } + + public function getRingkasan(string $filter, int $page) + { + $cacheKey = "laporan_ringkasan_{$filter}_page_{$page}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($filter, $page) { + $allSalesNames = $this->getAllSalesNames(); + + if ($filter === 'hari') { + return $this->processLaporanHarian($allSalesNames, $page, true); + } + + return $this->processLaporanBulanan($allSalesNames, $page, true); + }); + } + + public function getDetailPerProduk(array $params) + { + $tanggal = Carbon::parse($params['tanggal']); + $page = $params['page'] ?? 1; + $perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE; + + // Validasi nampan_id jika ada + if (isset($params['nampan_id']) && $params['nampan_id'] != 0) { + if (!Nampan::where('id', $params['nampan_id'])->exists()) { + throw new \Exception('Nampan tidak ditemukan'); + } + } + + $produkTerjualQuery = $this->buildBaseItemQuery($tanggal); + $this->applyFilters($produkTerjualQuery, $params); + + $produkTerjual = $produkTerjualQuery + ->select( + 'produks.id as id_produk', + 'produks.nama as nama_produk', + DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'), + DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'), + DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan') + ) + ->groupBy('produks.id', 'produks.nama') + ->get() + ->keyBy('id_produk'); + + $totals = $this->helper->calculateTotals($produkTerjual); + $semuaProdukPaginated = Produk::select('id', 'nama') + ->orderBy('nama') + ->paginate($perPage, ['*'], 'page', $page); + + $detailItem = $this->helper->mapProductsWithSalesData($semuaProdukPaginated, $produkTerjual); + $filterInfo = $this->helper->buildProdukFilterInfo($tanggal, $params); + + return [ + 'filter' => $filterInfo, + 'rekap_harian' => $totals, + 'produk' => $detailItem->values(), + 'pagination' => $this->helper->buildPaginationInfo($semuaProdukPaginated), + ]; + } + + public function getDetailPerNampan(array $params) + { + $tanggal = Carbon::parse($params['tanggal']); + $page = $params['page'] ?? 1; + $perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE; + + $nampanTerjualQuery = $this->buildBaseItemQuery($tanggal); + $this->applyNampanFilters($nampanTerjualQuery, $params); + + $nampanTerjual = $nampanTerjualQuery + ->leftJoin('nampans', 'items.id_nampan', '=', 'nampans.id') + ->select( + DB::raw('COALESCE(items.id_nampan, 0) as id_nampan'), + DB::raw('COALESCE(nampans.nama, "Brankas") as nama_nampan'), + DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'), + DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'), + DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan') + ) + ->groupBy('id_nampan', 'nama_nampan') + ->get() + ->keyBy('id_nampan'); + + $totals = $this->helper->calculateTotals($nampanTerjual); + $semuaNampanPaginated = $this->helper->getAllNampanWithPagination($page, $perPage); + $detailItem = $this->helper->mapNampanWithSalesData($semuaNampanPaginated, $nampanTerjual); + $filterInfo = $this->helper->buildNampanFilterInfo($tanggal, $params); + + return [ + 'filter' => $filterInfo, + 'rekap_harian' => $totals, + 'nampan' => $detailItem->values(), + 'pagination' => $this->helper->buildPaginationInfo($semuaNampanPaginated), + ]; + } + + public function exportRingkasan(array $params) + { + $filter = $params['filter']; + $format = $params['format']; + $page = $params['page'] ?? 1; + + $allSalesNames = $this->getAllSalesNames(); + + if ($filter === 'hari') { + // Tar kalau mau ubah eksport laporan setiap hari, param ke-3 jadiin false #Bagas + $data = $this->processLaporanHarian($allSalesNames, $page, true); + } else { + $data = $this->processLaporanBulanan($allSalesNames, $page, true); + } + + $fileName = "laporan_ringkasan_{$filter}_" . Carbon::now()->format('Ymd') . ".{$format}"; + + if ($format === 'pdf') { + $viewData = method_exists($data, 'items') ? $data->items() : $data; + + $pdf = PDF::loadView('exports.ringkasan_pdf', [ + 'data' => $viewData, + 'filter' => $filter + ]); + $pdf->setPaper('a4', 'landscape'); + return $pdf->download($fileName); + } + + return Excel::download(new RingkasanExport($data, $page), $fileName); + } + + private function getAllSalesNames(): Collection + { + return Cache::remember('all_sales_names', self::CACHE_TTL, function () { + return Transaksi::select('nama_sales')->distinct()->pluck('nama_sales'); + }); + } + + private function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true) + { + return $this->transaksiRepo->processLaporanHarian($allSalesNames, $page, $limitPagination); + } + + private function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true) + { + return $this->transaksiRepo->processLaporanBulanan($allSalesNames, $page, $limitPagination); + } + + private function buildBaseItemQuery(Carbon $carbonDate) + { + return ItemTransaksi::query() + ->join('items', 'item_transaksis.id_item', '=', 'items.id') + ->join('produks', 'items.id_produk', '=', 'produks.id') + ->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id') + ->whereDate('transaksis.created_at', $carbonDate); + } + + private function applyFilters($query, array $params): void + { + if (!empty($params['sales_id'])) { + $query->join('sales', 'transaksis.id_sales', '=', 'sales.id') + ->where('sales.id', $params['sales_id']); + } + + if (isset($params['nampan_id'])) { + if ($params['nampan_id'] == 0) { + $query->whereNull('items.id_nampan'); + } else { + $query->where('items.id_nampan', $params['nampan_id']); + } + } + + if (!empty($params['nama_pembeli'])) { + $query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%"); + } + } + + private function applyNampanFilters($query, array $params): void + { + if (!empty($params['sales_id'])) { + $query->join('sales', 'transaksis.id_sales', '=', 'sales.id') + ->where('sales.id', $params['sales_id']); + } + + if (!empty($params['produk_id'])) { + $query->where('produks.id', $params['produk_id']); + } + + if (!empty($params['nama_pembeli'])) { + $query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%"); + } + } +} diff --git a/resources/js/components/NavMobile.vue b/resources/js/components/NavMobile.vue index a946952..e2eccc5 100644 --- a/resources/js/components/NavMobile.vue +++ b/resources/js/components/NavMobile.vue @@ -17,19 +17,16 @@ const { \ No newline at end of file + diff --git a/resources/js/components/ProductCard.vue b/resources/js/components/ProductCard.vue index 650784a..bac5015 100644 --- a/resources/js/components/ProductCard.vue +++ b/resources/js/components/ProductCard.vue @@ -1,6 +1,6 @@