Kasir/app/Http/Controllers/LaporanController.php
2025-09-08 18:28:49 +07:00

646 lines
24 KiB
PHP

<?php
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 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;
/**
* Endpoint untuk ringkasan laporan dengan caching
*/
public function ringkasan(Request $request)
{
try {
$filter = $request->query('filter', 'bulan');
$page = (int) $request->query('page', 1);
// Validasi filter
if (!in_array($filter, ['hari', 'bulan'])) {
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);
});
return response()->json($data);
} catch (\Exception $e) {
Log::error('Error in ringkasan method: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data'], 500);
}
}
/**
* Detail laporan per produk dengan validasi dan error handling yang lebih baik
*/
public function detailPerProduk(Request $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),
]);
} catch (\Exception $e) {
Log::error('Error in detailPerProduk method: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data produk'], 500);
}
}
/**
* Detail laporan per nampan dengan perbaikan validasi dan error handling
*/
public function detailPerNampan(Request $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),
]);
} catch (\Exception $e) {
Log::error('Error in detailPerNampan method: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data nampan'], 500);
}
}
/**
* Export laporan ringkasan dengan validasi format
*/
public function exportRingkasan(Request $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);
} 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;
}
}