pdv.backend/app/Http/Controllers/App/KardexController.php

362 lines
14 KiB
PHP

<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Inventory;
use App\Models\InventoryMovement;
use Illuminate\Http\Request;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Color;
use Carbon\Carbon;
/**
* Controlador para generación de Kardex (Excel)
*/
class KardexController extends Controller
{
/**
* Generar Kardex en Excel para un producto
*/
public function export(Request $request)
{
// 1. VALIDACIÓN
$request->validate([
'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'sometimes|nullable|exists:warehouses,id',
'fecha_inicio' => 'required|date',
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
'movement_type' => 'sometimes|nullable|in:entry,exit,transfer,sale,return',
]);
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
$inventory = Inventory::with(['category', 'price'])->findOrFail($request->inventory_id);
// 2. CONSULTA DE MOVIMIENTOS
$query = InventoryMovement::where('inventory_id', $inventory->id)
->with(['warehouseFrom', 'warehouseTo', 'user'])
->whereBetween('created_at', [$fechaInicio, $fechaFin])
->orderBy('created_at', 'asc')
->orderBy('id', 'asc');
$warehouseId = $request->warehouse_id;
if ($warehouseId) {
$query->where(function ($q) use ($warehouseId) {
$q->where('warehouse_from_id', $warehouseId)
->orWhere('warehouse_to_id', $warehouseId);
});
}
if ($request->movement_type) {
$query->where('movement_type', $request->movement_type);
}
$movements = $query->get();
// 3. CALCULAR SALDO INICIAL (movimientos previos al rango)
$initialBalance = $this->calculateInitialBalance($inventory->id, $fechaInicio, $warehouseId);
// 4. CONSTRUIR LÍNEAS DEL KARDEX
$balance = $initialBalance;
$kardexLines = [];
foreach ($movements as $movement) {
$effect = $this->calculateMovementEffect($movement, $warehouseId);
$entryQty = $effect > 0 ? $effect : 0;
$exitQty = $effect < 0 ? abs($effect) : 0;
$balance += $effect;
$kardexLines[] = [
'date' => $movement->created_at->format('d/m/Y H:i'),
'type' => $this->translateMovementType($movement->movement_type),
'warehouse_from' => $movement->warehouseFrom?->name ?? '-',
'warehouse_to' => $movement->warehouseTo?->name ?? '-',
'entry' => $entryQty,
'exit' => $exitQty,
'balance' => $balance,
'unit_cost' => $movement->unit_cost ?? 0,
'notes' => $movement->notes ?? '',
'invoice_reference' => $movement->invoice_reference ?? '',
'user' => $movement->user?->name ?? '',
];
}
if (empty($kardexLines)) {
return response()->json(['message' => 'No se encontraron movimientos en el periodo especificado'], 404);
}
// 5. GENERAR EXCEL
$fileName = 'Kardex_' . ($inventory->sku ?? $inventory->id) . '_' . $fechaInicio->format('Ymd') . '_' . $fechaFin->format('Ymd') . '.xlsx';
$filePath = storage_path('app/temp/' . $fileName);
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Kardex');
// Fuente global
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
// Estilos
$styleLabel = [
'font' => ['bold' => true, 'size' => 10],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
];
$styleBox = [
'borders' => ['outline' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
];
$styleTableHeader = [
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '2E75B6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
];
$lastCol = 'K';
// --- TÍTULO ---
$sheet->mergeCells("A2:{$lastCol}2");
$sheet->setCellValue('A2', 'KARDEX DE PRODUCTO');
$sheet->getStyle('A2')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '2E75B6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(2)->setRowHeight(30);
// --- INFORMACIÓN DEL PRODUCTO ---
$row = 4;
$sheet->setCellValue("A{$row}", 'PRODUCTO:');
$sheet->getStyle("A{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("B{$row}:E{$row}");
$sheet->setCellValue("B{$row}", $inventory->name);
$sheet->getStyle("B{$row}:E{$row}")->applyFromArray($styleBox);
$sheet->setCellValue("G{$row}", 'SKU:');
$sheet->getStyle("G{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("H{$row}:{$lastCol}{$row}");
$sheet->setCellValue("H{$row}", $inventory->sku ?? 'N/A');
$sheet->getStyle("H{$row}:{$lastCol}{$row}")->applyFromArray($styleBox);
$row++;
$sheet->setCellValue("A{$row}", 'CATEGORÍA:');
$sheet->getStyle("A{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("B{$row}:E{$row}");
$sheet->setCellValue("B{$row}", $inventory->category?->name ?? 'Sin categoría');
$sheet->getStyle("B{$row}:E{$row}")->applyFromArray($styleBox);
$sheet->setCellValue("G{$row}", 'COSTO ACTUAL:');
$sheet->getStyle("G{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("H{$row}:{$lastCol}{$row}");
$sheet->setCellValue("H{$row}", '$' . number_format($inventory->price?->cost ?? 0, 2));
$sheet->getStyle("H{$row}:{$lastCol}{$row}")->applyFromArray($styleBox);
// --- PERÍODO ---
$row++;
Carbon::setLocale('es');
if ($fechaInicio->format('m/Y') === $fechaFin->format('m/Y')) {
$periodoTexto = 'del ' . $fechaInicio->format('d') . ' al ' . $fechaFin->format('d') . ' de ' . $fechaFin->translatedFormat('F \d\e Y');
} else {
$periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y');
}
$sheet->setCellValue("A{$row}", 'PERÍODO:');
$sheet->getStyle("A{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("B{$row}:{$lastCol}{$row}");
$sheet->setCellValue("B{$row}", $periodoTexto);
$sheet->getStyle("B{$row}:{$lastCol}{$row}")->applyFromArray($styleBox);
$sheet->getStyle("B{$row}")->getFont()->setSize(11);
// --- SALDO INICIAL ---
$row++;
$sheet->setCellValue("A{$row}", 'SALDO INICIAL:');
$sheet->getStyle("A{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("B{$row}:C{$row}");
$sheet->setCellValue("B{$row}", $initialBalance);
$sheet->getStyle("B{$row}:C{$row}")->applyFromArray($styleBox);
$sheet->getStyle("B{$row}")->getFont()->setBold(true);
// --- ENCABEZADOS DE TABLA ---
$row += 2;
$headerRow = $row;
$headers = [
'A' => 'FECHA',
'B' => 'TIPO',
'C' => "ALMACÉN\nORIGEN",
'D' => "ALMACÉN\nDESTINO",
'E' => 'ENTRADA',
'F' => 'SALIDA',
'G' => 'SALDO',
'H' => "COSTO\nUNITARIO",
'I' => 'REFERENCIA',
'J' => 'NOTAS',
'K' => 'USUARIO',
];
foreach ($headers as $col => $text) {
$sheet->setCellValue("{$col}{$row}", $text);
}
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray($styleTableHeader);
$sheet->getRowDimension($row)->setRowHeight(30);
// --- DATOS ---
$row++;
$totalEntries = 0;
$totalExits = 0;
foreach ($kardexLines as $line) {
$sheet->setCellValue("A{$row}", $line['date']);
$sheet->setCellValue("B{$row}", $line['type']);
$sheet->setCellValue("C{$row}", $line['warehouse_from']);
$sheet->setCellValue("D{$row}", $line['warehouse_to']);
$sheet->setCellValue("E{$row}", $line['entry'] ?: '');
$sheet->setCellValue("F{$row}", $line['exit'] ?: '');
$sheet->setCellValue("G{$row}", $line['balance']);
$sheet->setCellValue("H{$row}", $line['unit_cost'] ? '$' . number_format($line['unit_cost'], 2) : '');
$sheet->setCellValue("I{$row}", $line['invoice_reference']);
$sheet->setCellValue("J{$row}", $line['notes']);
$sheet->setCellValue("K{$row}", $line['user']);
$totalEntries += $line['entry'];
$totalExits += $line['exit'];
// Estilo de fila
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10],
]);
// Color alterno
if (($row - $headerRow) % 2 === 0) {
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'D9E2F3']],
]);
}
// Resaltar entradas en verde y salidas en rojo
if ($line['entry'] > 0) {
$sheet->getStyle("E{$row}")->getFont()->setColor(new Color('006100'));
$sheet->getStyle("E{$row}")->getFont()->setBold(true);
}
if ($line['exit'] > 0) {
$sheet->getStyle("F{$row}")->getFont()->setColor(new Color('9C0006'));
$sheet->getStyle("F{$row}")->getFont()->setBold(true);
}
$row++;
}
// --- FILA DE TOTALES ---
$sheet->mergeCells("A{$row}:D{$row}");
$sheet->setCellValue("A{$row}", 'TOTALES');
$sheet->setCellValue("E{$row}", $totalEntries);
$sheet->setCellValue("F{$row}", $totalExits);
$sheet->setCellValue("G{$row}", $balance);
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'font' => ['bold' => true, 'size' => 10],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '2E75B6']],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setColor(new Color('FFFFFF'));
// --- ANCHOS DE COLUMNA ---
$sheet->getColumnDimension('A')->setWidth(18);
$sheet->getColumnDimension('B')->setWidth(14);
$sheet->getColumnDimension('C')->setWidth(18);
$sheet->getColumnDimension('D')->setWidth(18);
$sheet->getColumnDimension('E')->setWidth(12);
$sheet->getColumnDimension('F')->setWidth(12);
$sheet->getColumnDimension('G')->setWidth(12);
$sheet->getColumnDimension('H')->setWidth(14);
$sheet->getColumnDimension('I')->setWidth(18);
$sheet->getColumnDimension('J')->setWidth(25);
$sheet->getColumnDimension('K')->setWidth(16);
// 6. GUARDAR Y DESCARGAR
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
}
/**
* Calcular saldo inicial (movimientos previos al rango de fechas)
*/
private function calculateInitialBalance(int $inventoryId, Carbon $beforeDate, ?int $warehouseId): int
{
$query = InventoryMovement::where('inventory_id', $inventoryId)
->where('created_at', '<', $beforeDate)
->orderBy('created_at', 'asc')
->orderBy('id', 'asc');
if ($warehouseId) {
$query->where(function ($q) use ($warehouseId) {
$q->where('warehouse_from_id', $warehouseId)
->orWhere('warehouse_to_id', $warehouseId);
});
}
$balance = 0;
foreach ($query->get() as $movement) {
$balance += $this->calculateMovementEffect($movement, $warehouseId);
}
return $balance;
}
/**
* Calcular el efecto de un movimiento en el stock
*/
private function calculateMovementEffect(InventoryMovement $movement, ?int $warehouseId): int
{
// Sin filtro de almacén: stock global
if (!$warehouseId) {
return match ($movement->movement_type) {
'entry', 'return' => $movement->quantity,
'exit', 'sale' => -$movement->quantity,
'transfer' => 0,
default => 0,
};
}
// Con filtro de almacén: depende de la dirección
if ($movement->warehouse_to_id == $warehouseId && $movement->warehouse_from_id != $warehouseId) {
return $movement->quantity;
}
if ($movement->warehouse_from_id == $warehouseId && $movement->warehouse_to_id != $warehouseId) {
return -$movement->quantity;
}
return 0;
}
/**
* Traducir tipo de movimiento
*/
private function translateMovementType(string $type): string
{
return match ($type) {
'entry' => 'Entrada',
'exit' => 'Salida',
'transfer' => 'Traspaso',
'sale' => 'Venta',
'return' => 'Devolución',
default => $type,
};
}
}