[Update] Export ringkasan Laporan
This commit is contained in:
parent
1cd2aa60d4
commit
d32e659076
172
Documentation/Laporan.md
Normal file
172
Documentation/Laporan.md
Normal file
@ -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
|
63
app/Exports/DetailNampanExport.php
Normal file
63
app/Exports/DetailNampanExport.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class DetailNampanExport implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||
{
|
||||
private $data;
|
||||
private $page;
|
||||
|
||||
public function __construct($data, $page = 1)
|
||||
{
|
||||
$this->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]],
|
||||
];
|
||||
}
|
||||
}
|
63
app/Exports/DetailProdukExport.php
Normal file
63
app/Exports/DetailProdukExport.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class DetailProdukExport implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||
{
|
||||
private $data;
|
||||
private $page;
|
||||
|
||||
public function __construct($data, $page = 1)
|
||||
{
|
||||
$this->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]],
|
||||
];
|
||||
}
|
||||
}
|
@ -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'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
204
app/Helpers/LaporanHelper.php
Normal file
204
app/Helpers/LaporanHelper.php
Normal file
@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Models\Nampan;
|
||||
use App\Models\Sales;
|
||||
use App\Models\Produk;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class LaporanHelper
|
||||
{
|
||||
public const CURRENCY_SYMBOL = 'Rp ';
|
||||
public const WEIGHT_UNIT = ' g';
|
||||
public const DEFAULT_DISPLAY = '-';
|
||||
|
||||
public 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),
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
61
app/Http/Requests/DetailLaporanRequest.php
Normal file
61
app/Http/Requests/DetailLaporanRequest.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DetailLaporanRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tanggal' => '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),
|
||||
]);
|
||||
}
|
||||
}
|
41
app/Http/Requests/ExportLaporanRequest.php
Normal file
41
app/Http/Requests/ExportLaporanRequest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ExportLaporanRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'filter' => '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"',
|
||||
];
|
||||
}
|
||||
}
|
143
app/Repositories/TransaksiRepository.php
Normal file
143
app/Repositories/TransaksiRepository.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Transaksi;
|
||||
use App\Helpers\LaporanHelper;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class TransaksiRepository
|
||||
{
|
||||
private const DAILY_PER_PAGE = 7;
|
||||
private const MONTHLY_PER_PAGE = 12;
|
||||
private const PAGINATION_DAYS_LIMIT = 365;
|
||||
|
||||
private LaporanHelper $helper;
|
||||
|
||||
public function __construct(LaporanHelper $helper)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
227
app/Services/LaporanService.php
Normal file
227
app/Services/LaporanService.php
Normal file
@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ItemTransaksi;
|
||||
use App\Models\Produk;
|
||||
use App\Models\Transaksi;
|
||||
use App\Models\Sales;
|
||||
use App\Models\Nampan;
|
||||
use App\Repositories\TransaksiRepository;
|
||||
use App\Helpers\LaporanHelper;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use App\Exports\RingkasanExport;
|
||||
use App\Exports\DetailProdukExport;
|
||||
use App\Exports\DetailNampanExport;
|
||||
|
||||
class LaporanService
|
||||
{
|
||||
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 TransaksiRepository $transaksiRepo;
|
||||
private LaporanHelper $helper;
|
||||
|
||||
public function __construct(TransaksiRepository $transaksiRepo, LaporanHelper $helper)
|
||||
{
|
||||
$this->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']}%");
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,11 @@
|
||||
</div>
|
||||
|
||||
<div class="relative w-40" ref="exportDropdownRef">
|
||||
<button @click="isExportOpen = !isExportOpen" type="button"
|
||||
<button v-if="loadingExport" type="button"
|
||||
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
|
||||
</button>
|
||||
<button v-else @click="isExportOpen = !isExportOpen" type="button"
|
||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
@ -135,7 +139,7 @@ const filterOptions = ref([
|
||||
]);
|
||||
const exportOptions = ref([
|
||||
{ value: 'pdf', label: 'Pdf' },
|
||||
{ value: 'xls', label: 'Excel' },
|
||||
{ value: 'xlsx', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
@ -186,7 +190,7 @@ const fetchRingkasan = async (page = 1) => {
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan?filter=${filterRingkasan.value}&page=${page}`, {
|
||||
const response = await axios.get(`/api/laporan/ringkasan?filter=${filterRingkasan.value}&page=${page}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
@ -226,15 +230,22 @@ const triggerDownload = async (format) => {
|
||||
loadingExport.value = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/laporan/ringkasan/export', {
|
||||
params: {
|
||||
filter: filterRingkasan.value,
|
||||
format: format
|
||||
},
|
||||
responseType: 'blob',
|
||||
console.log('Fucking report with params:', {
|
||||
filter: filterRingkasan.value,
|
||||
format: format,
|
||||
page: pagination.value.current_page,
|
||||
});
|
||||
|
||||
const response = await axios.get('/api/laporan/export/ringkasan', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
}
|
||||
},
|
||||
responseType: 'blob',
|
||||
params: {
|
||||
filter: filterRingkasan.value,
|
||||
format: format,
|
||||
page: pagination.value.current_page,
|
||||
},
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
@ -1,245 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center justify-end mt-5 gap-3">
|
||||
<div class="relative w-32" ref="filterDropdownRef">
|
||||
<button @click="isFilterOpen = !isFilterOpen" type="button"
|
||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||
<span>{{ selectedFilterLabel }}</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="isFilterOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
||||
<ul class="py-1">
|
||||
<li v-for="option in filterOptions" :key="option.value" @click="selectFilter(option)"
|
||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
||||
{{ option.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative w-40" ref="exportDropdownRef">
|
||||
<button @click="isExportOpen = !isExportOpen" type="button"
|
||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="isExportOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
||||
<ul class="py-1">
|
||||
<li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)"
|
||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
||||
{{ option.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<table class="w-full border-collapse border border-C rounded-md">
|
||||
<thead>
|
||||
<tr class="bg-C text-D rounded-t-md">
|
||||
<th class="border-x border-C px-3 py-3">Tanggal</th>
|
||||
<th class="border-x border-C px-3 py-3">Nama Sales</th>
|
||||
<th class="border-x border-C px-3 py-3">Jumlah Item Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Berat Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Pendapatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="5" class="p-4">
|
||||
<div class="flex items-center justify-center w-full h-30">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
||||
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!ringkasanLaporan.length">
|
||||
<td colspan="5" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
||||
</tr>
|
||||
<template v-else v-for="item in ringkasanLaporan" :key="item.tanggal">
|
||||
<template v-if="item.sales && item.sales.length > 0">
|
||||
<tr class="text-center border-y border-C"
|
||||
:class="item.sales[0].item_terjual == 0 ? 'bg-red-200 hover:bg-red-300' : 'hover:bg-A'">
|
||||
<td class="px-3 py-2 border-x border-C bg-white" :rowspan="item.sales.length">{{
|
||||
item.tanggal }}</td>
|
||||
<td class="px-3 py-2 border-x border-C text-left">{{ item.sales[0].nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.sales[0].pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="sales in item.sales.slice(1)" :key="sales.nama"
|
||||
class="text-center border-y border-C"
|
||||
:class="sales.item_terjual == '-' ? 'bg-red-200 hover:bg-red-300' : 'hover:bg-A'">
|
||||
<td class="px-3 py-2 text-left border-x border-C">{{ sales.nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="sales.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ sales.pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="font-semibold text-center border-y border-C bg-B hover:bg-C/80">
|
||||
<td class="px-3 py-2 border-x border-C" colspan="2">Total</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_berat }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.total_pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.tanggal }}</td>
|
||||
<td colspan="4" class="px-3 py-2 italic text-gray-500 border-x border-C bg-yellow-50 hover:bg-yellow-100">Tidak ada transaksi
|
||||
pada hari ini</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
||||
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
|
||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
||||
Sebelumnya
|
||||
</button>
|
||||
<span class="text-sm text-D">
|
||||
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
|
||||
</span>
|
||||
<button @click="goToPage(pagination.current_page + 1)"
|
||||
:disabled="(pagination.current_page === pagination.last_page) || loading"
|
||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
||||
Berikutnya
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
// --- State ---
|
||||
const isFilterOpen = ref(false);
|
||||
const isExportOpen = ref(false);
|
||||
const filterDropdownRef = ref(null);
|
||||
const exportDropdownRef = ref(null);
|
||||
|
||||
const filterOptions = ref([
|
||||
{ value: 'bulan', label: 'Bulanan' },
|
||||
{ value: 'hari', label: 'Harian' }
|
||||
]);
|
||||
const exportOptions = ref([
|
||||
{ value: 'pdf', label: 'Pdf' },
|
||||
{ value: 'xls', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
const filterRingkasan = ref("bulan");
|
||||
const exportFormat = ref(null);
|
||||
const ringkasanLaporan = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
// --- Computed ---
|
||||
const selectedFilterLabel = computed(() => {
|
||||
return filterOptions.value.find(opt => opt.value === filterRingkasan.value)?.label;
|
||||
});
|
||||
|
||||
const selectedExportLabel = computed(() => {
|
||||
return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan';
|
||||
});
|
||||
|
||||
const pendapatanStyle = computed(() => ({
|
||||
minWidth: `${pendapatanWidth.value}px`,
|
||||
padding: '0.5rem 0.75rem'
|
||||
}));
|
||||
|
||||
// --- Watchers ---
|
||||
watch(ringkasanLaporan, async (newValue) => {
|
||||
if (newValue && newValue.length > 0) {
|
||||
await nextTick();
|
||||
let maxWidth = 0;
|
||||
pendapatanElements.value.forEach(el => {
|
||||
if (el && el.scrollWidth > maxWidth) {
|
||||
maxWidth = el.scrollWidth;
|
||||
}
|
||||
});
|
||||
pendapatanWidth.value = maxWidth;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// --- Methods ---
|
||||
const fetchRingkasan = async (page = 1) => {
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan?filter=${filterRingkasan.value}&page=${page}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});;
|
||||
ringkasanLaporan.value = response.data.data;
|
||||
pagination.value = {
|
||||
current_page: response.data.current_page,
|
||||
last_page: response.data.last_page,
|
||||
total: response.data.total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching laporan:", error);
|
||||
ringkasanLaporan.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= pagination.value.last_page) {
|
||||
fetchRingkasan(page);
|
||||
}
|
||||
};
|
||||
|
||||
const selectFilter = (option) => {
|
||||
filterRingkasan.value = option.value;
|
||||
isFilterOpen.value = false;
|
||||
goToPage(1);
|
||||
};
|
||||
|
||||
const selectExport = (option) => {
|
||||
exportFormat.value = option.value;
|
||||
isExportOpen.value = false;
|
||||
alert(`Fitur Belum dikerjakan. Laporan akan diekspor dalam format ${option.label}`);
|
||||
};
|
||||
|
||||
const closeDropdownsOnClickOutside = (event) => {
|
||||
if (filterDropdownRef.value && !filterDropdownRef.value.contains(event.target)) {
|
||||
isFilterOpen.value = false;
|
||||
}
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(() => {
|
||||
fetchRingkasan(pagination.value.current_page);
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
</script>
|
@ -21,7 +21,7 @@
|
||||
|
||||
<div>
|
||||
<div v-if="activeTab === 'ringkasan'" id="ringkasan-content" role="tabpanel">
|
||||
<RingkasanLaporanB />
|
||||
<RingkasanLaporan />
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'detail-nampan'" id="detail-content" role="tabpanel">
|
||||
@ -38,8 +38,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RingkasanLaporanA from '../components/RingkasanLaporanA.vue';
|
||||
import RingkasanLaporanB from '../components/RingkasanLaporanB.vue';
|
||||
import RingkasanLaporan from '../components/RingkasanLaporan.vue';
|
||||
import mainLayout from '../layouts/mainLayout.vue';
|
||||
import DetailPerNampan from '../components/DetailPerNampan.vue';
|
||||
import DetailPerProduk from '../components/DetailPerProduk.vue';
|
||||
|
@ -39,11 +39,15 @@ Route::prefix('api')->group(function () {
|
||||
Route::delete('foto/reset/{user_id}', [FotoSementaraController::class, 'reset']);
|
||||
|
||||
// Laporan
|
||||
Route::get('laporan', [LaporanController::class, 'ringkasan']);
|
||||
Route::get('detail-per-produk', [LaporanController::class, 'detailPerProduk']);
|
||||
Route::get('detail-per-nampan', [LaporanController::class, 'detailPerNampan']);
|
||||
|
||||
Route::get('/laporan/ringkasan/export', [LaporanController::class, 'exportRingkasan']);
|
||||
Route::prefix('laporan')->group(function () {
|
||||
Route::get('ringkasan', [LaporanController::class, 'ringkasan']);
|
||||
Route::get('detail-per-produk', [LaporanController::class, 'detailPerProduk']);
|
||||
Route::get('detail-per-nampan', [LaporanController::class, 'detailPerNampan']);
|
||||
|
||||
Route::get('export/ringkasan', [LaporanController::class, 'exportRingkasan']);
|
||||
Route::get('export/detail-produk', [LaporanController::class, 'exportDetailProduk']);
|
||||
Route::get('export/detail-nampan', [LaporanController::class, 'exportDetailNampan']);
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'role:owner,kasir'])->group(function () {
|
||||
|
Loading…
Reference in New Issue
Block a user