fix feature report stock product
This commit is contained in:
316
app/Exports/StockProductsExport.php
Normal file
316
app/Exports/StockProductsExport.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnWidths;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class StockProductsExport implements FromCollection, WithHeadings, WithStyles, WithColumnWidths
|
||||
{
|
||||
protected $reportData;
|
||||
|
||||
public function __construct($reportData)
|
||||
{
|
||||
// Validate and sanitize report data
|
||||
if (!is_array($reportData)) {
|
||||
throw new \InvalidArgumentException('Report data must be an array');
|
||||
}
|
||||
|
||||
if (!isset($reportData['data']) || !isset($reportData['dealers'])) {
|
||||
throw new \InvalidArgumentException('Report data must contain "data" and "dealers" keys');
|
||||
}
|
||||
|
||||
// Ensure dealers is a collection
|
||||
if (!($reportData['dealers'] instanceof Collection)) {
|
||||
$reportData['dealers'] = collect($reportData['dealers']);
|
||||
}
|
||||
|
||||
// Ensure data is an array
|
||||
if (!is_array($reportData['data'])) {
|
||||
$reportData['data'] = [];
|
||||
}
|
||||
|
||||
$this->reportData = $reportData;
|
||||
|
||||
// Debug: Log the structure of report data
|
||||
Log::info('StockProductsExport constructor', [
|
||||
'has_data' => isset($reportData['data']),
|
||||
'has_dealers' => isset($reportData['dealers']),
|
||||
'data_count' => isset($reportData['data']) ? count($reportData['data']) : 0,
|
||||
'dealers_count' => isset($reportData['dealers']) ? count($reportData['dealers']) : 0,
|
||||
'dealers' => isset($reportData['dealers']) ? $reportData['dealers']->pluck('name')->toArray() : []
|
||||
]);
|
||||
}
|
||||
|
||||
public function collection()
|
||||
{
|
||||
try {
|
||||
$data = collect();
|
||||
$no = 1;
|
||||
|
||||
foreach ($this->reportData['data'] as $row) {
|
||||
$exportRow = [
|
||||
'no' => $no++,
|
||||
'kode_produk' => $row['product_code'] ?? '',
|
||||
'nama_produk' => $row['product_name'] ?? '',
|
||||
'kategori' => $row['category_name'] ?? '',
|
||||
'satuan' => $row['unit'] ?? ''
|
||||
];
|
||||
|
||||
// Add dealer columns
|
||||
foreach ($this->reportData['dealers'] as $dealer) {
|
||||
try {
|
||||
$dealerKey = "dealer_{$dealer->id}";
|
||||
// Clean dealer name for array key to avoid special characters
|
||||
$cleanDealerName = $this->cleanDealerName($dealer->name);
|
||||
$exportRow[$cleanDealerName] = $row[$dealerKey] ?? 0;
|
||||
|
||||
Log::info('Processing dealer column', [
|
||||
'original_name' => $dealer->name,
|
||||
'clean_name' => $cleanDealerName,
|
||||
'dealer_key' => $dealerKey,
|
||||
'value' => $row[$dealerKey] ?? 0
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error processing dealer column', [
|
||||
'dealer_id' => $dealer->id,
|
||||
'dealer_name' => $dealer->name,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Use a safe fallback name
|
||||
$exportRow['Dealer_' . $dealer->id] = $row["dealer_{$dealer->id}"] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Add total stock
|
||||
$exportRow['total_stok'] = $row['total_stock'] ?? 0;
|
||||
|
||||
$data->push($exportRow);
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in collection method', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
// Return empty collection as fallback
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
try {
|
||||
$headings = [
|
||||
'No',
|
||||
'Kode Produk',
|
||||
'Nama Produk',
|
||||
'Kategori',
|
||||
'Satuan'
|
||||
];
|
||||
|
||||
// Add dealer headings
|
||||
foreach ($this->reportData['dealers'] as $dealer) {
|
||||
try {
|
||||
$cleanName = $this->cleanDealerName($dealer->name);
|
||||
$headings[] = $cleanName;
|
||||
|
||||
Log::info('Processing dealer heading', [
|
||||
'original_name' => $dealer->name,
|
||||
'clean_name' => $cleanName
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error processing dealer heading', [
|
||||
'dealer_id' => $dealer->id,
|
||||
'dealer_name' => $dealer->name,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Use a safe fallback name
|
||||
$headings[] = 'Dealer_' . $dealer->id;
|
||||
}
|
||||
}
|
||||
|
||||
// Add total heading
|
||||
$headings[] = 'Total Stok';
|
||||
|
||||
return $headings;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in headings method', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
// Return basic headings as fallback
|
||||
return ['No', 'Kode Produk', 'Nama Produk', 'Kategori', 'Satuan', 'Total Stok'];
|
||||
}
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
try {
|
||||
$lastColumn = $sheet->getHighestColumn();
|
||||
$lastRow = $sheet->getHighestRow();
|
||||
|
||||
// Validate column and row values
|
||||
if (!$lastColumn || !$lastRow || $lastRow < 1) {
|
||||
Log::warning('Invalid sheet dimensions', ['lastColumn' => $lastColumn, 'lastRow' => $lastRow]);
|
||||
return $sheet;
|
||||
}
|
||||
|
||||
// Style header row
|
||||
$sheet->getStyle('A1:' . $lastColumn . '1')->applyFromArray([
|
||||
'font' => [
|
||||
'bold' => true,
|
||||
'color' => ['rgb' => 'FFFFFF'],
|
||||
],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4472C4'],
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
],
|
||||
]);
|
||||
|
||||
// Style all cells
|
||||
$sheet->getStyle('A1:' . $lastColumn . $lastRow)->applyFromArray([
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => Border::BORDER_THIN,
|
||||
'color' => ['rgb' => '000000'],
|
||||
],
|
||||
],
|
||||
'alignment' => [
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
],
|
||||
]);
|
||||
|
||||
// Center align specific columns
|
||||
$sheet->getStyle('A:A')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('D:D')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
// Right align numeric columns (dealer columns and total)
|
||||
$dealerStartCol = 'E';
|
||||
$dealerCount = count($this->reportData['dealers']);
|
||||
|
||||
if ($dealerCount > 0) {
|
||||
$dealerEndCol = chr(ord('E') + $dealerCount - 1);
|
||||
$totalCol = chr(ord($dealerStartCol) + $dealerCount);
|
||||
|
||||
// Validate column letters
|
||||
if (ord($dealerEndCol) <= ord('Z') && ord($totalCol) <= ord('Z')) {
|
||||
$sheet->getStyle($dealerStartCol . ':' . $dealerEndCol)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle($totalCol . ':' . $totalCol)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
|
||||
// Bold total column
|
||||
$sheet->getStyle($totalCol . '1:' . $totalCol . $lastRow)->getFont()->setBold(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-size columns safely
|
||||
foreach (range('A', $lastColumn) as $column) {
|
||||
try {
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Failed to auto-size column', ['column' => $column, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $sheet;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in styles method', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return $sheet;
|
||||
}
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
try {
|
||||
$widths = [
|
||||
'A' => 8, // No
|
||||
'B' => 15, // Kode Produk
|
||||
'C' => 30, // Nama Produk
|
||||
'D' => 15, // Kategori
|
||||
'E' => 10, // Satuan
|
||||
];
|
||||
|
||||
// Add dealer column widths safely
|
||||
$currentCol = 'F';
|
||||
$dealerCount = count($this->reportData['dealers']);
|
||||
|
||||
for ($i = 0; $i < $dealerCount; $i++) {
|
||||
// Validate column letter
|
||||
if (ord($currentCol) <= ord('Z')) {
|
||||
$widths[$currentCol] = 15;
|
||||
$currentCol = chr(ord($currentCol) + 1);
|
||||
} else {
|
||||
Log::warning('Too many dealer columns, stopping at column Z');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add total column width if we haven't exceeded Z
|
||||
if (ord($currentCol) <= ord('Z')) {
|
||||
$widths[$currentCol] = 15;
|
||||
}
|
||||
|
||||
return $widths;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in columnWidths method', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
// Return basic widths as fallback
|
||||
return [
|
||||
'A' => 8, // No
|
||||
'B' => 15, // Kode Produk
|
||||
'C' => 30, // Nama Produk
|
||||
'D' => 15, // Kategori
|
||||
'E' => 10, // Satuan
|
||||
'F' => 15 // Total Stok
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean dealer name to make it safe for array keys and Excel headers
|
||||
*/
|
||||
private function cleanDealerName($dealerName)
|
||||
{
|
||||
// Remove or replace special characters that can cause issues with Excel
|
||||
$cleanName = preg_replace('/[^a-zA-Z0-9\s\-_]/', '', $dealerName);
|
||||
$cleanName = trim($cleanName);
|
||||
|
||||
// If name becomes empty, use a default
|
||||
if (empty($cleanName)) {
|
||||
$cleanName = 'Dealer';
|
||||
}
|
||||
|
||||
// Limit length to avoid Excel issues
|
||||
if (strlen($cleanName) > 31) {
|
||||
$cleanName = substr($cleanName, 0, 31);
|
||||
}
|
||||
|
||||
return $cleanName;
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/Reports/ReportStockProductsController.php
Normal file
102
app/Http/Controllers/Reports/ReportStockProductsController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Reports;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Menu;
|
||||
use App\Models\Product;
|
||||
use App\Models\Dealer;
|
||||
use App\Models\Stock;
|
||||
use App\Models\StockLog;
|
||||
use App\Services\StockReportService;
|
||||
use App\Exports\StockProductsExport;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Yajra\DataTables\DataTables;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
class ReportStockProductsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$menu = Menu::where('link','reports.stock-product.index')->first();
|
||||
abort_if(!Gate::allows('view', $menu), 403);
|
||||
|
||||
return view('reports.stock-products');
|
||||
}
|
||||
|
||||
public function getData(Request $request)
|
||||
{
|
||||
$menu = Menu::where('link','reports.stock-product.index')->first();
|
||||
abort_if(!Gate::allows('view', $menu), 403);
|
||||
|
||||
if ($request->ajax()) {
|
||||
$filterDate = $request->get('filter_date');
|
||||
|
||||
$stockService = new StockReportService();
|
||||
$reportData = $stockService->getOptimizedStockReportData($filterDate);
|
||||
|
||||
return DataTables::of($reportData['data'])
|
||||
->addIndexColumn()
|
||||
->addColumn('product_info', function($row) {
|
||||
return "<strong>{$row['product_name']}</strong><br><small class='text-muted'>{$row['product_code']}</small>";
|
||||
})
|
||||
->addColumn('total_stock', function($row) {
|
||||
return number_format($row['total_stock'], 2);
|
||||
})
|
||||
->rawColumns(['product_info'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
return response()->json(['error' => 'Invalid request'], 400);
|
||||
}
|
||||
|
||||
public function getDealers()
|
||||
{
|
||||
$dealers = Dealer::orderBy('name')->get(['id', 'name', 'dealer_code']);
|
||||
return response()->json($dealers);
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
try {
|
||||
$menu = Menu::where('link','reports.stock-product.index')->first();
|
||||
abort_if(!Gate::allows('view', $menu), 403);
|
||||
|
||||
$filterDate = $request->get('filter_date');
|
||||
|
||||
$stockService = new StockReportService();
|
||||
$reportData = $stockService->getOptimizedStockReportData($filterDate);
|
||||
|
||||
// Validate report data
|
||||
if (!isset($reportData['data']) || !isset($reportData['dealers'])) {
|
||||
throw new \Exception('Invalid report data structure');
|
||||
}
|
||||
|
||||
// Debug: Log dealer names to identify problematic characters
|
||||
Log::info('Export data validation', [
|
||||
'data_count' => count($reportData['data']),
|
||||
'dealers_count' => count($reportData['dealers']),
|
||||
'dealer_names' => $reportData['dealers']->pluck('name')->toArray(),
|
||||
'first_data_row' => isset($reportData['data'][0]) ? array_keys($reportData['data'][0]) : []
|
||||
]);
|
||||
|
||||
$fileName = 'laporan_stok_produk_' . ($filterDate ?: date('Y-m-d')) . '.xlsx';
|
||||
|
||||
return Excel::download(new StockProductsExport($reportData), $fileName);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Export error: ' . $e->getMessage(), [
|
||||
'filter_date' => $request->get('filter_date'),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return back()->with('error', 'Gagal mengexport data: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot()
|
||||
{
|
||||
Carbon::setLocale('id');
|
||||
View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*', 'warehouse_management.*'], function ($view) {
|
||||
View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*', 'warehouse_management.*', 'reports.*', 'kpi.*'], function ($view) {
|
||||
$menuQuery = Menu::all();
|
||||
$menus = [];
|
||||
foreach($menuQuery as $menu) {
|
||||
|
||||
178
app/Services/StockReportService.php
Normal file
178
app/Services/StockReportService.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\Dealer;
|
||||
use App\Models\Stock;
|
||||
use App\Models\StockLog;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class StockReportService
|
||||
{
|
||||
/**
|
||||
* Get stock report data for all products and dealers on a specific date
|
||||
*/
|
||||
public function getStockReportData($targetDate = null)
|
||||
{
|
||||
$targetDate = $targetDate ? Carbon::parse($targetDate) : now();
|
||||
|
||||
// Get all dealers
|
||||
$dealers = Dealer::orderBy('name')->get();
|
||||
|
||||
// Get all active products
|
||||
$products = Product::where('active', true)
|
||||
->with(['category'])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$row = [
|
||||
'product_id' => $product->id,
|
||||
'product_code' => $product->code,
|
||||
'product_name' => $product->name,
|
||||
'category_name' => $product->category ? $product->category->name : '-',
|
||||
'unit' => $product->unit ?? '-',
|
||||
'total_stock' => 0
|
||||
];
|
||||
|
||||
// Calculate stock for each dealer on the target date
|
||||
foreach ($dealers as $dealer) {
|
||||
$stockOnDate = $this->getStockOnDate($product->id, $dealer->id, $targetDate);
|
||||
$row["dealer_{$dealer->id}"] = $stockOnDate;
|
||||
$row['total_stock'] += $stockOnDate;
|
||||
}
|
||||
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'dealers' => $dealers
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock quantity for a specific product and dealer on a given date
|
||||
*/
|
||||
public function getStockOnDate($productId, $dealerId, $targetDate)
|
||||
{
|
||||
// Get the latest stock log entry before or on the target date
|
||||
$latestStockLog = StockLog::whereHas('stock', function($query) use ($productId, $dealerId) {
|
||||
$query->where('product_id', $productId)
|
||||
->where('dealer_id', $dealerId);
|
||||
})
|
||||
->where('created_at', '<=', $targetDate->endOfDay())
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
|
||||
if ($latestStockLog) {
|
||||
// Return the new_quantity from the latest log entry
|
||||
return $latestStockLog->new_quantity;
|
||||
}
|
||||
|
||||
// If no stock log found, check if there's a current stock record
|
||||
$currentStock = Stock::where('product_id', $productId)
|
||||
->where('dealer_id', $dealerId)
|
||||
->first();
|
||||
|
||||
if ($currentStock) {
|
||||
// Check if the stock was created before or on the target date
|
||||
if ($currentStock->created_at <= $targetDate) {
|
||||
return $currentStock->quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// No stock data available for this date
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimized stock data using a single query approach
|
||||
*/
|
||||
public function getOptimizedStockReportData($targetDate = null)
|
||||
{
|
||||
$targetDate = $targetDate ? Carbon::parse($targetDate) : now();
|
||||
|
||||
// Get all dealers
|
||||
$dealers = Dealer::orderBy('name')->get();
|
||||
|
||||
// Get all active products with their stock data
|
||||
$products = Product::where('active', true)
|
||||
->with(['category', 'stocks.dealer'])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
$row = [
|
||||
'product_id' => $product->id,
|
||||
'product_code' => $product->code,
|
||||
'product_name' => $product->name,
|
||||
'category_name' => $product->category ? $product->category->name : '-',
|
||||
'unit' => $product->unit ?? '-',
|
||||
'total_stock' => 0
|
||||
];
|
||||
|
||||
// Calculate stock for each dealer on the target date
|
||||
foreach ($dealers as $dealer) {
|
||||
$stockOnDate = $this->getOptimizedStockOnDate($product->id, $dealer->id, $targetDate);
|
||||
$row["dealer_{$dealer->id}"] = $stockOnDate;
|
||||
$row['total_stock'] += $stockOnDate;
|
||||
}
|
||||
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'dealers' => $dealers
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized method to get stock on date using subquery
|
||||
*/
|
||||
private function getOptimizedStockOnDate($productId, $dealerId, $targetDate)
|
||||
{
|
||||
try {
|
||||
// Use a subquery to get the latest stock log entry efficiently
|
||||
$latestStockLog = DB::table('stock_logs')
|
||||
->join('stocks', 'stock_logs.stock_id', '=', 'stocks.id')
|
||||
->where('stocks.product_id', $productId)
|
||||
->where('stocks.dealer_id', $dealerId)
|
||||
->where('stock_logs.created_at', '<=', $targetDate->endOfDay())
|
||||
->orderBy('stock_logs.created_at', 'desc')
|
||||
->select('stock_logs.new_quantity')
|
||||
->first();
|
||||
|
||||
if ($latestStockLog) {
|
||||
return $latestStockLog->new_quantity;
|
||||
}
|
||||
|
||||
// If no stock log found, check current stock
|
||||
$currentStock = Stock::where('product_id', $productId)
|
||||
->where('dealer_id', $dealerId)
|
||||
->first();
|
||||
|
||||
if ($currentStock && $currentStock->created_at <= $targetDate) {
|
||||
return $currentStock->quantity;
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
// Log error and return 0
|
||||
Log::error('Error getting stock on date: ' . $e->getMessage(), [
|
||||
'product_id' => $productId,
|
||||
'dealer_id' => $dealerId,
|
||||
'target_date' => $targetDate
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user