Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da49a9a75a | ||
|
|
8eedb89172 | ||
| c7b28d9053 | |||
|
|
3384941cf5 | ||
|
|
920dcb2cc9 | ||
|
|
86ef5931e1 | ||
|
|
d7503304f1 | ||
|
|
2db5cafb21 | ||
|
|
232d9ccaa6 | ||
|
|
b253e8a308 | ||
|
|
19885a0aba | ||
|
|
ec33cf2c0e | ||
|
|
3d5198a65a | ||
| 48fe26899a | |||
|
|
f184e4d444 | ||
|
|
37f91d84f2 | ||
| 1c21602b7e | |||
| e5e3412fea | |||
|
|
bbc95f4ea2 | ||
| c2929d0b2f | |||
|
|
115a033510 | ||
| 6ea9665b0b | |||
|
|
5ad7b9ca72 | ||
| 897f59211d | |||
|
|
7a68c458b8 | ||
|
|
7ebca6456f | ||
|
|
da5acc11c5 | ||
|
|
c1d6f58697 | ||
|
|
aff2448356 | ||
| 562397402c | |||
|
|
41a84d05a0 | ||
| 7f6db1b83c | |||
|
|
6b76b94e62 | ||
| 3f4a03c9c5 | |||
| 516ad1cae6 | |||
|
|
9a78d92dbf | ||
| 5a646d84d5 | |||
|
|
3cac336e10 | ||
|
|
656492251b | ||
|
|
a0e8c70624 | ||
|
|
c68a16763c | ||
|
|
f2d2fd5aaf | ||
|
|
38e5050692 | ||
|
|
95154f4b28 | ||
|
|
c5d8e3c65b | ||
|
|
da92d0dc1a | ||
| 328ce7488f | |||
|
|
4357c560df | ||
|
|
5f0d4ec28e | ||
|
|
91f27c4e4a | ||
| b9e694f6ca | |||
|
|
2c8189ca59 | ||
|
|
eaad8a57df | ||
| c1473cdb95 | |||
|
|
810aff1b0e | ||
| 08871b8dde | |||
|
|
fa8bea2060 | ||
|
|
06425157b8 | ||
| 388ba3eff2 | |||
|
|
40bc2b5735 | ||
| d5665f0448 | |||
|
|
e37a8b117d | ||
|
|
07ee72e548 | ||
|
|
a3c45c5efc | ||
|
|
599bb68ce6 | ||
|
|
569fbd09d7 | ||
|
|
f47a551d46 |
@ -87,3 +87,8 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
|
||||
# WhatsApp API Configuracion
|
||||
WHATSAPP_API_URL=https://whatsapp.golsystems.mx/api/send-whatsapp
|
||||
WHATSAPP_ORG_ID=1
|
||||
WHATSAPP_TOKEN=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -24,3 +24,4 @@ yarn-error.log
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
CLAUDE.md
|
||||
|
||||
@ -45,7 +45,7 @@ server {
|
||||
|
||||
# Handle storage files (Laravel storage link)
|
||||
location /storage {
|
||||
alias /var/www/pdv.backend/storage/app;
|
||||
alias /var/www/pdv.backend/storage/app/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ class UserController extends Controller
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$users = User::orderBy('name');
|
||||
$users = User::orderBy('name')->where('id', '!=', 1);
|
||||
|
||||
QuerySupport::queryByKeys($users, ['name', 'email']);
|
||||
|
||||
|
||||
234
app/Http/Controllers/App/BillController.php
Normal file
234
app/Http/Controllers/App/BillController.php
Normal file
@ -0,0 +1,234 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Requests\App\BillStoreRequest;
|
||||
use App\Http\Requests\App\BillUpdateRequest;
|
||||
use App\Models\Bill;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
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 Carbon\Carbon;
|
||||
|
||||
class BillController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$query = Bill::query()->with('supplier');
|
||||
|
||||
if (request()->filled('q')) {
|
||||
$query->where('name', 'like', '%' . request()->q . '%');
|
||||
}
|
||||
|
||||
$bills = $query->orderByDesc('created_at')
|
||||
->paginate(config('app.pagination', 15));
|
||||
|
||||
return ApiResponse::OK->response(['bills' => $bills]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(BillStoreRequest $request)
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
$data['file_path'] = $request->file('file')->store('bills', 'public');
|
||||
}
|
||||
|
||||
unset($data['file']);
|
||||
|
||||
$bill = Bill::create($data);
|
||||
|
||||
return ApiResponse::CREATED->response(['bill' => $bill]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Bill $bill)
|
||||
{
|
||||
return ApiResponse::OK->response(['bill' => $bill]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(BillUpdateRequest $request, Bill $bill)
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
if ($bill->file_path) {
|
||||
Storage::disk('public')->delete($bill->file_path);
|
||||
}
|
||||
$data['file_path'] = $request->file('file')->store('bills', 'public');
|
||||
}
|
||||
|
||||
unset($data['file']);
|
||||
|
||||
$bill->update($data);
|
||||
|
||||
return ApiResponse::OK->response(['bill' => $bill->fresh()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Bill $bill)
|
||||
{
|
||||
if ($bill->file_path) {
|
||||
Storage::disk('public')->delete($bill->file_path);
|
||||
}
|
||||
|
||||
$bill->delete();
|
||||
|
||||
return ApiResponse::OK->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alterna el estado de pago de la factura.
|
||||
*/
|
||||
public function togglePaid(Bill $bill)
|
||||
{
|
||||
$bill->update(['paid' => !$bill->paid]);
|
||||
|
||||
return ApiResponse::OK->response(['bill' => $bill->fresh()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporta a Excel las facturas pendientes de pago.
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
$bills = Bill::with('supplier')->where('paid', false)
|
||||
->orderBy('deadline')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Facturas por Pagar');
|
||||
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial')->setSize(10);
|
||||
|
||||
$styleHeader = [
|
||||
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
];
|
||||
|
||||
$styleData = [
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D0D0D0']]],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
];
|
||||
|
||||
// Título
|
||||
$sheet->mergeCells('A1:F1');
|
||||
$sheet->setCellValue('A1', 'FACTURAS PENDIENTES DE PAGO');
|
||||
$sheet->getStyle('A1')->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 14],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||
]);
|
||||
|
||||
$sheet->mergeCells('A2:F2');
|
||||
$sheet->setCellValue('A2', 'Generado el ' . Carbon::now()->format('d/m/Y H:i'));
|
||||
$sheet->getStyle('A2')->applyFromArray([
|
||||
'font' => ['italic' => true, 'color' => ['rgb' => '666666']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
|
||||
]);
|
||||
|
||||
// Encabezados
|
||||
$headers = ['A4' => '#', 'B4' => 'NOMBRE', 'C4' => 'PROVEEDOR', 'D4' => 'COSTO', 'E4' => 'FECHA LÍMITE', 'F4' => 'DÍAS RESTANTES'];
|
||||
foreach ($headers as $cell => $text) {
|
||||
$sheet->setCellValue($cell, $text);
|
||||
}
|
||||
$sheet->getStyle('A4:F4')->applyFromArray($styleHeader);
|
||||
$sheet->getRowDimension(4)->setRowHeight(22);
|
||||
|
||||
// Datos
|
||||
$row = 5;
|
||||
$total = 0;
|
||||
foreach ($bills as $i => $bill) {
|
||||
$deadline = $bill->deadline ? Carbon::parse($bill->deadline) : null;
|
||||
// Positivo = días que faltan, negativo = días vencida
|
||||
$daysLeft = $deadline ? (int) Carbon::today()->diffInDays($deadline, false) : null;
|
||||
|
||||
if ($daysLeft === null) {
|
||||
$daysLabel = '—';
|
||||
} elseif ($daysLeft === 0) {
|
||||
$daysLabel = 'Hoy';
|
||||
} elseif ($daysLeft > 0) {
|
||||
$daysLabel = "+{$daysLeft}";
|
||||
} else {
|
||||
$daysLabel = (string) $daysLeft; // ya incluye el signo negativo
|
||||
}
|
||||
|
||||
$sheet->setCellValue('A' . $row, $i + 1);
|
||||
$sheet->setCellValue('B' . $row, $bill->name);
|
||||
$sheet->setCellValue('C' . $row, $bill->supplier?->business_name ?? '—');
|
||||
$sheet->setCellValue('D' . $row, (float) $bill->cost);
|
||||
$sheet->setCellValue('E' . $row, $deadline?->format('d/m/Y') ?? '—');
|
||||
$sheet->setCellValue('F' . $row, $daysLabel);
|
||||
|
||||
$sheet->getStyle('D' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
$sheet->getStyle('A' . $row . ':F' . $row)->applyFromArray($styleData);
|
||||
$sheet->getStyle('A' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('D' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle('E' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle('F' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
// Verde si quedan días, rojo si está vencida
|
||||
if ($daysLeft !== null && $daysLeft > 0) {
|
||||
$sheet->getStyle('F' . $row)->getFont()->getColor()->setRGB('1A7A1A');
|
||||
} elseif ($daysLeft !== null && $daysLeft < 0) {
|
||||
$sheet->getStyle('E' . $row . ':F' . $row)->getFont()->getColor()->setRGB('CC0000');
|
||||
$sheet->getStyle('E' . $row . ':F' . $row)->getFont()->setBold(true);
|
||||
} elseif ($daysLeft === 0) {
|
||||
$sheet->getStyle('F' . $row)->getFont()->getColor()->setRGB('B45309');
|
||||
$sheet->getStyle('F' . $row)->getFont()->setBold(true);
|
||||
}
|
||||
|
||||
$total += (float) $bill->cost;
|
||||
$row++;
|
||||
}
|
||||
|
||||
// Total
|
||||
$sheet->setCellValue('C' . $row, 'TOTAL PENDIENTE');
|
||||
$sheet->setCellValue('D' . $row, $total);
|
||||
$sheet->getStyle('D' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
$sheet->getStyle('C' . $row . ':D' . $row)->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 11],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FFF2CC']],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT],
|
||||
]);
|
||||
|
||||
// Anchos de columna
|
||||
$sheet->getColumnDimension('A')->setWidth(6);
|
||||
$sheet->getColumnDimension('B')->setWidth(36);
|
||||
$sheet->getColumnDimension('C')->setWidth(30);
|
||||
$sheet->getColumnDimension('D')->setWidth(18);
|
||||
$sheet->getColumnDimension('E')->setWidth(16);
|
||||
$sheet->getColumnDimension('F')->setWidth(16);
|
||||
|
||||
// Generar archivo
|
||||
$fileName = 'Facturas_Pendientes_' . Carbon::now()->format('Ymd_His') . '.xlsx';
|
||||
$filePath = storage_path('app/temp/' . $fileName);
|
||||
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
|
||||
|
||||
(new Xlsx($spreadsheet))->save($filePath);
|
||||
|
||||
return response()->download($filePath, $fileName, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
])->deleteFileAfterSend(true);
|
||||
}
|
||||
}
|
||||
140
app/Http/Controllers/App/BundleController.php
Normal file
140
app/Http/Controllers/App/BundleController.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Bundle;
|
||||
use App\Services\BundleService;
|
||||
use App\Http\Requests\App\BundleStoreRequest;
|
||||
use App\Http\Requests\App\BundleUpdateRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class BundleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BundleService $bundleService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Listar todos los bundles activos
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$bundles = Bundle::with(['items.inventory.price', 'price'])
|
||||
->when($request->has('q'), function ($query) use ($request) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(config('app.pagination', 15));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'bundles' => $bundles,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver detalle de un bundle
|
||||
*/
|
||||
public function show(Bundle $bundle)
|
||||
{
|
||||
$bundle->load(['items.inventory.price', 'price']);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $bundle,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear un nuevo bundle
|
||||
*/
|
||||
public function store(BundleStoreRequest $request)
|
||||
{
|
||||
try {
|
||||
$bundle = $this->bundleService->createBundle($request->validated());
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'model' => $bundle,
|
||||
'message' => 'Bundle creado exitosamente',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al crear el bundle: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un bundle existente
|
||||
*/
|
||||
public function update(BundleUpdateRequest $request, Bundle $bundle)
|
||||
{
|
||||
try {
|
||||
$updatedBundle = $this->bundleService->updateBundle($bundle, $request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $updatedBundle,
|
||||
'message' => 'Bundle actualizado exitosamente',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al actualizar el bundle: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar (soft delete) un bundle
|
||||
*/
|
||||
public function destroy(Bundle $bundle)
|
||||
{
|
||||
try {
|
||||
$this->bundleService->deleteBundle($bundle);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Bundle eliminado exitosamente',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al eliminar el bundle: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar stock disponible de un bundle
|
||||
*/
|
||||
public function checkStock(Request $request, Bundle $bundle)
|
||||
{
|
||||
$quantity = $request->input('quantity', 1);
|
||||
$warehouseId = $request->input('warehouse_id');
|
||||
|
||||
$bundle->load(['items.inventory']);
|
||||
|
||||
$availableStock = $warehouseId
|
||||
? $bundle->stockInWarehouse($warehouseId)
|
||||
: $bundle->available_stock;
|
||||
|
||||
$hasStock = $bundle->hasStock($quantity, $warehouseId);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'bundle_id' => $bundle->id,
|
||||
'bundle_name' => $bundle->name,
|
||||
'quantity_requested' => $quantity,
|
||||
'available_stock' => $availableStock,
|
||||
'has_stock' => $hasStock,
|
||||
'components_stock' => $bundle->items->map(function ($item) use ($warehouseId) {
|
||||
return [
|
||||
'inventory_id' => $item->inventory_id,
|
||||
'product_name' => $item->inventory->name,
|
||||
'required_quantity' => $item->quantity,
|
||||
'available_stock' => $warehouseId
|
||||
? $item->inventory->stockInWarehouse($warehouseId)
|
||||
: $item->inventory->stock,
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
139
app/Http/Controllers/App/CashRegisterController.php
Normal file
139
app/Http/Controllers/App/CashRegisterController.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CashRegister;
|
||||
use App\Services\CashRegisterService;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class CashRegisterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CashRegisterService $cashRegisterService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Listar cortes de caja
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = CashRegister::with('user')->orderBy('opened_at', 'desc');
|
||||
|
||||
// Filtro por rango de fechas
|
||||
if ($request->has('from') && $request->has('to')) {
|
||||
$query->whereBetween('opened_at', [$request->from, $request->to]);
|
||||
}
|
||||
|
||||
// Filtro por estado
|
||||
if ($request->has('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$registers = $query->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'registers' => $registers
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver caja actual del usuario
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
$register = CashRegister::where('user_id', auth()->id())
|
||||
->where('status', 'open')
|
||||
->with(['user', 'sales'])
|
||||
->first();
|
||||
|
||||
if (!$register) {
|
||||
return ApiResponse::OK->response([
|
||||
'register' => null,
|
||||
'message' => 'No tienes una caja abierta.'
|
||||
]);
|
||||
}
|
||||
|
||||
$service = new CashRegisterService();
|
||||
$summary = $service->getCurrentSummary($register);
|
||||
|
||||
return ApiResponse::OK->response(['register' => $summary ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver detalle de un corte específico
|
||||
*/
|
||||
public function show(CashRegister $register)
|
||||
{
|
||||
$register->load(['user', 'sales.details']);
|
||||
|
||||
$summary = [
|
||||
'register' => $register,
|
||||
'payment_summary' => $register->getTotalsByPaymentMethod(),
|
||||
];
|
||||
|
||||
return ApiResponse::OK->response($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abrir caja
|
||||
*/
|
||||
public function open(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'initial_cash' => ['required', 'numeric', 'min:0'],
|
||||
], [
|
||||
'initial_cash.required' => 'El efectivo inicial es obligatorio.',
|
||||
'initial_cash.numeric' => 'El efectivo inicial debe ser un número.',
|
||||
'initial_cash.min' => 'El efectivo inicial no puede ser negativo.',
|
||||
]);
|
||||
|
||||
try {
|
||||
$register = $this->cashRegisterService->openRegister([
|
||||
'user_id' => auth()->id(),
|
||||
'initial_cash' => $request->initial_cash,
|
||||
]);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'model' => $register,
|
||||
'message' => 'Caja abierta exitosamente.'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cerrar caja
|
||||
*/
|
||||
public function close(CashRegister $register, Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'final_cash' => ['required', 'numeric', 'min:0'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
], [
|
||||
'final_cash.required' => 'El efectivo final es obligatorio.',
|
||||
'final_cash.numeric' => 'El efectivo final debe ser un número.',
|
||||
'final_cash.min' => 'El efectivo final no puede ser negativo.',
|
||||
]);
|
||||
|
||||
try {
|
||||
$closedRegister = $this->cashRegisterService->closeRegister($register, [
|
||||
'final_cash' => $request->final_cash,
|
||||
'notes' => $request->notes,
|
||||
]);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $closedRegister,
|
||||
'message' => 'Caja cerrada exitosamente.'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/App/CategoryController.php
Normal file
63
app/Http/Controllers/App/CategoryController.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\CategoryStoreRequest;
|
||||
use App\Http\Requests\App\CategoryUpdateRequest;
|
||||
use App\Models\Category;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$categorias = Category::with(['subcategories' => fn ($q) => $q->where('is_active', true)->orderBy('name')])
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'categories' => $categorias
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Category $categoria)
|
||||
{
|
||||
$categoria->load(['subcategories' => fn ($q) => $q->where('is_active', true)->orderBy('name')]);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $categoria
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CategoryStoreRequest $request)
|
||||
{
|
||||
$categoria = Category::create($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $categoria
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(CategoryUpdateRequest $request, Category $categoria)
|
||||
{
|
||||
$categoria->update($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $categoria->fresh()
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Category $categoria)
|
||||
{
|
||||
if ($categoria->inventories()->exists()) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'No se puede eliminar la clasificación porque tiene productos asociados.'
|
||||
]);
|
||||
}
|
||||
|
||||
$categoria->delete();
|
||||
|
||||
return ApiResponse::OK->response();
|
||||
}
|
||||
}
|
||||
245
app/Http/Controllers/App/ClientController.php
Normal file
245
app/Http/Controllers/App/ClientController.php
Normal file
@ -0,0 +1,245 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Client;
|
||||
use App\Services\ClientTierService;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
protected ClientTierService $clientTierService;
|
||||
|
||||
public function __construct(ClientTierService $clientTierService)
|
||||
{
|
||||
$this->clientTierService = $clientTierService;
|
||||
}
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Client::query();
|
||||
|
||||
if ($request->has('with')) {
|
||||
$relations = explode(',', $request->with);
|
||||
$query->with($relations);
|
||||
}
|
||||
|
||||
if ($request->has('client_number') && $request->client_number) {
|
||||
$query->where('client_number', 'like', "%{$request->client_number}%");
|
||||
}
|
||||
|
||||
elseif ($request->has('q') && $request->q) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('email', 'like', "%{$request->q}%")
|
||||
->orWhere('rfc', 'like', "%{$request->q}%");
|
||||
});
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'clients' => $query->paginate(config('app.pagination')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Client $client)
|
||||
{
|
||||
return ApiResponse::OK->response([
|
||||
'client' => $client
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'address' => 'nullable|string|max:500',
|
||||
'rfc' => 'required|string|max:13',
|
||||
'razon_social' => 'nullable|string|max:255',
|
||||
'regimen_fiscal' => 'nullable|string|max:100',
|
||||
'cp_fiscal' => 'nullable|string|max:5',
|
||||
'uso_cfdi' => 'nullable|string|max:100',
|
||||
],[
|
||||
'email.unique' => 'El correo electrónico ya está en uso por otro cliente.',
|
||||
'phone.unique' => 'El teléfono ya está en uso por otro cliente.',
|
||||
'rfc.unique' => 'El RFC ya está en uso por otro cliente.',
|
||||
'rfc.required' => 'El RFC es obligatorio.',
|
||||
]);
|
||||
|
||||
try{
|
||||
$data = $request->only([
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'rfc',
|
||||
'razon_social',
|
||||
'regimen_fiscal',
|
||||
'cp_fiscal',
|
||||
'uso_cfdi',
|
||||
]);
|
||||
|
||||
// Usar RFC como client_number
|
||||
$data['client_number'] = $data['rfc'] ?? null;
|
||||
|
||||
$client = Client::create($data);
|
||||
|
||||
// Cargar relación tier
|
||||
$client->load('tier');
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'client' => $client,
|
||||
'message' => 'Cliente creado correctamente.'
|
||||
]);
|
||||
|
||||
}catch(\Exception $e){
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error al crear el cliente.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, Client $client)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'address' => 'nullable|string|max:500',
|
||||
'rfc' => 'nullable|string|max:13',
|
||||
],[
|
||||
'email.unique' => 'El correo electrónico ya está en uso por otro cliente.',
|
||||
'phone.unique' => 'El teléfono ya está en uso por otro cliente.',
|
||||
'rfc.unique' => 'El RFC ya está en uso por otro cliente.',
|
||||
]);
|
||||
|
||||
try{
|
||||
|
||||
$data = $request->only([
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'rfc',
|
||||
'razon_social',
|
||||
'regimen_fiscal',
|
||||
'cp_fiscal',
|
||||
'uso_cfdi',
|
||||
]);
|
||||
|
||||
// Mantener client_number sincronizado con RFC cuando se actualiza
|
||||
if ($request->filled('rfc')) {
|
||||
$data['client_number'] = $data['rfc'];
|
||||
}
|
||||
|
||||
$client->update($data);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'client' => $client,
|
||||
'message' => 'Cliente actualizado correctamente.'
|
||||
]);
|
||||
|
||||
}catch(\Exception $e){
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error al actualizar el cliente.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Client $client)
|
||||
{
|
||||
try{
|
||||
$client->delete();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Cliente eliminado correctamente.'
|
||||
]);
|
||||
|
||||
}catch(\Exception $e){
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error al eliminar el cliente.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas de compra del cliente
|
||||
*/
|
||||
public function stats(Client $client)
|
||||
{
|
||||
try {
|
||||
$stats = $this->clientTierService->getClientStats($client);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'stats' => $stats
|
||||
]);
|
||||
} catch(\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error al obtener estadísticas del cliente.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener historial de cambios de tier del cliente
|
||||
*/
|
||||
public function tierHistory(Client $client)
|
||||
{
|
||||
try {
|
||||
$history = $client->tierHistory()
|
||||
->with(['oldTier', 'newTier'])
|
||||
->orderBy('changed_at', 'desc')
|
||||
->get();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'history' => $history
|
||||
]);
|
||||
} catch(\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error al obtener historial del cliente.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener ventas con descuento de un cliente
|
||||
*/
|
||||
public function salesWithDiscounts(Client $client, Request $request)
|
||||
{
|
||||
try {
|
||||
$query = $client->sales()
|
||||
->where('discount_amount', '>', 0)
|
||||
->with(['details', 'user:id,name'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
// Filtro por rango de fechas
|
||||
if ($request->has('date_from')) {
|
||||
$query->where('created_at', '>=', $request->date_from);
|
||||
}
|
||||
|
||||
if ($request->has('date_to')) {
|
||||
$query->where('created_at', '<=', $request->date_to);
|
||||
}
|
||||
|
||||
$sales = $query->paginate($request->per_page ?? 15);
|
||||
|
||||
// Calcular totales
|
||||
$totals = [
|
||||
'total_sales' => $client->sales()->where('discount_amount', '>', 0)->count(),
|
||||
'total_amount' => $client->sales()->where('discount_amount', '>', 0)->sum('total'),
|
||||
'total_discounts' => $client->sales()->where('discount_amount', '>', 0)->sum('discount_amount'),
|
||||
];
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'sales' => $sales,
|
||||
'totals' => $totals
|
||||
]);
|
||||
} catch(\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error al obtener ventas con descuentos.',
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/App/ClientTierController.php
Normal file
110
app/Http/Controllers/App/ClientTierController.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Models\ClientTier;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ClientTiers\ClientTierStoreRequest;
|
||||
use App\Http\Requests\ClientTiers\ClientTierUpdateRequest;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class ClientTierController extends Controller
|
||||
{
|
||||
/**
|
||||
* Listar todos los tiers
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$tiers = ClientTier::withCount('clients')
|
||||
->orderBy('min_purchase_amount')
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tiers' => $tiers
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Almacenar un nuevo tier
|
||||
*/
|
||||
public function store(ClientTierStoreRequest $request)
|
||||
{
|
||||
$tier = ClientTier::create($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier,
|
||||
'message' => 'Tier creado correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostrar un tier específico
|
||||
*/
|
||||
public function show(ClientTier $tier)
|
||||
{
|
||||
$tier->loadCount('clients');
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un tier existente
|
||||
*/
|
||||
public function update(ClientTierUpdateRequest $request, ClientTier $tier)
|
||||
{
|
||||
$tier->update($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier,
|
||||
'message' => 'Tier actualizado correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar un tier
|
||||
*/
|
||||
public function destroy(ClientTier $tier)
|
||||
{
|
||||
// Verificar si el tier tiene clientes asignados
|
||||
if ($tier->clients()->count() > 0) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'No se puede eliminar el tier porque tiene clientes asignados.'
|
||||
]);
|
||||
}
|
||||
|
||||
$tier->delete();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Tier eliminado correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activar/Desactivar un tier
|
||||
*/
|
||||
public function toggleActive(ClientTier $tier)
|
||||
{
|
||||
$tier->update(['is_active' => !$tier->is_active]);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier,
|
||||
'message' => 'Estado del tier actualizado correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seleccionar tiers activos
|
||||
*/
|
||||
public function active()
|
||||
{
|
||||
$tiers = ClientTier::active()
|
||||
->orderBy('min_purchase_amount')
|
||||
->get();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tiers' => $tiers
|
||||
]);
|
||||
}
|
||||
}
|
||||
818
app/Http/Controllers/App/ExcelController.php
Normal file
818
app/Http/Controllers/App/ExcelController.php
Normal file
@ -0,0 +1,818 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Client;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
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 Carbon\Carbon;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
|
||||
class ExcelController extends Controller
|
||||
{
|
||||
/**
|
||||
* Generar reporte Excel de descuentos a clientes
|
||||
*/
|
||||
public function clientDiscountsReport(Request $request)
|
||||
{
|
||||
// 1. VALIDACIÓN Y OBTENCIÓN DE DATOS
|
||||
$request->validate([
|
||||
'fecha_inicio' => 'required|date',
|
||||
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
|
||||
'tier_id' => 'nullable|exists:client_tiers,id',
|
||||
]);
|
||||
|
||||
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
|
||||
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
|
||||
$tierId = $request->tier_id;
|
||||
|
||||
// 2. OBTENER DATOS DE CLIENTES CON DESCUENTOS
|
||||
$clients = Client::whereNotNull('tier_id')
|
||||
->with('tier:id,tier_name,discount_percentage')
|
||||
->whereHas('sales', function($q) use ($fechaInicio, $fechaFin) {
|
||||
$q->whereBetween('created_at', [$fechaInicio, $fechaFin]);
|
||||
})
|
||||
->when($tierId, function($query) use ($tierId) {
|
||||
$query->where('tier_id', $tierId);
|
||||
})
|
||||
->get();
|
||||
|
||||
if ($clients->isEmpty()) {
|
||||
return response()->json(['message' => 'No se encontraron registros en el periodo especificado'], 404);
|
||||
}
|
||||
|
||||
// 3. MAPEO DE DATOS
|
||||
$data = $clients->map(function($client) use ($fechaInicio, $fechaFin) {
|
||||
$sales = $client->sales()
|
||||
->whereBetween('created_at', [$fechaInicio, $fechaFin])
|
||||
->get();
|
||||
|
||||
return [
|
||||
'numero' => $client->client_number,
|
||||
'nombre' => $client->name,
|
||||
'email' => $client->email ?? 'N/A',
|
||||
'telefono' => $client->phone ?? 'N/A',
|
||||
'tier' => $client->tier?->tier_name ?? 'N/A',
|
||||
'descuento_porcentaje' => $client->tier?->discount_percentage ?? 0,
|
||||
'total_compras' => $client->total_purchases,
|
||||
'ventas_con_descuento' => $sales->count(),
|
||||
'descuentos_recibidos' => $sales->sum('discount_amount'),
|
||||
'promedio_descuento' => $sales->count() > 0 ? $sales->avg('discount_amount') : 0,
|
||||
];
|
||||
});
|
||||
|
||||
// 4. CONFIGURACIÓN EXCEL Y ESTILOS
|
||||
$fileName = 'Reporte_Descuentos_Clientes_' . $fechaInicio->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();
|
||||
|
||||
// Fuente Global
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setSize(10);
|
||||
|
||||
// Estilos Comunes
|
||||
$styleBox = [
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '000000']]],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER]
|
||||
];
|
||||
$styleLabel = [
|
||||
'font' => ['size' => 12, 'bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER]
|
||||
];
|
||||
$styleTableHeader = [
|
||||
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]
|
||||
];
|
||||
|
||||
// --- ESTRUCTURA DEL DOCUMENTO ---
|
||||
$sheet->getRowDimension(2)->setRowHeight(10);
|
||||
$sheet->getRowDimension(3)->setRowHeight(25);
|
||||
$sheet->getRowDimension(5)->setRowHeight(30);
|
||||
|
||||
// --- TÍTULO PRINCIPAL ---
|
||||
$sheet->mergeCells('A3:J3');
|
||||
$sheet->setCellValue('A3', 'REPORTE DE DESCUENTOS A CLIENTES');
|
||||
$sheet->getStyle('A3')->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
|
||||
// --- INFORMACIÓN DEL PERIODO ---
|
||||
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->mergeCells('A5:B5');
|
||||
$sheet->setCellValue('A5', 'PERÍODO:');
|
||||
$sheet->getStyle('A5')->applyFromArray($styleLabel);
|
||||
|
||||
$sheet->mergeCells('C5:J5');
|
||||
$sheet->setCellValue('C5', $periodoTexto);
|
||||
$sheet->getStyle('C5:J5')->applyFromArray($styleBox);
|
||||
$sheet->getStyle('C5')->getFont()->setSize(12);
|
||||
|
||||
// --- RESUMEN DE TOTALES ---
|
||||
$totalClientes = $data->count();
|
||||
$totalVentas = $data->sum('ventas_con_descuento');
|
||||
$totalDescuentos = $data->sum('descuentos_recibidos');
|
||||
|
||||
$row = 7;
|
||||
$sheet->setCellValue('A' . $row, 'TOTAL CLIENTES CON DESCUENTOS:');
|
||||
$sheet->setCellValueExplicit('C' . $row, (int) $totalClientes, DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValue('E' . $row, 'TOTAL VENTAS:');
|
||||
$sheet->setCellValueExplicit('G' . $row, (int) $totalVentas, DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValue('I' . $row, 'TOTAL DESCUENTOS:');
|
||||
$sheet->setCellValueExplicit('J' . $row, (float) $totalDescuentos, DataType::TYPE_NUMERIC);
|
||||
|
||||
// Aplicar formato moneda
|
||||
$sheet->getStyle('G' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
$sheet->getStyle('J' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
|
||||
$sheet->getStyle('A' . $row . ':J' . $row)->getFont()->setBold(true);
|
||||
$sheet->getStyle('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4');
|
||||
$sheet->getStyle('G' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4');
|
||||
$sheet->getStyle('J' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000');
|
||||
|
||||
// --- ENCABEZADOS DE TABLA ---
|
||||
$h = 9;
|
||||
$headers = [
|
||||
'A' => 'No.',
|
||||
'B' => "NÚMERO\nCLIENTE",
|
||||
'C' => 'NOMBRE',
|
||||
'D' => 'EMAIL',
|
||||
'E' => 'TELÉFONO',
|
||||
'F' => 'NIVEL',
|
||||
'G' => "DESCUENTO\n(%)",
|
||||
'H' => "VENTAS CON\nDESCUENTO",
|
||||
'I' => "DESCUENTOS\nRECIBIDOS",
|
||||
'J' => "PROMEDIO\nDESCUENTO"
|
||||
];
|
||||
|
||||
foreach ($headers as $col => $text) {
|
||||
$sheet->setCellValue("{$col}{$h}", $text);
|
||||
}
|
||||
|
||||
$sheet->getStyle("A{$h}:J{$h}")->applyFromArray($styleTableHeader);
|
||||
$sheet->getRowDimension($h)->setRowHeight(35);
|
||||
|
||||
// --- LLENADO DE DATOS ---
|
||||
$row = 10;
|
||||
$i = 1;
|
||||
|
||||
foreach ($data as $item) {
|
||||
$sheet->setCellValue('A' . $row, $i);
|
||||
$sheet->setCellValue('B' . $row, $item['numero']);
|
||||
$sheet->setCellValue('C' . $row, $item['nombre']);
|
||||
$sheet->setCellValue('D' . $row, $item['email']);
|
||||
$sheet->setCellValue('E' . $row, $item['telefono']);
|
||||
$sheet->setCellValue('F' . $row, $item['tier']);
|
||||
$sheet->setCellValue('G' . $row, $item['descuento_porcentaje'] . '%');
|
||||
$sheet->setCellValue('H' . $row, $item['ventas_con_descuento']);
|
||||
$sheet->setCellValue('I' . $row, '$' . number_format($item['descuentos_recibidos'], 2));
|
||||
$sheet->setCellValue('J' . $row, '$' . number_format($item['promedio_descuento'], 2));
|
||||
|
||||
// Estilos de fila
|
||||
$sheet->getStyle("A{$row}:J{$row}")->applyFromArray([
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
'font' => ['size' => 10]
|
||||
]);
|
||||
|
||||
// Centrados
|
||||
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("G{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
// Color alterno de filas
|
||||
if ($i % 2 == 0) {
|
||||
$sheet->getStyle("A{$row}:J{$row}")->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setRGB('F2F2F2');
|
||||
}
|
||||
|
||||
$row++;
|
||||
$i++;
|
||||
}
|
||||
|
||||
// --- ANCHOS DE COLUMNA ---
|
||||
$sheet->getColumnDimension('A')->setWidth(5);
|
||||
$sheet->getColumnDimension('B')->setWidth(15);
|
||||
$sheet->getColumnDimension('C')->setWidth(25);
|
||||
$sheet->getColumnDimension('D')->setWidth(25);
|
||||
$sheet->getColumnDimension('E')->setWidth(15);
|
||||
$sheet->getColumnDimension('F')->setWidth(12);
|
||||
$sheet->getColumnDimension('G')->setWidth(12);
|
||||
$sheet->getColumnDimension('H')->setWidth(15);
|
||||
$sheet->getColumnDimension('I')->setWidth(18);
|
||||
$sheet->getColumnDimension('J')->setWidth(18);
|
||||
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($filePath);
|
||||
|
||||
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar reporte Excel de ventas
|
||||
*/
|
||||
public function salesReport(Request $request)
|
||||
{
|
||||
// 1. VALIDACIÓN
|
||||
$request->validate([
|
||||
'fecha_inicio' => 'required|date',
|
||||
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
|
||||
'client_id' => 'nullable|exists:clients,id',
|
||||
'user_id' => 'nullable|exists:users,id',
|
||||
'status' => 'nullable|in:completed,cancelled',
|
||||
]);
|
||||
|
||||
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
|
||||
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
|
||||
|
||||
// 2. CONSULTA DE VENTAS
|
||||
$sales = Sale::with(['client:id,name,client_number,rfc', 'user:id,name', 'details'])
|
||||
->whereBetween('created_at', [$fechaInicio, $fechaFin])
|
||||
->when($request->client_id, function($query) use ($request) {
|
||||
$query->where('client_id', $request->client_id);
|
||||
})
|
||||
->when($request->user_id, function($query) use ($request) {
|
||||
$query->where('user_id', $request->user_id);
|
||||
})
|
||||
->when($request->status, function($query) use ($request) {
|
||||
$query->where('status', $request->status);
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($sales->isEmpty()) {
|
||||
return response()->json(['message' => 'No se encontraron ventas en el periodo especificado'], 404);
|
||||
}
|
||||
|
||||
// 3. MAPEO DE DATOS
|
||||
$data = $sales->map(function($sale) {
|
||||
return [
|
||||
'folio' => $sale->invoice_number,
|
||||
'fecha' => $sale->created_at->format('d/m/Y H:i'),
|
||||
'cliente' => $sale->client?->name ?? 'N/A',
|
||||
'rfc_cliente' => $sale->client?->rfc ?? 'N/A',
|
||||
'vendedor' => $sale->user?->name ?? 'N/A',
|
||||
'subtotal' => (float) $sale->subtotal,
|
||||
'iva' => (float) $sale->tax,
|
||||
'descuento' => (float) ($sale->discount_amount ?? 0),
|
||||
'total' => (float) $sale->total,
|
||||
'metodo_pago' => $this->translatePaymentMethod($sale->payment_method),
|
||||
'status' => $sale->status === 'completed' ? 'Completada' : 'Cancelada',
|
||||
'productos' => $sale->details->count(),
|
||||
];
|
||||
});
|
||||
|
||||
// 4. CONFIGURACIÓN EXCEL
|
||||
$fileName = 'Reporte_Ventas_' . $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();
|
||||
|
||||
// Fuente Global
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setSize(10);
|
||||
|
||||
// Estilos Comunes
|
||||
$styleBox = [
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '000000']]],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER]
|
||||
];
|
||||
$styleLabel = [
|
||||
'font' => ['size' => 12, 'bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, '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]]
|
||||
];
|
||||
|
||||
// --- ESTRUCTURA DEL DOCUMENTO ---
|
||||
$lastCol = 'L';
|
||||
$sheet->getRowDimension(2)->setRowHeight(10);
|
||||
$sheet->getRowDimension(3)->setRowHeight(25);
|
||||
$sheet->getRowDimension(5)->setRowHeight(30);
|
||||
|
||||
// --- TÍTULO PRINCIPAL ---
|
||||
$sheet->mergeCells("A3:{$lastCol}3");
|
||||
$sheet->setCellValue('A3', 'REPORTE DE VENTAS');
|
||||
$sheet->getStyle('A3')->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
|
||||
// --- INFORMACIÓN DEL PERIODO ---
|
||||
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->mergeCells('A5:B5');
|
||||
$sheet->setCellValue('A5', 'PERÍODO:');
|
||||
$sheet->getStyle('A5')->applyFromArray($styleLabel);
|
||||
|
||||
$sheet->mergeCells("C5:{$lastCol}5");
|
||||
$sheet->setCellValue('C5', $periodoTexto);
|
||||
$sheet->getStyle("C5:{$lastCol}5")->applyFromArray($styleBox);
|
||||
$sheet->getStyle('C5')->getFont()->setSize(12);
|
||||
|
||||
// --- RESUMEN DE TOTALES ---
|
||||
$totalVentas = $data->count();
|
||||
$totalSubtotal = $data->sum('subtotal');
|
||||
$totalIva = $data->sum('iva');
|
||||
$totalDescuentos = $data->sum('descuento');
|
||||
$totalMonto = $data->sum('total');
|
||||
|
||||
$row = 7;
|
||||
$sheet->setCellValue('A' . $row, 'TOTAL VENTAS:');
|
||||
$sheet->setCellValue('B' . $row, $totalVentas);
|
||||
$sheet->setCellValue('D' . $row, 'SUBTOTAL:');
|
||||
$sheet->setCellValue('E' . $row, '$' . number_format($totalSubtotal, 2));
|
||||
$sheet->setCellValue('G' . $row, 'IVA:');
|
||||
$sheet->setCellValue('H' . $row, '$' . number_format($totalIva, 2));
|
||||
$sheet->setCellValue('I' . $row, 'DESCUENTOS:');
|
||||
$sheet->setCellValue('J' . $row, '$' . number_format($totalDescuentos, 2));
|
||||
$sheet->setCellValue('K' . $row, 'TOTAL:');
|
||||
$sheet->setCellValue('L' . $row, '$' . number_format($totalMonto, 2));
|
||||
|
||||
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setBold(true);
|
||||
$sheet->getStyle('B' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
|
||||
$sheet->getStyle('E' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
|
||||
$sheet->getStyle('H' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
|
||||
$sheet->getStyle('J' . $row)->getFont()->setSize(12)->getColor()->setRGB('FF6600');
|
||||
$sheet->getStyle('L' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000');
|
||||
|
||||
// --- ENCABEZADOS DE TABLA ---
|
||||
$h = 9;
|
||||
$headers = [
|
||||
'A' => 'No.',
|
||||
'B' => 'FOLIO',
|
||||
'C' => 'FECHA',
|
||||
'D' => 'CLIENTE',
|
||||
'E' => 'RFC',
|
||||
'F' => 'VENDEDOR',
|
||||
'G' => 'SUBTOTAL',
|
||||
'H' => 'IVA',
|
||||
'I' => 'DESCUENTO',
|
||||
'J' => 'TOTAL',
|
||||
'K' => "MÉTODO\nPAGO",
|
||||
'L' => 'ESTADO',
|
||||
];
|
||||
|
||||
foreach ($headers as $col => $text) {
|
||||
$sheet->setCellValue("{$col}{$h}", $text);
|
||||
}
|
||||
|
||||
$sheet->getStyle("A{$h}:{$lastCol}{$h}")->applyFromArray($styleTableHeader);
|
||||
$sheet->getRowDimension($h)->setRowHeight(35);
|
||||
|
||||
// --- LLENADO DE DATOS ---
|
||||
$row = 10;
|
||||
$i = 1;
|
||||
|
||||
foreach ($data as $item) {
|
||||
$sheet->setCellValue('A' . $row, $i);
|
||||
$sheet->setCellValue('B' . $row, $item['folio']);
|
||||
$sheet->setCellValue('C' . $row, $item['fecha']);
|
||||
$sheet->setCellValue('D' . $row, $item['cliente']);
|
||||
$sheet->setCellValue('E' . $row, $item['rfc_cliente']);
|
||||
$sheet->setCellValue('F' . $row, $item['vendedor']);
|
||||
$sheet->setCellValue('G' . $row, '$' . number_format($item['subtotal'], 2));
|
||||
$sheet->setCellValue('H' . $row, '$' . number_format($item['iva'], 2));
|
||||
$sheet->setCellValue('I' . $row, '$' . number_format($item['descuento'], 2));
|
||||
$sheet->setCellValue('J' . $row, '$' . number_format($item['total'], 2));
|
||||
$sheet->setCellValue('K' . $row, $item['metodo_pago']);
|
||||
$sheet->setCellValue('L' . $row, $item['status']);
|
||||
|
||||
// Estilos de fila
|
||||
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
'font' => ['size' => 10]
|
||||
]);
|
||||
|
||||
// Centrados
|
||||
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("C{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("K{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("L{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
// Color de estado
|
||||
if ($item['status'] === 'Cancelada') {
|
||||
$sheet->getStyle("L{$row}")->getFont()->getColor()->setRGB('FF0000');
|
||||
}
|
||||
|
||||
// Color alterno de filas
|
||||
if ($i % 2 == 0) {
|
||||
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setRGB('F2F2F2');
|
||||
}
|
||||
|
||||
$row++;
|
||||
$i++;
|
||||
}
|
||||
|
||||
// --- ANCHOS DE COLUMNA ---
|
||||
$sheet->getColumnDimension('A')->setWidth(5);
|
||||
$sheet->getColumnDimension('B')->setWidth(22);
|
||||
$sheet->getColumnDimension('C')->setWidth(18);
|
||||
$sheet->getColumnDimension('D')->setWidth(22);
|
||||
$sheet->getColumnDimension('E')->setWidth(16);
|
||||
$sheet->getColumnDimension('F')->setWidth(20);
|
||||
$sheet->getColumnDimension('G')->setWidth(14);
|
||||
$sheet->getColumnDimension('H')->setWidth(12);
|
||||
$sheet->getColumnDimension('I')->setWidth(14);
|
||||
$sheet->getColumnDimension('J')->setWidth(14);
|
||||
$sheet->getColumnDimension('K')->setWidth(14);
|
||||
$sheet->getColumnDimension('L')->setWidth(14);
|
||||
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($filePath);
|
||||
|
||||
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar reporte Excel de inventario
|
||||
*/
|
||||
public function inventoryReport(Request $request)
|
||||
{
|
||||
// 1. VALIDACIÓN
|
||||
$request->validate([
|
||||
'fecha_inicio' => 'sometimes|date',
|
||||
'fecha_fin' => 'sometimes|date|after_or_equal:fecha_inicio',
|
||||
'category_id' => 'sometimes|nullable|exists:categories,id',
|
||||
'with_serials_only' => 'sometimes|nullable|boolean',
|
||||
'low_stock_threshold' => 'sometimes|nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
|
||||
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
|
||||
|
||||
// 2. CONSULTA DE INVENTARIO
|
||||
$query = Inventory::with([
|
||||
'category:id,name',
|
||||
'price:inventory_id,cost,retail_price',
|
||||
'unitOfMeasure:id,abbreviation',
|
||||
'serials'
|
||||
])
|
||||
->when($request->q, function($q) use ($request) {
|
||||
$q->where(function($query) use ($request) {
|
||||
$query->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
})
|
||||
->when($request->category_id, function($q) use ($request) {
|
||||
$q->where('category_id', $request->category_id);
|
||||
})
|
||||
->when($request->with_serials_only, function($q) {
|
||||
$q->where('track_serials', true);
|
||||
})
|
||||
->when($request->low_stock_threshold, function($q) use ($request) {
|
||||
$q->whereHas('warehouses', function($wq) use ($request) {
|
||||
$wq->where('inventory_warehouse.stock', '<=', $request->low_stock_threshold);
|
||||
});
|
||||
});
|
||||
|
||||
$inventories = $query->orderBy('name')->get();
|
||||
|
||||
if ($inventories->isEmpty()) {
|
||||
return response()->json(['message' => 'No se encontraron productos'], 404);
|
||||
}
|
||||
|
||||
// 3. MAPEO DE DATOS
|
||||
$data = $inventories->map(function($inventory) use ($fechaInicio, $fechaFin) {
|
||||
// Cantidad vendida en el periodo
|
||||
$quantitySold = SaleDetail::where('inventory_id', $inventory->id)
|
||||
->whereHas('sale', function($q) use ($fechaInicio, $fechaFin) {
|
||||
$q->where('status', 'completed')
|
||||
->whereBetween('created_at', [$fechaInicio, $fechaFin]);
|
||||
})
|
||||
->sum('quantity');
|
||||
|
||||
// Conteo de seriales
|
||||
$serialsTotal = $inventory->serials->count();
|
||||
$serialsAvailable = $inventory->serials->where('status', 'disponible')->count();
|
||||
$serialsSold = $inventory->serials->where('status', 'vendido')->count();
|
||||
$serialsReturned = $inventory->serials->where('status', 'devuelto')->count();
|
||||
|
||||
$cost = $inventory->price?->cost ?? 0;
|
||||
$retailPrice = $inventory->price?->retail_price ?? 0;
|
||||
$totalSold = $quantitySold * $retailPrice;
|
||||
$inventoryValue = $inventory->stock * $cost;
|
||||
$unitProfit = $retailPrice - $cost;
|
||||
$totalProfit = $unitProfit * $quantitySold;
|
||||
|
||||
return [
|
||||
'sku' => $inventory->sku ?? 'N/A',
|
||||
'name' => $inventory->name,
|
||||
'category' => $inventory->category?->name ?? 'Sin categoría',
|
||||
'unit' => $inventory->unitOfMeasure?->abbreviation ?? 'u',
|
||||
'stock' => $inventory->stock,
|
||||
'quantity_sold' => $quantitySold,
|
||||
'serials_total' => $serialsTotal,
|
||||
'serials_available' => $serialsAvailable,
|
||||
'serials_sold' => $serialsSold,
|
||||
'serials_returned' => $serialsReturned,
|
||||
'cost' => (float) $cost,
|
||||
'price' => (float) $retailPrice,
|
||||
'total_sold' => $totalSold,
|
||||
'inventory_value' => $inventoryValue,
|
||||
'unit_profit' => (float) $unitProfit,
|
||||
'total_profit' => $totalProfit,
|
||||
];
|
||||
});
|
||||
|
||||
// 4. CONFIGURACIÓN EXCEL
|
||||
$fileName = 'Reporte_Inventario_' . $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();
|
||||
|
||||
// Fuente Global
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setSize(10);
|
||||
|
||||
// Estilos Comunes
|
||||
$styleBox = [
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '000000']]],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER]
|
||||
];
|
||||
$styleLabel = [
|
||||
'font' => ['size' => 12, 'bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER]
|
||||
];
|
||||
$styleTableHeader = [
|
||||
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '70AD47']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]
|
||||
];
|
||||
|
||||
// --- ESTRUCTURA DEL DOCUMENTO ---
|
||||
$lastCol = 'N';
|
||||
$sheet->getRowDimension(2)->setRowHeight(10);
|
||||
$sheet->getRowDimension(3)->setRowHeight(25);
|
||||
$sheet->getRowDimension(5)->setRowHeight(30);
|
||||
|
||||
// --- TÍTULO PRINCIPAL ---
|
||||
$sheet->mergeCells("A3:{$lastCol}3");
|
||||
$sheet->setCellValue('A3', 'REPORTE DE INVENTARIO');
|
||||
$sheet->getStyle('A3')->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
|
||||
// --- INFORMACIÓN DEL PERIODO ---
|
||||
$sheet->mergeCells('A5:B5');
|
||||
$sheet->setCellValue('A5', 'PERÍODO:');
|
||||
$sheet->getStyle('A5')->applyFromArray($styleLabel);
|
||||
|
||||
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->mergeCells("C5:{$lastCol}5");
|
||||
$sheet->setCellValue('C5', $periodoTexto);
|
||||
$sheet->getStyle("C5:{$lastCol}5")->applyFromArray($styleBox);
|
||||
$sheet->getStyle('C5')->getFont()->setSize(12);
|
||||
|
||||
// --- RESUMEN DE TOTALES ---
|
||||
$totalProducts = $data->count();
|
||||
$totalStock = $data->sum('stock');
|
||||
$totalQuantitySold = $data->sum('quantity_sold');
|
||||
$totalSoldValue = $data->sum('total_sold');
|
||||
$totalInventoryValue = $data->sum('inventory_value');
|
||||
$totalProfit = $data->sum('total_profit');
|
||||
|
||||
$row = 7;
|
||||
// Columna A-B: TOTAL PRODUCTOS
|
||||
$sheet->mergeCells("A{$row}:B{$row}");
|
||||
$sheet->setCellValue("A{$row}", 'TOTAL PRODUCTOS:');
|
||||
$sheet->setCellValueExplicit("C{$row}", (int) $totalProducts, DataType::TYPE_NUMERIC);
|
||||
|
||||
// Columna D-E: STOCK TOTAL
|
||||
$sheet->mergeCells("D{$row}:E{$row}");
|
||||
$sheet->setCellValue("D{$row}", 'STOCK TOTAL:');
|
||||
$sheet->setCellValueExplicit("F{$row}", (int) $totalStock, DataType::TYPE_NUMERIC);
|
||||
|
||||
// Columna G: VENDIDOS
|
||||
$sheet->setCellValue("G{$row}", 'VENDIDOS:');
|
||||
$sheet->setCellValueExplicit("H{$row}", (int) $totalQuantitySold, DataType::TYPE_NUMERIC);
|
||||
|
||||
// Columna I-J: TOTAL VENDIDO
|
||||
$sheet->mergeCells("I{$row}:J{$row}");
|
||||
$sheet->setCellValue("I{$row}", 'TOTAL VENDIDO:');
|
||||
$sheet->setCellValueExplicit("K{$row}", (float) $totalSoldValue, DataType::TYPE_NUMERIC);
|
||||
|
||||
// Columna L-M: VALOR INVENTARIO
|
||||
$sheet->mergeCells("L{$row}:M{$row}");
|
||||
$sheet->setCellValue("L{$row}", 'VALOR INVENTARIO:');
|
||||
$sheet->setCellValueExplicit("N{$row}", (float) $totalInventoryValue, DataType::TYPE_NUMERIC);
|
||||
|
||||
// Columna O: UTILIDAD TOTAL
|
||||
$sheet->setCellValue("O{$row}", 'UTILIDAD TOTAL:');
|
||||
$sheet->setCellValueExplicit("P{$row}", (float) $totalProfit, DataType::TYPE_NUMERIC);
|
||||
|
||||
// Aplicar formato moneda
|
||||
$sheet->getStyle("K{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
$sheet->getStyle("N{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
$sheet->getStyle("P{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
|
||||
// Aplicar estilos a TODA la fila
|
||||
$sheet->getStyle("A{$row}:P{$row}")->getFont()->setBold(true);
|
||||
|
||||
// Centrar vertical y horizontalmente TODOS los datos
|
||||
$sheet->getStyle("A{$row}:P{$row}")->getAlignment()
|
||||
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||
->setVertical(Alignment::VERTICAL_CENTER);
|
||||
|
||||
// Colores para cada valor numérico
|
||||
$sheet->getStyle("C{$row}")->getFont()->setSize(12)->getColor()->setRGB('70AD47');
|
||||
$sheet->getStyle("F{$row}")->getFont()->setSize(12)->getColor()->setRGB('70AD47');
|
||||
$sheet->getStyle("H{$row}")->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
|
||||
$sheet->getStyle("K{$row}")->getFont()->setSize(12)->getColor()->setRGB('FF6600');
|
||||
$sheet->getStyle("N{$row}")->getFont()->setSize(12)->getColor()->setRGB('008000');
|
||||
$sheet->getStyle("P{$row}")->getFont()->setSize(12);
|
||||
|
||||
// Color de utilidad (verde si es positiva, rojo si es negativa)
|
||||
if ($totalProfit > 0) {
|
||||
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('008000');
|
||||
} elseif ($totalProfit < 0) {
|
||||
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('FF0000');
|
||||
}
|
||||
|
||||
// Altura de fila para que se vea mejor
|
||||
$sheet->getRowDimension($row)->setRowHeight(25);
|
||||
|
||||
// --- ENCABEZADOS DE TABLA ---
|
||||
$h = 9;
|
||||
$headers = [
|
||||
'A' => 'No.',
|
||||
'B' => 'SKU',
|
||||
'C' => 'NOMBRE',
|
||||
'D' => 'CATEGORÍA',
|
||||
'E' => 'UNIDAD',
|
||||
'F' => "STOCK\nDISPONIBLE",
|
||||
'G' => "CANTIDAD\nVENDIDA",
|
||||
'H' => "SERIALES\nTOTALES",
|
||||
'I' => "SERIALES\nDISPONIBLES",
|
||||
'J' => "SERIALES\nVENDIDOS",
|
||||
'K' => "SERIALES\nDEVUELTOS",
|
||||
'L' => "COSTO\nUNITARIO",
|
||||
'M' => "PRECIO\nVENTA",
|
||||
'N' => "TOTAL\nVENDIDO",
|
||||
'O' => "VALOR\nINVENTARIO",
|
||||
'P' => "UTILIDAD\nPOR UNIDAD",
|
||||
'Q' => "UTILIDAD\nTOTAL",
|
||||
];
|
||||
|
||||
foreach ($headers as $col => $text) {
|
||||
$sheet->setCellValue("{$col}{$h}", $text);
|
||||
}
|
||||
|
||||
$sheet->getStyle("A{$h}:Q{$h}")->applyFromArray($styleTableHeader);
|
||||
$sheet->getRowDimension($h)->setRowHeight(35);
|
||||
|
||||
// --- LLENADO DE DATOS ---
|
||||
$row = 10;
|
||||
$i = 1;
|
||||
$totalProfit = 0;
|
||||
|
||||
foreach ($data as $item) {
|
||||
$sheet->setCellValue('A' . $row, $i);
|
||||
$sheet->setCellValue('B' . $row, $item['sku']);
|
||||
$sheet->setCellValue('C' . $row, $item['name']);
|
||||
$sheet->setCellValue('D' . $row, $item['category']);
|
||||
$sheet->setCellValue('E' . $row, $item['unit']);
|
||||
|
||||
// NÚMEROS SIN FORMATO
|
||||
$sheet->setCellValueExplicit('F' . $row, (float) $item['stock'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('G' . $row, (float) $item['quantity_sold'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('H' . $row, (int) $item['serials_total'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('I' . $row, (int) $item['serials_available'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('J' . $row, (int) $item['serials_sold'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('K' . $row, (int) $item['serials_returned'], DataType::TYPE_NUMERIC);
|
||||
|
||||
// NÚMEROS CON FORMATO MONEDA
|
||||
$sheet->setCellValueExplicit('L' . $row, (float) $item['cost'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('M' . $row, (float) $item['price'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('N' . $row, (float) $item['total_sold'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('O' . $row, (float) $item['inventory_value'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('P' . $row, (float) $item['unit_profit'], DataType::TYPE_NUMERIC);
|
||||
$sheet->setCellValueExplicit('Q' . $row, (float) $item['total_profit'], DataType::TYPE_NUMERIC);
|
||||
|
||||
$totalProfit += $item['total_profit'];
|
||||
|
||||
// Estilos de fila
|
||||
$sheet->getStyle("A{$row}:Q{$row}")->applyFromArray([
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
'font' => ['size' => 10]
|
||||
]);
|
||||
|
||||
// Centrados
|
||||
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("E{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("F{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("G{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("K{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
// Formato moneda para columnas L-Q
|
||||
$sheet->getStyle("L{$row}:Q{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||
|
||||
// Color de utilidad (verde si es positiva)
|
||||
if ($item['total_profit'] > 0) {
|
||||
$sheet->getStyle("Q{$row}")->getFont()->getColor()->setRGB('008000');
|
||||
} elseif ($item['total_profit'] < 0) {
|
||||
$sheet->getStyle("Q{$row}")->getFont()->getColor()->setRGB('FF0000');
|
||||
}
|
||||
|
||||
// Color alterno de filas
|
||||
if ($i % 2 == 0) {
|
||||
$sheet->getStyle("A{$row}:Q{$row}")->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setRGB('F2F2F2');
|
||||
}
|
||||
|
||||
$row++;
|
||||
$i++;
|
||||
}
|
||||
|
||||
// --- ANCHOS DE COLUMNA ---
|
||||
$sheet->getColumnDimension('A')->setWidth(6);
|
||||
$sheet->getColumnDimension('B')->setWidth(15);
|
||||
$sheet->getColumnDimension('C')->setWidth(25);
|
||||
$sheet->getColumnDimension('D')->setWidth(18);
|
||||
$sheet->getColumnDimension('E')->setWidth(10);
|
||||
$sheet->getColumnDimension('F')->setWidth(15);
|
||||
$sheet->getColumnDimension('G')->setWidth(15);
|
||||
$sheet->getColumnDimension('H')->setWidth(15);
|
||||
$sheet->getColumnDimension('I')->setWidth(15);
|
||||
$sheet->getColumnDimension('J')->setWidth(15);
|
||||
$sheet->getColumnDimension('K')->setWidth(15);
|
||||
$sheet->getColumnDimension('L')->setWidth(15);
|
||||
$sheet->getColumnDimension('M')->setWidth(15);
|
||||
$sheet->getColumnDimension('N')->setWidth(18);
|
||||
$sheet->getColumnDimension('O')->setWidth(18);
|
||||
$sheet->getColumnDimension('P')->setWidth(18);
|
||||
$sheet->getColumnDimension('Q')->setWidth(18);
|
||||
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($filePath);
|
||||
|
||||
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traducir método de pago
|
||||
*/
|
||||
private function translatePaymentMethod(?string $method): string
|
||||
{
|
||||
return match($method) {
|
||||
'cash' => 'Efectivo',
|
||||
'credit_card' => 'T. Crédito',
|
||||
'debit_card' => 'T. Débito',
|
||||
'transfer' => 'Transferencia',
|
||||
default => $method ?? 'N/A',
|
||||
};
|
||||
}
|
||||
}
|
||||
259
app/Http/Controllers/App/InventoryController.php
Normal file
259
app/Http/Controllers/App/InventoryController.php
Normal file
@ -0,0 +1,259 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\InventoryStoreRequest;
|
||||
use App\Http\Requests\App\InventoryUpdateRequest;
|
||||
use App\Http\Requests\App\InventoryImportRequest;
|
||||
use App\Services\ProductService;
|
||||
use App\Imports\ProductsImport;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Maatwebsite\Excel\Validators\ValidationException;
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\Exportable;
|
||||
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ProductService $productService
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$products = Inventory::with(['category', 'subcategory', 'price', 'unitOfMeasure'])->withCount('serials')
|
||||
->where('is_active', true);
|
||||
|
||||
|
||||
// Filtro por búsqueda de texto (nombre, SKU, código de barras)
|
||||
if ($request->has('q') && $request->q) {
|
||||
$products->where(function($query) use ($request) {
|
||||
$query->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
}
|
||||
|
||||
// Filtro por categoría (independiente de la búsqueda de texto)
|
||||
if ($request->has('category_id') && $request->category_id) {
|
||||
$products->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
// Calcular el valor total del inventario
|
||||
$totalInventoryValue = DB::table('inventory_warehouse')
|
||||
->join('prices', 'inventory_warehouse.inventory_id', '=', 'prices.inventory_id')
|
||||
->join('inventories', 'inventory_warehouse.inventory_id', '=', 'inventories.id')
|
||||
->where('inventories.is_active', true)
|
||||
->sum(DB::raw('inventory_warehouse.stock * prices.cost'));
|
||||
|
||||
$products = $products->orderBy('name')
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
// Stock del almacén principal por producto
|
||||
$mainWarehouseId = DB::table('warehouses')->where('is_main', true)->value('id');
|
||||
$mainWarehouseStocks = $mainWarehouseId
|
||||
? DB::table('inventory_warehouse')
|
||||
->where('warehouse_id', $mainWarehouseId)
|
||||
->whereIn('inventory_id', $products->pluck('id')->toArray())
|
||||
->pluck('stock', 'inventory_id')
|
||||
: collect();
|
||||
|
||||
$products->each(function ($product) use ($mainWarehouseStocks) {
|
||||
$product->main_warehouse_stock = (float) ($mainWarehouseStocks[$product->id] ?? 0);
|
||||
});
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'products' => $products,
|
||||
'total_inventory_value' => round($totalInventoryValue, 2),
|
||||
'main_warehouse_id' => $mainWarehouseId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Inventory $inventario)
|
||||
{
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $inventario->load(['category', 'subcategory', 'price', 'unitOfMeasure'])->loadCount('serials')
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(InventoryStoreRequest $request)
|
||||
{
|
||||
$product = $this->productService->createProduct($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $product
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(InventoryUpdateRequest $request, Inventory $inventario)
|
||||
{
|
||||
$product = $this->productService->updateProduct($inventario, $request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $product
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Inventory $inventario)
|
||||
{
|
||||
$inventario->delete();
|
||||
|
||||
return ApiResponse::OK->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener productos disponibles en un almacén específico
|
||||
*/
|
||||
public function getProductsByWarehouse(Request $request, int $warehouseId)
|
||||
{
|
||||
$query = Inventory::query()
|
||||
->with(['category', 'subcategory', 'price', 'unitOfMeasure'])
|
||||
->where('is_active', true)
|
||||
->whereHas('warehouses', function ($q) use ($warehouseId) {
|
||||
$q->where('warehouse_id', $warehouseId)
|
||||
->where('stock', '>', 0);
|
||||
});
|
||||
|
||||
// Filtro por búsqueda de texto
|
||||
if ($request->has('q') && $request->q) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
}
|
||||
|
||||
// Filtro por categoría
|
||||
if ($request->has('category_id') && $request->category_id) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$products = $query->orderBy('name')->get();
|
||||
|
||||
// Agregar el stock específico de este almacén a cada producto
|
||||
$products->each(function ($product) use ($warehouseId) {
|
||||
$warehouseStock = $product->warehouses()
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->first();
|
||||
|
||||
$product->warehouse_stock = $warehouseStock ? $warehouseStock->pivot->stock : 0;
|
||||
});
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'products' => $products,
|
||||
'warehouse_id' => $warehouseId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Importar productos desde Excel
|
||||
*/
|
||||
public function import(InventoryImportRequest $request)
|
||||
{
|
||||
try {
|
||||
$import = new ProductsImport();
|
||||
|
||||
Excel::import($import, $request->file('file'));
|
||||
|
||||
$stats = $import->getStats();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Importación completada exitosamente.',
|
||||
'imported' => $stats['imported'],
|
||||
'updated' => $stats['updated'],
|
||||
'skipped' => $stats['skipped'],
|
||||
'errors' => $stats['errors'],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
$failures = $e->failures();
|
||||
$errors = [];
|
||||
|
||||
foreach ($failures as $failure) {
|
||||
$errors[] = [
|
||||
'row' => $failure->row(),
|
||||
'attribute' => $failure->attribute(),
|
||||
'errors' => $failure->errors(),
|
||||
'values' => $failure->values(),
|
||||
];
|
||||
}
|
||||
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error de validación en el archivo.',
|
||||
'errors' => $errors,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al importar productos: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descargar plantilla de Excel para importación
|
||||
*/
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$headers = [
|
||||
'nombre',
|
||||
'sku',
|
||||
'codigo_barras',
|
||||
'categoria',
|
||||
'unidad_medida',
|
||||
'precio_venta',
|
||||
'impuesto'
|
||||
];
|
||||
|
||||
$exampleData = [
|
||||
[
|
||||
'nombre' => 'Samsung Galaxy A55',
|
||||
'sku' => 'SAM-A55-BLK',
|
||||
'codigo_barras' => '7502276853456',
|
||||
'categoria' => 'Electrónica',
|
||||
'unidad_medida' => 'Pieza',
|
||||
'precio_venta' => 7500.00,
|
||||
'impuesto' => 16
|
||||
],
|
||||
[
|
||||
'nombre' => 'Cable UTP CAT6 (Metro)',
|
||||
'sku' => 'UTP6-MTR',
|
||||
'codigo_barras' => '750227686666',
|
||||
'categoria' => 'Cables',
|
||||
'unidad_medida' => 'Metro',
|
||||
'precio_venta' => 50.00,
|
||||
'impuesto' => 16
|
||||
],
|
||||
[
|
||||
'nombre' => 'Laptop HP Pavilion 15',
|
||||
'sku' => 'HP-LAP-15',
|
||||
'codigo_barras' => '7502276854443',
|
||||
'categoria' => 'Computadoras',
|
||||
'unidad_medida' => 'Pieza',
|
||||
'precio_venta' => 12000.00,
|
||||
'impuesto' => 16
|
||||
],
|
||||
];
|
||||
|
||||
return Excel::download(
|
||||
new class($headers, $exampleData) implements FromArray, WithHeadings {
|
||||
use Exportable;
|
||||
|
||||
public function __construct(private array $headers, private array $data) {}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
},
|
||||
'plantilla_productos.xlsx'
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
203
app/Http/Controllers/App/InventoryMovementController.php
Normal file
203
app/Http/Controllers/App/InventoryMovementController.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\InventoryEntryRequest;
|
||||
use App\Http\Requests\App\InventoryExitRequest;
|
||||
use App\Http\Requests\App\InventoryMovementUpdateRequest;
|
||||
use App\Http\Requests\App\InventoryTransferRequest;
|
||||
use App\Models\InventoryMovement;
|
||||
use App\Services\InventoryMovementService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
/**
|
||||
* Controlador para movimientos de inventario
|
||||
*/
|
||||
class InventoryMovementController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected InventoryMovementService $movementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Listar movimientos con filtros
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->has('q') && $request->q){
|
||||
$query->whereHas('inventory', function($qy) use ($request){
|
||||
$qy->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->has('movement_type')) {
|
||||
$query->where('movement_type', $request->movement_type);
|
||||
}
|
||||
|
||||
if ($request->has('inventory_id')) {
|
||||
$query->where('inventory_id', $request->inventory_id);
|
||||
}
|
||||
|
||||
if ($request->has('warehouse_id')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('warehouse_from_id', $request->warehouse_id)
|
||||
->orWhere('warehouse_to_id', $request->warehouse_id);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->has('from_date')) {
|
||||
$query->whereDate('created_at', '>=', $request->from_date);
|
||||
}
|
||||
|
||||
if ($request->has('to_date')) {
|
||||
$query->whereDate('created_at', '<=', $request->to_date);
|
||||
}
|
||||
|
||||
$movements = $query->paginate($request->get('per_page', 15));
|
||||
|
||||
return ApiResponse::OK->response(['movements' => $movements]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver detalle de un movimiento
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials', 'transferredSerials', 'exitedSerials'])
|
||||
->find($id);
|
||||
|
||||
if (!$movement) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Movimiento no encontrado'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'movement' => $movement
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Editar movimiento de inventario
|
||||
* Revierte el movimiento original y aplica el nuevo
|
||||
*/
|
||||
public function update(InventoryMovementUpdateRequest $request, int $id)
|
||||
{
|
||||
|
||||
try {
|
||||
$movement = $this->movementService->updateMovement(
|
||||
$id,
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Movimiento actualizado correctamente',
|
||||
'movement' => $movement,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar entrada de inventario
|
||||
*/
|
||||
public function entry(InventoryEntryRequest $request)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validated();
|
||||
|
||||
if(isset($validated['products'])){
|
||||
$movements = $this->movementService->bulkEntry($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Entrada registrada correctamente',
|
||||
'movement' => $movements,
|
||||
'total_products' => count($movements),
|
||||
]);
|
||||
} else {
|
||||
$movement = $this->movementService->entry($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Entrada registrada correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseTo', 'supplier']),
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar salida de inventario
|
||||
*/
|
||||
public function exit(InventoryExitRequest $request)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validated();
|
||||
|
||||
if(isset($validated['products'])){
|
||||
$movements = $this->movementService->bulkExit($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Salidas registradas correctamente',
|
||||
'movements' => $movements,
|
||||
'total_products' => count($movements),
|
||||
]);
|
||||
} else {
|
||||
$movement = $this->movementService->exit($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Salida registrada correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseFrom', 'exitedSerials']),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar traspaso entre almacenes
|
||||
*/
|
||||
public function transfer(InventoryTransferRequest $request)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validated();
|
||||
|
||||
if(isset($validated['products'])){
|
||||
$movements = $this->movementService->bulkTransfer($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Traspasos registrados correctamente',
|
||||
'movements' => $movements,
|
||||
'total_products' => count($movements),
|
||||
]);
|
||||
} else {
|
||||
$movement = $this->movementService->transfer($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Traspaso registrado correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/App/InventorySerialController.php
Normal file
114
app/Http/Controllers/App/InventorySerialController.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\InventorySerial;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\InventoryMovementService;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
/**
|
||||
* Controlador para gestión de números de serie
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class InventorySerialController extends Controller
|
||||
{
|
||||
/**
|
||||
* Listar seriales de un producto
|
||||
*/
|
||||
public function index(Inventory $inventario, Request $request)
|
||||
{
|
||||
$query = $inventario->serials();
|
||||
|
||||
if ($request->has('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->has('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
} elseif ($request->boolean('main_warehouse')) {
|
||||
$mainWarehouse = Warehouse::where('is_main', true)->first();
|
||||
if ($mainWarehouse) {
|
||||
$query->where(function ($q) use ($mainWarehouse) {
|
||||
$q->where('warehouse_id', $mainWarehouse->id)
|
||||
->orWhereNull('warehouse_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('q')) {
|
||||
$query->where('serial_number', 'like', "%{$request->q}%");
|
||||
}
|
||||
|
||||
$serials = $query->orderBy('serial_number', 'ASC')->with(['saleDetail.sale', 'warehouse'])
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'serials' => $serials,
|
||||
'inventory' => $inventario->load('category'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$serialNumber = $request->input('serial_number');
|
||||
|
||||
$serial = InventorySerial::with(['inventory.price', 'inventory.category'])
|
||||
->where('serial_number', $serialNumber)
|
||||
->where('status', 'disponible')
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => ['serial' => $serial]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostrar un serial específico
|
||||
*/
|
||||
public function show(Inventory $inventario, InventorySerial $serial)
|
||||
{
|
||||
// Verificar que el serial pertenece al inventario
|
||||
if ($serial->inventory_id !== $inventario->id) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Serial no encontrado para este inventario'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'serial' => $serial->load('saleDetail'),
|
||||
'inventory' => $inventario->load('category'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Eliminar un serial
|
||||
*/
|
||||
public function destroy(Inventory $inventario, InventorySerial $serial)
|
||||
{
|
||||
// Verificar que el serial pertenece al inventario
|
||||
if ($serial->inventory_id !== $inventario->id) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Serial no encontrado para este inventario'
|
||||
]);
|
||||
}
|
||||
|
||||
$serial->delete();
|
||||
|
||||
// Sincronizar stock
|
||||
$inventario->syncStock();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Serial eliminado exitosamente',
|
||||
'inventory' => $inventario->fresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
246
app/Http/Controllers/App/InvoiceController.php
Normal file
246
app/Http/Controllers/App/InvoiceController.php
Normal file
@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\InvoiceStoreRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\InvoiceRequest;
|
||||
use App\Models\Sale;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Muestra los datos de la venta para el formulario de facturación.
|
||||
*/
|
||||
public function show(string $invoiceNumber)
|
||||
{
|
||||
$sale = Sale::where('invoice_number', $invoiceNumber)
|
||||
->with([
|
||||
'client',
|
||||
'details.inventory.category',
|
||||
'details.serials',
|
||||
'user:id,name,email',
|
||||
'invoiceRequests' => function ($query) {
|
||||
$query->orderBy('requested_at', 'desc');
|
||||
},
|
||||
])->first();
|
||||
|
||||
if (!$sale) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Venta no encontrada'
|
||||
]);
|
||||
}
|
||||
|
||||
// Verificar si ya tiene solicitud pendiente o procesada
|
||||
$existingRequest = $sale->invoiceRequests
|
||||
->whereIn('status', [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED])
|
||||
->first();
|
||||
|
||||
if ($existingRequest) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Esta venta ya tiene una solicitud de facturación ' .
|
||||
($existingRequest->status === InvoiceRequest::STATUS_PENDING ? 'pendiente' : 'procesada'),
|
||||
'invoice_request' => $existingRequest,
|
||||
]);
|
||||
}
|
||||
|
||||
// Buscar cliente existente si la venta ya tiene cliente asociado
|
||||
$existingClient = null;
|
||||
if ($sale->client_id) {
|
||||
$existingClient = $sale->client;
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'sale' => $this->formatSaleData($sale),
|
||||
'client' => $sale->client,
|
||||
'existing_client' => $existingClient,
|
||||
'invoice_requests' => $sale->invoiceRequests,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Guarda los datos fiscales del cliente para la venta.
|
||||
*/
|
||||
public function store(InvoiceStoreRequest $request, string $invoiceNumber)
|
||||
{
|
||||
$sale = Sale::where('invoice_number', $invoiceNumber)
|
||||
->with('details.serials')
|
||||
->first();
|
||||
|
||||
if (!$sale) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Venta no encontrada'
|
||||
]);
|
||||
}
|
||||
|
||||
$existingRequest = InvoiceRequest::where('sale_id', $sale->id)
|
||||
->whereIn('status', [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED])
|
||||
->first();
|
||||
|
||||
if ($existingRequest) {
|
||||
return ApiResponse::NO_CONTENT->response([
|
||||
'message' => 'Esta venta ya tiene una solicitud de facturación ' .
|
||||
($existingRequest->status === InvoiceRequest::STATUS_PENDING ? 'pendiente' : 'procesada')
|
||||
]);
|
||||
}
|
||||
|
||||
// Verificar que la venta esté completada
|
||||
if ($sale->status !== 'completed') {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Solo se pueden facturar ventas completadas'
|
||||
]);
|
||||
}
|
||||
|
||||
// Buscar si ya existe un cliente con ese RFC
|
||||
$client = Client::where('rfc', strtoupper($request->rfc))->first();
|
||||
|
||||
if ($client) {
|
||||
// Solo actualizar campos que el usuario proporciona explícitamente
|
||||
$updateData = [];
|
||||
|
||||
// Actualizar nombre solo si es diferente y fue proporcionado
|
||||
if ($request->filled('name') && $request->name !== $client->name) {
|
||||
$updateData['name'] = $request->name;
|
||||
}
|
||||
|
||||
// Actualizar email solo si es diferente y fue proporcionado
|
||||
if ($request->filled('email') && $request->email !== $client->email) {
|
||||
$updateData['email'] = $request->email;
|
||||
}
|
||||
|
||||
// Actualizar teléfono si fue proporcionado
|
||||
if ($request->filled('phone')) {
|
||||
$updateData['phone'] = $request->phone;
|
||||
}
|
||||
|
||||
// Actualizar dirección si fue proporcionada
|
||||
if ($request->filled('address')) {
|
||||
$updateData['address'] = $request->address;
|
||||
}
|
||||
|
||||
// Actualizar datos fiscales siempre (son obligatorios en el request)
|
||||
$updateData['razon_social'] = $request->razon_social;
|
||||
$updateData['regimen_fiscal'] = $request->regimen_fiscal;
|
||||
$updateData['cp_fiscal'] = $request->cp_fiscal;
|
||||
$updateData['uso_cfdi'] = $request->uso_cfdi;
|
||||
|
||||
// Solo actualizar si hay cambios
|
||||
if (!empty($updateData)) {
|
||||
$client->update($updateData);
|
||||
}
|
||||
} else {
|
||||
// Crear nuevo cliente
|
||||
$client = Client::create([
|
||||
'name' => $request->name,
|
||||
'client_number' => strtoupper($request->rfc),
|
||||
'email' => $request->email,
|
||||
'phone' => $request->phone,
|
||||
'address' => $request->address,
|
||||
'rfc' => strtoupper($request->rfc),
|
||||
'razon_social' => $request->razon_social,
|
||||
'regimen_fiscal' => $request->regimen_fiscal,
|
||||
'cp_fiscal' => $request->cp_fiscal,
|
||||
'uso_cfdi' => $request->uso_cfdi,
|
||||
]);
|
||||
}
|
||||
|
||||
// Asociar cliente a la venta solo si no está ya asociado
|
||||
if ($sale->client_id !== $client->id) {
|
||||
$sale->update(['client_id' => $client->id]);
|
||||
}
|
||||
|
||||
// Crear solicitud de facturación
|
||||
$invoiceRequest = InvoiceRequest::create([
|
||||
'sale_id' => $sale->id,
|
||||
'client_id' => $client->id,
|
||||
'status' => InvoiceRequest::STATUS_PENDING,
|
||||
'requested_at' => now(),
|
||||
]);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Solicitud de facturación guardada correctamente',
|
||||
'client' => $client->fresh(),
|
||||
'sale' => $this->formatSaleData($sale->fresh('details.serials')),
|
||||
'invoice_request' => $invoiceRequest,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si existe un cliente con el RFC proporcionado
|
||||
*/
|
||||
public function checkRfc(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'rfc' => ['required', 'string', 'min:12', 'max:13'],
|
||||
]);
|
||||
|
||||
$rfc = $request->input('rfc');
|
||||
|
||||
$client = Client::where('rfc', strtoupper($rfc))->first();
|
||||
|
||||
if ($client) {
|
||||
return ApiResponse::OK->response([
|
||||
'exists' => true,
|
||||
'client' => [
|
||||
'name' => $client->name,
|
||||
'email' => $client->email,
|
||||
'phone' => $client->phone,
|
||||
'address' => $client->address,
|
||||
'rfc' => $client->rfc,
|
||||
'razon_social' => $client->razon_social,
|
||||
'regimen_fiscal' => $client->regimen_fiscal,
|
||||
'cp_fiscal' => $client->cp_fiscal,
|
||||
'uso_cfdi' => $client->uso_cfdi,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'exists' => false
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear datos de la venta incluyendo números de serie
|
||||
*/
|
||||
private function formatSaleData(Sale $sale): array
|
||||
{
|
||||
return [
|
||||
'id' => $sale->id,
|
||||
'invoice_number' => $sale->invoice_number,
|
||||
'total' => $sale->total,
|
||||
'subtotal' => $sale->subtotal,
|
||||
'tax' => $sale->tax,
|
||||
'payment_method' => $sale->payment_method,
|
||||
'status' => $sale->status,
|
||||
'created_at' => $sale->created_at,
|
||||
'user' => $sale->user ? [
|
||||
'id' => $sale->user->id,
|
||||
'name' => $sale->user->name,
|
||||
'email' => $sale->user->email,
|
||||
] : null,
|
||||
'items' => $sale->details->map(function ($detail) {
|
||||
return [
|
||||
'id' => $detail->id,
|
||||
'product_name' => $detail->product_name,
|
||||
'quantity' => $detail->quantity,
|
||||
'unit_price' => $detail->unit_price,
|
||||
'subtotal' => $detail->subtotal,
|
||||
'category' => $detail->inventory->category->name ?? null,
|
||||
'sku' => $detail->inventory->sku ?? null,
|
||||
// Números de serie vendidos
|
||||
'serial_numbers' => $detail->serials->map(function ($serial) {
|
||||
return [
|
||||
'serial_number' => $serial->serial_number,
|
||||
'status' => $serial->status,
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
284
app/Http/Controllers/App/InvoiceRequestController.php
Normal file
284
app/Http/Controllers/App/InvoiceRequestController.php
Normal file
@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\InvoiceRequestProcessRequest;
|
||||
use App\Http\Requests\App\InvoiceRequestRejectRequest;
|
||||
use App\Http\Requests\App\InvoiceRequestUploadRequest;
|
||||
use App\Models\InvoiceRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
/**
|
||||
* Controlador para gestión administrativa de solicitudes de factura
|
||||
*/
|
||||
class InvoiceRequestController extends Controller
|
||||
{
|
||||
/**
|
||||
* Listar todas las solicitudes de factura con filtros y paginación
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InvoiceRequest::with([
|
||||
'sale:id,invoice_number,subtotal,tax,total,payment_method,status,created_at',
|
||||
'sale.user:id,name,email',
|
||||
'client:id,name,client_number,email,phone,rfc,razon_social',
|
||||
'processedBy:id,name,email'
|
||||
]);
|
||||
|
||||
// Filtro por estado
|
||||
if ($request->has('status') && $request->status !== '') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Filtro por rango de fechas
|
||||
if ($request->has('date_from')) {
|
||||
$query->whereDate('requested_at', '>=', $request->date_from);
|
||||
}
|
||||
|
||||
if ($request->has('date_to')) {
|
||||
$query->whereDate('requested_at', '<=', $request->date_to);
|
||||
}
|
||||
|
||||
// Búsqueda por folio de venta
|
||||
if ($request->has('invoice_number') && $request->invoice_number !== '') {
|
||||
$query->whereHas('sale', function ($q) use ($request) {
|
||||
$q->where('invoice_number', 'like', '%' . $request->invoice_number . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Búsqueda por cliente
|
||||
if ($request->has('client_search') && $request->client_search !== '') {
|
||||
$query->whereHas('client', function ($q) use ($request) {
|
||||
$q->where('name', 'like', '%' . $request->client_search . '%')
|
||||
->orWhere('rfc', 'like', '%' . $request->client_search . '%')
|
||||
->orWhere('email', 'like', '%' . $request->client_search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Ordenamiento
|
||||
$sortBy = $request->get('sort_by', 'requested_at');
|
||||
$sortOrder = $request->get('sort_order', 'desc');
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
|
||||
// Paginación
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$invoiceRequests = $query->paginate($perPage);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'invoice_requests' => $invoiceRequests
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostrar detalles de una solicitud específica
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
$invoiceRequest = InvoiceRequest::with([
|
||||
'sale.user:id,name,email',
|
||||
'sale.details:id,sale_id,inventory_id,product_name,quantity,unit_price,subtotal,discount_percentage,discount_amount',
|
||||
'sale.details:inventory_id.category_id,name',
|
||||
'sale.details.inventory:id,category_id,name,sku',
|
||||
'sale.details.inventory.category:id,name',
|
||||
'sale.details.serials:id,inventory_id,sale_detail_id,serial_number,status',
|
||||
'client',
|
||||
'processedBy:id,name,email'
|
||||
])->find($id);
|
||||
|
||||
if (!$invoiceRequest) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Solicitud de factura no encontrada'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'invoice_request' => $invoiceRequest
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar una solicitud como procesada
|
||||
*
|
||||
*/
|
||||
public function process(InvoiceRequestProcessRequest $request, $id)
|
||||
{
|
||||
$invoiceRequest = InvoiceRequest::find($id);
|
||||
|
||||
if (!$invoiceRequest) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Solicitud de factura no encontrada'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($invoiceRequest->status !== InvoiceRequest::STATUS_PENDING) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Solo se pueden procesar solicitudes pendientes',
|
||||
'current_status' => $invoiceRequest->status
|
||||
]);
|
||||
}
|
||||
|
||||
$invoiceRequest->markAsProcessed(
|
||||
$request->user()->id,
|
||||
$request->notes
|
||||
);
|
||||
|
||||
$invoiceRequest->load([
|
||||
'sale:id,invoice_number,total',
|
||||
'client:id,name,rfc,email',
|
||||
'processedBy:id,name'
|
||||
]);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Solicitud marcada como procesada correctamente',
|
||||
'invoice_request' => $invoiceRequest
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechazar una solicitud
|
||||
*
|
||||
*/
|
||||
public function reject(InvoiceRequestRejectRequest $request, $id)
|
||||
{
|
||||
$invoiceRequest = InvoiceRequest::find($id);
|
||||
|
||||
if (!$invoiceRequest) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Solicitud de factura no encontrada'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($invoiceRequest->status !== InvoiceRequest::STATUS_PENDING) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Solo se pueden rechazar solicitudes pendientes',
|
||||
'current_status' => $invoiceRequest->status
|
||||
]);
|
||||
}
|
||||
|
||||
$invoiceRequest->markAsRejected(
|
||||
$request->user()->id,
|
||||
$request->notes
|
||||
);
|
||||
|
||||
$invoiceRequest->load([
|
||||
'sale:id,invoice_number,total',
|
||||
'client:id,name,rfc,email',
|
||||
'processedBy:id,name'
|
||||
]);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Solicitud rechazada correctamente',
|
||||
'invoice_request' => $invoiceRequest
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas de solicitudes de factura
|
||||
*/
|
||||
public function stats()
|
||||
{
|
||||
$stats = [
|
||||
'pending' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PENDING)->count(),
|
||||
'processed' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PROCESSED)->count(),
|
||||
'rejected' => InvoiceRequest::where('status', InvoiceRequest::STATUS_REJECTED)->count(),
|
||||
'total' => InvoiceRequest::count(),
|
||||
'today_pending' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PENDING)
|
||||
->whereDate('requested_at', today())
|
||||
->count(),
|
||||
'this_month' => InvoiceRequest::whereMonth('requested_at', now()->month)
|
||||
->whereYear('requested_at', now()->year)
|
||||
->count(),
|
||||
];
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'stats' => $stats
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subir archivos de factura (XML y PDF) y guardar UUID del CFDI
|
||||
*/
|
||||
public function uploadInvoiceFile(InvoiceRequestUploadRequest $request, $id)
|
||||
{
|
||||
$invoiceRequest = InvoiceRequest::find($id);
|
||||
|
||||
if (!$invoiceRequest) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Solicitud de factura no encontrada'
|
||||
]);
|
||||
}
|
||||
|
||||
// Validar que la solicitud esté pendiente o procesada (permitir actualizar facturas)
|
||||
if (!in_array($invoiceRequest->status, [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED])) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'No se pueden subir archivos a solicitudes rechazadas',
|
||||
'current_status' => $invoiceRequest->status
|
||||
]);
|
||||
}
|
||||
|
||||
// Generar timestamp para nombres de archivo
|
||||
$timestamp = now()->format('YmdHis');
|
||||
$storagePath = 'invoices/' . now()->format('Y/m');
|
||||
|
||||
$updateData = [
|
||||
'cfdi_uuid' => $request->cfdi_uuid,
|
||||
];
|
||||
|
||||
// Procesar XML si se envió
|
||||
if ($request->hasFile('invoice_xml')) {
|
||||
// Eliminar XML anterior si existe
|
||||
if ($invoiceRequest->invoice_xml_path) {
|
||||
Storage::disk('public')->delete($invoiceRequest->invoice_xml_path);
|
||||
}
|
||||
|
||||
$xmlFileName = "invoice-{$id}-{$timestamp}.xml";
|
||||
$xmlPath = $request->file('invoice_xml')->storeAs(
|
||||
$storagePath,
|
||||
$xmlFileName,
|
||||
'public'
|
||||
);
|
||||
$updateData['invoice_xml_path'] = $xmlPath;
|
||||
}
|
||||
|
||||
// Procesar PDF (siempre requerido)
|
||||
if ($request->hasFile('invoice_pdf')) {
|
||||
// Eliminar PDF anterior si existe
|
||||
if ($invoiceRequest->invoice_pdf_path) {
|
||||
Storage::disk('public')->delete($invoiceRequest->invoice_pdf_path);
|
||||
}
|
||||
|
||||
$pdfFileName = "invoice-{$id}-{$timestamp}.pdf";
|
||||
$pdfPath = $request->file('invoice_pdf')->storeAs(
|
||||
$storagePath,
|
||||
$pdfFileName,
|
||||
'public'
|
||||
);
|
||||
$updateData['invoice_pdf_path'] = $pdfPath;
|
||||
}
|
||||
|
||||
// Actualizar registro
|
||||
$invoiceRequest->update($updateData);
|
||||
|
||||
$invoiceRequest->load([
|
||||
'sale:id,invoice_number',
|
||||
'client:id,name,rfc',
|
||||
]);
|
||||
|
||||
$files = [];
|
||||
if (isset($xmlPath)) {
|
||||
$files['xml_url'] = Storage::url($xmlPath);
|
||||
}
|
||||
if (isset($pdfPath)) {
|
||||
$files['pdf_url'] = Storage::url($pdfPath);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Archivos de factura subidos correctamente',
|
||||
'invoice_request' => $invoiceRequest,
|
||||
'files' => $files
|
||||
]);
|
||||
}
|
||||
}
|
||||
419
app/Http/Controllers/App/KardexController.php
Normal file
419
app/Http/Controllers/App/KardexController.php
Normal file
@ -0,0 +1,419 @@
|
||||
<?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 o todos los movimientos
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
// 1. VALIDACIÓN
|
||||
$request->validate([
|
||||
'inventory_id' => 'nullable|exists:inventories,id',
|
||||
'warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
'fecha_inicio' => 'required|date',
|
||||
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
|
||||
'movement_type' => 'nullable|in:entry,exit,transfer,sale,return',
|
||||
]);
|
||||
|
||||
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
|
||||
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
|
||||
|
||||
// Si no hay inventory_id, mostrar todos los movimientos
|
||||
if (!$request->inventory_id) {
|
||||
return $this->exportAllMovements($request, $fechaInicio, $fechaFin);
|
||||
}
|
||||
|
||||
$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. CONSTRUIR LÍNEAS DEL KARDEX
|
||||
$kardexLines = [];
|
||||
|
||||
foreach ($movements as $movement) {
|
||||
$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 ?? '-',
|
||||
'quantity' => $movement->quantity,
|
||||
'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 = 'J';
|
||||
|
||||
// --- 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);
|
||||
|
||||
// --- ENCABEZADOS DE TABLA ---
|
||||
$row += 2;
|
||||
$headerRow = $row;
|
||||
|
||||
$headers = [
|
||||
'A' => 'FECHA',
|
||||
'B' => 'TIPO',
|
||||
'C' => "ALMACÉN\nORIGEN",
|
||||
'D' => "ALMACÉN\nDESTINO",
|
||||
'E' => 'CANTIDAD',
|
||||
'F' => "COSTO\nUNITARIO",
|
||||
'G' => 'REFERENCIA',
|
||||
'H' => 'NOTAS',
|
||||
'I' => '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++;
|
||||
$totalQuantity = 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['quantity']);
|
||||
$sheet->setCellValue("F{$row}", $line['unit_cost'] ? '$' . number_format($line['unit_cost'], 2) : '');
|
||||
$sheet->setCellValue("G{$row}", $line['invoice_reference']);
|
||||
$sheet->setCellValue("H{$row}", $line['notes']);
|
||||
$sheet->setCellValue("I{$row}", $line['user']);
|
||||
|
||||
$totalQuantity += $line['quantity'];
|
||||
|
||||
// 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']],
|
||||
]);
|
||||
}
|
||||
|
||||
$row++;
|
||||
}
|
||||
|
||||
// --- FILA DE TOTALES ---
|
||||
$sheet->mergeCells("A{$row}:D{$row}");
|
||||
$sheet->setCellValue("A{$row}", 'TOTALES');
|
||||
$sheet->setCellValue("E{$row}", $totalQuantity);
|
||||
|
||||
$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(14);
|
||||
$sheet->getColumnDimension('G')->setWidth(18);
|
||||
$sheet->getColumnDimension('H')->setWidth(25);
|
||||
$sheet->getColumnDimension('I')->setWidth(16);
|
||||
$sheet->getColumnDimension('J')->setWidth(16);
|
||||
|
||||
// 6. GUARDAR Y DESCARGAR
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($filePath);
|
||||
|
||||
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportar todos los movimientos (sin filtro de producto)
|
||||
*/
|
||||
private function exportAllMovements(Request $request, Carbon $fechaInicio, Carbon $fechaFin)
|
||||
{
|
||||
// CONSULTA DE MOVIMIENTOS
|
||||
$query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user'])
|
||||
->whereBetween('created_at', [$fechaInicio, $fechaFin])
|
||||
->orderBy('created_at', 'asc')
|
||||
->orderBy('id', 'asc');
|
||||
|
||||
if ($request->warehouse_id) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('warehouse_from_id', $request->warehouse_id)
|
||||
->orWhere('warehouse_to_id', $request->warehouse_id);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->movement_type) {
|
||||
$query->where('movement_type', $request->movement_type);
|
||||
}
|
||||
|
||||
$movements = $query->get();
|
||||
|
||||
if ($movements->isEmpty()) {
|
||||
return response()->json(['message' => 'No se encontraron movimientos en el periodo especificado'], 404);
|
||||
}
|
||||
|
||||
// GENERAR EXCEL
|
||||
$fileName = 'Movimientos_' . $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('Movimientos');
|
||||
|
||||
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
|
||||
|
||||
$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 = 'J';
|
||||
|
||||
// TÍTULO
|
||||
$sheet->mergeCells("A2:{$lastCol}2");
|
||||
$sheet->setCellValue('A2', 'REPORTE DE MOVIMIENTOS DE INVENTARIO');
|
||||
$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);
|
||||
|
||||
// PERÍODO
|
||||
$row = 4;
|
||||
Carbon::setLocale('es');
|
||||
$periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y');
|
||||
|
||||
$sheet->setCellValue("A{$row}", 'PERÍODO:');
|
||||
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
|
||||
$sheet->mergeCells("B{$row}:{$lastCol}{$row}");
|
||||
$sheet->setCellValue("B{$row}", $periodoTexto);
|
||||
$sheet->getStyle("B{$row}:{$lastCol}{$row}")->applyFromArray([
|
||||
'borders' => ['outline' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
|
||||
// ENCABEZADOS
|
||||
$row += 2;
|
||||
$headerRow = $row;
|
||||
|
||||
$headers = [
|
||||
'A' => 'FECHA',
|
||||
'B' => 'PRODUCTO',
|
||||
'C' => 'TIPO',
|
||||
'D' => "ALMACÉN\nORIGEN",
|
||||
'E' => "ALMACÉN\nDESTINO",
|
||||
'F' => 'CANTIDAD',
|
||||
'G' => "COSTO\nUNITARIO",
|
||||
'H' => 'REFERENCIA',
|
||||
'I' => 'NOTAS',
|
||||
'J' => '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++;
|
||||
foreach ($movements as $movement) {
|
||||
$sheet->setCellValue("A{$row}", $movement->created_at->format('d/m/Y H:i'));
|
||||
$sheet->setCellValue("B{$row}", $movement->inventory?->name ?? 'N/A');
|
||||
$sheet->setCellValue("C{$row}", $this->translateMovementType($movement->movement_type));
|
||||
$sheet->setCellValue("D{$row}", $movement->warehouseFrom?->name ?? '-');
|
||||
$sheet->setCellValue("E{$row}", $movement->warehouseTo?->name ?? '-');
|
||||
$sheet->setCellValue("F{$row}", $movement->quantity);
|
||||
$sheet->setCellValue("G{$row}", $movement->unit_cost ? '$' . number_format($movement->unit_cost, 2) : '');
|
||||
$sheet->setCellValue("H{$row}", $movement->invoice_reference ?? '');
|
||||
$sheet->setCellValue("I{$row}", $movement->notes ?? '');
|
||||
$sheet->setCellValue("J{$row}", $movement->user?->name ?? '');
|
||||
|
||||
// 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']],
|
||||
]);
|
||||
}
|
||||
|
||||
$row++;
|
||||
}
|
||||
|
||||
// ANCHOS DE COLUMNA
|
||||
$sheet->getColumnDimension('A')->setWidth(18);
|
||||
$sheet->getColumnDimension('B')->setWidth(30);
|
||||
$sheet->getColumnDimension('C')->setWidth(14);
|
||||
$sheet->getColumnDimension('D')->setWidth(18);
|
||||
$sheet->getColumnDimension('E')->setWidth(18);
|
||||
$sheet->getColumnDimension('F')->setWidth(12);
|
||||
$sheet->getColumnDimension('G')->setWidth(14);
|
||||
$sheet->getColumnDimension('H')->setWidth(18);
|
||||
$sheet->getColumnDimension('I')->setWidth(25);
|
||||
$sheet->getColumnDimension('J')->setWidth(16);
|
||||
|
||||
// GUARDAR Y DESCARGAR
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($filePath);
|
||||
|
||||
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/App/PriceController.php
Normal file
29
app/Http/Controllers/App/PriceController.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\PriceUpdateRequest;
|
||||
use App\Models\Price;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class PriceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$prices = Price::with('inventory')->paginate();
|
||||
return ApiResponse::OK->response(['prices' => $prices]);
|
||||
}
|
||||
|
||||
public function show(Price $price)
|
||||
{
|
||||
return ApiResponse::OK->response(['model' => $price->load('inventory')]);
|
||||
}
|
||||
|
||||
// Actualizar solo precio
|
||||
public function update(PriceUpdateRequest $request, Price $precio)
|
||||
{
|
||||
$precio->update($request->validated());
|
||||
return ApiResponse::OK->response(['model' => $precio->fresh('inventory')]);
|
||||
}
|
||||
}
|
||||
90
app/Http/Controllers/App/ReportController.php
Normal file
90
app/Http/Controllers/App/ReportController.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ReportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ReportService $reportService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Obtener el producto más vendido
|
||||
*/
|
||||
public function topSellingProduct(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'include_stock_value' => ['nullable', 'boolean'],
|
||||
'from_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:from_date', 'required_with:from_date'],
|
||||
], [
|
||||
'include_stock_value.boolean' => 'El parámetro de valor de stock debe ser verdadero o falso.',
|
||||
'from_date.date_format' => 'La fecha inicial debe tener el formato Y-m-d (ejemplo: 2025-01-01).',
|
||||
'to_date.date_format' => 'La fecha final debe tener el formato Y-m-d (ejemplo: 2025-01-31).',
|
||||
'to_date.after_or_equal' => 'La fecha final debe ser igual o posterior a la fecha inicial.',
|
||||
'to_date.required_with' => 'La fecha final es obligatoria cuando se proporciona fecha inicial.',
|
||||
]);
|
||||
|
||||
|
||||
try {
|
||||
$product = $this->reportService->getTopSellingProduct(
|
||||
fromDate: $request->input('from_date'),
|
||||
toDate: $request->input('to_date')
|
||||
);
|
||||
|
||||
if ($product === null) {
|
||||
return ApiResponse::OK->response([
|
||||
'product' => null,
|
||||
'message' => 'No hay datos de ventas para el período especificado.'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'product' => $product,
|
||||
'message' => 'Producto más vendido obtenido exitosamente.'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al generar el reporte: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener productos sin movimiento
|
||||
*/
|
||||
public function productsWithoutMovement(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'from_date' => ['required', 'date_format:Y-m-d'],
|
||||
'to_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:from_date'],
|
||||
], [
|
||||
'from_date.required' => 'La fecha inicial es obligatoria.',
|
||||
'from_date.date_format' => 'La fecha inicial debe tener el formato Y-m-d (ejemplo: 2025-01-01).',
|
||||
'to_date.required' => 'La fecha final es obligatoria.',
|
||||
'to_date.date_format' => 'La fecha final debe tener el formato Y-m-d (ejemplo: 2025-01-31).',
|
||||
'to_date.after_or_equal' => 'La fecha final debe ser igual o posterior a la fecha inicial.',
|
||||
]);
|
||||
|
||||
try {
|
||||
$products = $this->reportService->getProductsWithoutMovement(
|
||||
fromDate: $request->input('from_date'),
|
||||
toDate: $request->input('to_date')
|
||||
);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'products' => $products,
|
||||
'message' => 'Reporte de productos sin movimiento generado exitosamente.'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al generar el reporte: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
152
app/Http/Controllers/App/ReturnController.php
Normal file
152
app/Http/Controllers/App/ReturnController.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\ReturnStoreRequest;
|
||||
use App\Services\ReturnService;
|
||||
use App\Models\Returns;
|
||||
use App\Models\Sale;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class ReturnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ReturnService $returnService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Listar devoluciones
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Returns::with([
|
||||
'sale',
|
||||
'user',
|
||||
'cashRegister',
|
||||
'details.inventory',
|
||||
])->orderBy('created_at', 'desc');
|
||||
|
||||
if (!auth()->user()->hasRole('admin')) {
|
||||
$query->where('user_id', auth()->id());
|
||||
}
|
||||
|
||||
// Filtros
|
||||
if ($request->has('q') && $request->q) {
|
||||
$query->where('return_number', 'like', "%{$request->q}%")
|
||||
->orWhereHas('sale', fn ($q) =>
|
||||
$q->where('invoice_number', 'like', "%{$request->q}%")
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->has('sale_id')) {
|
||||
$query->where('sale_id', $request->sale_id);
|
||||
}
|
||||
|
||||
if ($request->has('reason')) {
|
||||
$query->where('reason', $request->reason);
|
||||
}
|
||||
|
||||
if ($request->has('cash_register_id')) {
|
||||
$query->where('cash_register_id', $request->cash_register_id);
|
||||
}
|
||||
|
||||
if ($request->has('from') && $request->has('to')) {
|
||||
$query->whereBetween('created_at', [$request->from, $request->to]);
|
||||
}
|
||||
|
||||
$returns = $query->paginate(config('app.pagination', 15));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'returns' => $returns,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver detalle de una devolución
|
||||
*/
|
||||
public function show(Returns $return)
|
||||
{
|
||||
$return->load([
|
||||
'details.inventory',
|
||||
'details.serials',
|
||||
'sale.details',
|
||||
'user',
|
||||
'cashRegister',
|
||||
]);
|
||||
|
||||
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para ver esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $return,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear nueva devolución
|
||||
*/
|
||||
public function store(ReturnStoreRequest $request)
|
||||
{
|
||||
try {
|
||||
$return = $this->returnService->createReturn($request->validated());
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'model' => $return,
|
||||
'message' => 'Devolución procesada exitosamente. Reembolso: $'.number_format($return->total, 2),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelar una devolución (caso excepcional)
|
||||
*/
|
||||
public function cancel(Returns $return)
|
||||
{
|
||||
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para cancelar esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$cancelledReturn = $this->returnService->cancelReturn($return);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $cancelledReturn,
|
||||
'message' => 'Devolución cancelada exitosamente. Productos restaurados a vendido.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener items elegibles para devolución de una venta
|
||||
*/
|
||||
public function returnable(Sale $sale)
|
||||
{
|
||||
try {
|
||||
$returnableItems = $this->returnService->getReturnableItems($sale);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'sale' => $sale->load('cashRegister'),
|
||||
'returnable_items' => $returnableItems,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
95
app/Http/Controllers/App/SaleController.php
Normal file
95
app/Http/Controllers/App/SaleController.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\SaleStoreRequest;
|
||||
use App\Services\SaleService;
|
||||
use App\Models\Sale;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class SaleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SaleService $saleService
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$sales = Sale::with(['details.inventory', 'details.serials', 'user', 'client'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
// Filtrar por usuario: solo admin puede ver todas las ventas
|
||||
if (!auth()->user()->hasRole('admin')) {
|
||||
$sales->where('user_id', auth()->id());
|
||||
}
|
||||
|
||||
if ($request->has('q') && $request->q) {
|
||||
$sales->where(function ($query) use ($request) {
|
||||
$query->where('invoice_number', 'like', "%{$request->q}%")
|
||||
->orWhereHas('user', fn($q) =>
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->has('cash_register_id')) {
|
||||
$sales->where('cash_register_id', $request->cash_register_id);
|
||||
}
|
||||
|
||||
if ($request->has('status')) {
|
||||
$sales->where('status', $request->status);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'sales' => $sales->paginate(config('app.pagination')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Sale $sale)
|
||||
{
|
||||
// Solo admin puede ver ventas de otros usuarios
|
||||
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para ver esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $sale->load(['details.inventory', 'user', 'client'])
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(SaleStoreRequest $request)
|
||||
{
|
||||
$sale = $this->saleService->createSale($request->validated());
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'model' => $sale,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Sale $sale)
|
||||
{
|
||||
// Solo admin puede cancelar ventas de otros usuarios
|
||||
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para cancelar esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$cancelledSale = $this->saleService->cancelSale($sale);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $cancelledSale,
|
||||
'message' => 'Venta cancelada exitosamente. Stock restaurado.'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/App/SubcategoryController.php
Normal file
69
app/Http/Controllers/App/SubcategoryController.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\SubcategoryStoreRequest;
|
||||
use App\Http\Requests\App\SubcategoryUpdateRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class SubcategoryController extends Controller
|
||||
{
|
||||
public function index(Category $category)
|
||||
{
|
||||
$subcategorias = $category->subcategories()
|
||||
->orderBy('name')
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'subcategories' => $subcategorias,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Category $category, Subcategory $subcategory)
|
||||
{
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $subcategory,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(SubcategoryStoreRequest $request, Category $category)
|
||||
{
|
||||
if($request->isBulk()){
|
||||
$subcategorias = collect($request->validated())
|
||||
->map(fn($data) => $category->subcategories()->create($data));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'models' => $subcategorias,
|
||||
]);
|
||||
}
|
||||
|
||||
$subcategoria = $category->subcategories()->create($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $subcategoria,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SubcategoryUpdateRequest $request, Category $category, Subcategory $subcategory)
|
||||
{
|
||||
$subcategory->update($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $subcategory->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Category $category, Subcategory $subcategory)
|
||||
{
|
||||
if ($subcategory->inventories()->exists()) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'No se puede eliminar la subclasificación porque tiene productos asociados.'
|
||||
]);
|
||||
}
|
||||
|
||||
$subcategory->delete();
|
||||
|
||||
return ApiResponse::OK->response();
|
||||
}
|
||||
}
|
||||
124
app/Http/Controllers/App/SupplierController.php
Normal file
124
app/Http/Controllers/App/SupplierController.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class SupplierController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Supplier::query();
|
||||
|
||||
// Filtro por búsqueda
|
||||
if ($request->has('q')) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('business_name', 'like', "%{$request->q}%")
|
||||
->orWhere('rfc', 'like', "%{$request->q}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Filtro por estado
|
||||
if ($request->has('is_active')) {
|
||||
$query->where('is_active', $request->is_active);
|
||||
}
|
||||
|
||||
$suppliers = $query->orderBy('business_name')
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'suppliers' => $suppliers
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_name' => 'required|string|max:255',
|
||||
'email' => 'nullable|email',
|
||||
'phone' => 'nullable|string|max:10',
|
||||
'rfc' => 'nullable|string|unique:suppliers,rfc',
|
||||
'address' => 'nullable|string',
|
||||
'postal_code' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$supplier = Supplier::create($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'supplier' => $supplier
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Supplier $supplier)
|
||||
{
|
||||
return ApiResponse::OK->response([
|
||||
'supplier' => $supplier->load('inventoryMovements')
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Supplier $supplier)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email',
|
||||
'phone' => 'nullable|string|max:10',
|
||||
'rfc' => 'nullable|string|unique:suppliers,rfc,' . $supplier->id,
|
||||
'address' => 'nullable|string',
|
||||
'postal_code' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$supplier->update($validated);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'supplier' => $supplier->fresh()
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Supplier $supplier)
|
||||
{
|
||||
$supplier->delete();
|
||||
return ApiResponse::OK->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* Productos suministrados por el proveedor
|
||||
*/
|
||||
public function products(Supplier $supplier)
|
||||
{
|
||||
$products = $supplier->suppliedProducts()
|
||||
->with(['category', 'price'])
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'products' => $products
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Historial de compras al proveedor
|
||||
*/
|
||||
public function purchases(Supplier $supplier, Request $request)
|
||||
{
|
||||
$query = $supplier->inventoryMovements()
|
||||
->with(['inventory', 'warehouseTo', 'user'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->has('from_date')) {
|
||||
$query->whereDate('created_at', '>=', $request->from_date);
|
||||
}
|
||||
|
||||
if ($request->has('to_date')) {
|
||||
$query->whereDate('created_at', '<=', $request->to_date);
|
||||
}
|
||||
|
||||
$purchases = $query->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'purchases' => $purchases,
|
||||
'total_amount' => $supplier->total_purchases
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/App/UnitEquivalenceController.php
Normal file
69
app/Http/Controllers/App/UnitEquivalenceController.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\UnitEquivalenceStoreRequest;
|
||||
use App\Http\Requests\App\UnitEquivalenceUpdateRequest;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\UnitEquivalence;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class UnitEquivalenceController extends Controller
|
||||
{
|
||||
public function index(Inventory $inventory)
|
||||
{
|
||||
$equivalences = $inventory->unitEquivalences()
|
||||
->with('unitOfMeasure')
|
||||
->get()
|
||||
->map(function ($eq) use ($inventory) {
|
||||
$basePrice = $inventory->price?->retail_price ?? 0;
|
||||
|
||||
return [
|
||||
'id' => $eq->id,
|
||||
'unit_of_measure_id' => $eq->unit_of_measure_id,
|
||||
'unit_name' => $eq->unitOfMeasure->name,
|
||||
'unit_abbreviation' => $eq->unitOfMeasure->abbreviation,
|
||||
'conversion_factor' => $eq->conversion_factor,
|
||||
'retail_price' => $eq->retail_price ?? round($basePrice * $eq->conversion_factor, 2),
|
||||
'is_active' => $eq->is_active,
|
||||
'created_at' => $eq->created_at,
|
||||
];
|
||||
});
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'equivalences' => $equivalences,
|
||||
'base_unit' => $inventory->unitOfMeasure,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(UnitEquivalenceStoreRequest $request, Inventory $inventory)
|
||||
{
|
||||
$equivalence = $inventory->unitEquivalences()->create($request->validated());
|
||||
$equivalence->load('unitOfMeasure');
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Equivalencia creada correctamente.',
|
||||
'equivalence' => $equivalence,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UnitEquivalenceUpdateRequest $request, Inventory $inventory, UnitEquivalence $equivalencia)
|
||||
{
|
||||
$equivalencia->update($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Equivalencia actualizada correctamente.',
|
||||
'equivalence' => $equivalencia->fresh('unitOfMeasure'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Inventory $inventory, UnitEquivalence $equivalencia)
|
||||
{
|
||||
$equivalencia->delete();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Equivalencia eliminada correctamente.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
87
app/Http/Controllers/App/UnitOfMeasurementController.php
Normal file
87
app/Http/Controllers/App/UnitOfMeasurementController.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\UnitOfMeasurementStoreRequest;
|
||||
use App\Http\Requests\App\UnitOfMeasurementUpdateRequest;
|
||||
use App\Models\UnitOfMeasurement;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*/
|
||||
class UnitOfMeasurementController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = UnitOfMeasurement::query();
|
||||
|
||||
// Filtro por búsqueda
|
||||
if ($request->has('q')) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('abbreviation', 'like', "%{$request->q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$units = $query->orderBy('name')->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'units' => $units
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(UnitOfMeasurement $unit)
|
||||
{
|
||||
return ApiResponse::OK->response([
|
||||
'unit' => $unit
|
||||
]);
|
||||
}
|
||||
|
||||
public function active()
|
||||
{
|
||||
$units = UnitOfMeasurement::active()->orderBy('name')->get();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'units' => $units
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(UnitOfMeasurementStoreRequest $request)
|
||||
{
|
||||
$unit = UnitOfMeasurement::create($request->validated());
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Unidad de medida creada correctamente.',
|
||||
'unit' => $unit
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UnitOfMeasurementUpdateRequest $request, UnitOfMeasurement $unit)
|
||||
{
|
||||
$unit->update($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Unidad de medida actualizada correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(UnitOfMeasurement $unit)
|
||||
{
|
||||
// Verificar si hay productos asociados a esta unidad de medida
|
||||
if ($unit->inventories()->exists()) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'No se puede eliminar esta unidad de medida porque hay productos asociados a ella.'
|
||||
]);
|
||||
}
|
||||
|
||||
$unit->delete();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Unidad de medida eliminada correctamente.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
137
app/Http/Controllers/App/WarehouseController.php
Normal file
137
app/Http/Controllers/App/WarehouseController.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\WarehouseStoreRequest;
|
||||
use App\Http\Requests\App\WarehouseUpdateRequest;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
/**
|
||||
* Controlador para gestión de almacenes
|
||||
*/
|
||||
class WarehouseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Listar almacenes
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$warehouse = Warehouse::query();
|
||||
|
||||
if ($request->has('active')) {
|
||||
$warehouse->where('is_active', $request->boolean('active'));
|
||||
}
|
||||
|
||||
if ($request->has('q') && $request->q) {
|
||||
$warehouse->where(function($query) use ($request) {
|
||||
$query->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('code', 'like', "%{$request->q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$warehouse->orderBy('id', 'ASC');
|
||||
|
||||
$warehouses = $request->boolean('all')
|
||||
? $warehouse->get()
|
||||
: $warehouse->paginate(config('app.pagination'));
|
||||
return ApiResponse::OK->response([
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver detalle de un almacén con su inventario
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$warehouse = Warehouse::find($id);
|
||||
|
||||
if (!$warehouse) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Almacén no encontrado'
|
||||
]);
|
||||
}
|
||||
|
||||
$inventories = $warehouse->inventories()
|
||||
->wherePivot('stock', '>', 0)
|
||||
->paginate(config('app.pagination'));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'warehouse' => $warehouse,
|
||||
'inventories' => $inventories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear almacén
|
||||
*/
|
||||
public function store(WarehouseStoreRequest $request)
|
||||
{
|
||||
$warehouse = Warehouse::create($request->validated());
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Almacén creado correctamente',
|
||||
'warehouse' => $warehouse,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar almacén
|
||||
*/
|
||||
public function update(WarehouseUpdateRequest $request, int $id)
|
||||
{
|
||||
$warehouse = Warehouse::find($id);
|
||||
|
||||
if (!$warehouse) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Almacén no encontrado'
|
||||
]);
|
||||
}
|
||||
|
||||
$warehouse->update($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Almacén actualizado correctamente',
|
||||
'warehouse' => $warehouse,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar almacén
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$warehouse = Warehouse::find($id);
|
||||
|
||||
if (!$warehouse) {
|
||||
return ApiResponse::NOT_FOUND->response([
|
||||
'message' => 'Almacén no encontrado'
|
||||
]);
|
||||
}
|
||||
|
||||
// Verificar si es el almacén principal
|
||||
if ($warehouse->is_main) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'No se puede eliminar el almacén principal'
|
||||
]);
|
||||
}
|
||||
|
||||
// Verificar si tiene stock
|
||||
$hasStock = $warehouse->inventories()->wherePivot('stock', '>', 0)->exists();
|
||||
|
||||
if ($hasStock) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'No se puede eliminar un almacén con stock'
|
||||
]);
|
||||
}
|
||||
|
||||
$warehouse->delete();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Almacén eliminado correctamente'
|
||||
]);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/App/WhatsappController.php
Normal file
85
app/Http/Controllers/App/WhatsappController.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\WhatsappService;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class WhatsappController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected WhatsappService $whatsAppService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Enviar documento por WhatsApp
|
||||
*/
|
||||
public function sendDocument(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'phone_number' => ['required', 'string', 'regex:/^[0-9]{10,13}$/'],
|
||||
'document_url' => ['required', 'url'],
|
||||
'filename' => ['required', 'string', 'max:255'],
|
||||
'ticket' => ['required', 'string', 'max:100'],
|
||||
'customer_name' => ['required', 'string', 'max:255'],
|
||||
], [
|
||||
'phone_number.required' => 'El número de teléfono es obligatorio',
|
||||
'phone_number.regex' => 'El número de teléfono debe tener entre 10 y 13 dígitos',
|
||||
'document_url.required' => 'La URL del documento es obligatoria',
|
||||
'document_url.url' => 'La URL del documento no es válida',
|
||||
]);
|
||||
|
||||
$result = $this->whatsAppService->sendDocument(
|
||||
phoneNumber: $validated['phone_number'],
|
||||
documentUrl: $validated['document_url'],
|
||||
filename: $validated['filename'],
|
||||
userEmail: auth()->user()->email,
|
||||
ticket: $validated['ticket'],
|
||||
customerName: $validated['customer_name']
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Documento enviado correctamente por WhatsApp',
|
||||
'data' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $result['message'],
|
||||
'error' => $result['error'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar factura por WhatsApp
|
||||
*/
|
||||
public function sendInvoice(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'phone_number' => ['required', 'string', 'regex:/^[0-9]{10,13}$/'],
|
||||
'invoice_number' => ['required', 'string'],
|
||||
'pdf_url' => ['required', 'url'],
|
||||
'customer_name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$result = $this->whatsAppService->sendInvoice(
|
||||
phoneNumber: $validated['phone_number'],
|
||||
pdfUrl: $validated['pdf_url'],
|
||||
invoiceNumber: $validated['invoice_number'],
|
||||
customerName: $validated['customer_name']
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Factura enviada correctamente por WhatsApp',
|
||||
'data' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => 'Error al enviar la factura',
|
||||
'error' => $result
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Requests/App/BillStoreRequest.php
Normal file
47
app/Http/Requests/App/BillStoreRequest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BillStoreRequest 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 [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'supplier_id' => ['nullable', 'exists:suppliers,id'],
|
||||
'cost' => ['required', 'numeric', 'min:0'],
|
||||
'deadline' => ['nullable', 'date'],
|
||||
'paid' => ['boolean'],
|
||||
'file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation messages
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'El nombre de la factura es obligatorio.',
|
||||
'supplier_id.exists' => 'El proveedor seleccionado no es válido.',
|
||||
'cost.required' => 'El costo es obligatorio.',
|
||||
'cost.numeric' => 'El costo debe ser un número válido.',
|
||||
'cost.min' => 'El costo debe ser un valor positivo.',
|
||||
'file.mimes' => 'El archivo debe ser PDF o imagen (jpg, jpeg, png).',
|
||||
'file.max' => 'El archivo no puede superar los 10 MB.',
|
||||
];
|
||||
}
|
||||
}
|
||||
47
app/Http/Requests/App/BillUpdateRequest.php
Normal file
47
app/Http/Requests/App/BillUpdateRequest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BillUpdateRequest 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 [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'supplier_id' => ['nullable', 'exists:suppliers,id'],
|
||||
'cost' => ['required', 'numeric', 'min:0'],
|
||||
'deadline' => ['nullable', 'date'],
|
||||
'paid' => ['boolean'],
|
||||
'file' => ['sometimes', 'nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation messages
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'El nombre de la factura es obligatorio.',
|
||||
'supplier_id.exists' => 'El proveedor seleccionado no es válido.',
|
||||
'cost.required' => 'El costo es obligatorio.',
|
||||
'cost.numeric' => 'El costo debe ser un número válido.',
|
||||
'cost.min' => 'El costo debe ser un valor positivo.',
|
||||
'file.mimes' => 'El archivo debe ser PDF o imagen (jpg, jpeg, png).',
|
||||
'file.max' => 'El archivo no puede superar los 10 MB.',
|
||||
];
|
||||
}
|
||||
}
|
||||
73
app/Http/Requests/App/BundleStoreRequest.php
Normal file
73
app/Http/Requests/App/BundleStoreRequest.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BundleStoreRequest 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 [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'sku' => ['required', 'string', 'max:50', 'unique:bundles,sku'],
|
||||
'barcode' => ['nullable', 'string', 'unique:bundles,barcode'],
|
||||
|
||||
// Componentes del kit (mínimo 2 productos)
|
||||
'items' => ['required', 'array', 'min:2'],
|
||||
'items.*.inventory_id' => ['required', 'exists:inventories,id'],
|
||||
'items.*.quantity' => ['required', 'integer', 'min:1'],
|
||||
|
||||
// Precio (opcional, se calcula automáticamente si no se provee)
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation messages
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'El nombre del bundle es obligatorio.',
|
||||
'sku.required' => 'El SKU es obligatorio.',
|
||||
'sku.unique' => 'Este SKU ya está en uso.',
|
||||
'items.required' => 'Debes agregar productos al bundle.',
|
||||
'items.min' => 'Un bundle debe tener al menos 2 productos.',
|
||||
'items.*.inventory_id.required' => 'Cada producto debe tener un ID válido.',
|
||||
'items.*.inventory_id.exists' => 'Uno de los productos no existe.',
|
||||
'items.*.quantity.required' => 'La cantidad es obligatoria.',
|
||||
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Validar que no haya productos duplicados
|
||||
$inventoryIds = collect($this->items)->pluck('inventory_id')->toArray();
|
||||
|
||||
if (count($inventoryIds) !== count(array_unique($inventoryIds))) {
|
||||
$validator->errors()->add(
|
||||
'items',
|
||||
'No se pueden agregar productos duplicados al bundle.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
73
app/Http/Requests/App/BundleUpdateRequest.php
Normal file
73
app/Http/Requests/App/BundleUpdateRequest.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BundleUpdateRequest 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
|
||||
{
|
||||
$bundleId = $this->route('bundle')?->id;
|
||||
|
||||
return [
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'sku' => ['nullable', 'string', 'max:50', 'unique:bundles,sku,' . $bundleId],
|
||||
'barcode' => ['nullable', 'string', 'unique:bundles,barcode,' . $bundleId],
|
||||
|
||||
// Componentes del kit (opcional en update)
|
||||
'items' => ['nullable', 'array', 'min:2'],
|
||||
'items.*.inventory_id' => ['required_with:items', 'exists:inventories,id'],
|
||||
'items.*.quantity' => ['required_with:items', 'integer', 'min:1'],
|
||||
|
||||
// Precio
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'recalculate_price' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation messages
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'sku.unique' => 'Este SKU ya está en uso.',
|
||||
'items.min' => 'Un bundle debe tener al menos 2 productos.',
|
||||
'items.*.inventory_id.exists' => 'Uno de los productos no existe.',
|
||||
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Validar que no haya productos duplicados (si se proporcionan items)
|
||||
if ($this->has('items')) {
|
||||
$inventoryIds = collect($this->items)->pluck('inventory_id')->toArray();
|
||||
|
||||
if (count($inventoryIds) !== count(array_unique($inventoryIds))) {
|
||||
$validator->errors()->add(
|
||||
'items',
|
||||
'No se pueden agregar productos duplicados al bundle.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/App/CategoryStoreRequest.php
Normal file
39
app/Http/Requests/App/CategoryStoreRequest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CategoryStoreRequest 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 [
|
||||
'name' => ['required', 'string', 'max:100', Rule::unique('categories', 'name')->withoutTrashed()],
|
||||
'description' => ['nullable', 'string', 'max:225'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'El nombre es obligatorio.',
|
||||
'name.string' => 'El nombre debe ser una cadena de texto.',
|
||||
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'description.string' => 'La descripción debe ser una cadena de texto.',
|
||||
'description.max' => 'La descripción no debe exceder los 225 caracteres.',
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/App/CategoryUpdateRequest.php
Normal file
39
app/Http/Requests/App/CategoryUpdateRequest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CategoryUpdateRequest 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 [
|
||||
'name' => ['nullable', 'string', 'max:100', Rule::unique('categories', 'name')->ignore($this->route('categoria'))->withoutTrashed()],
|
||||
'description' => ['nullable', 'string', 'max:225'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.string' => 'El nombre debe ser una cadena de texto.',
|
||||
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'description.string' => 'La descripción debe ser una cadena de texto.',
|
||||
'description.max' => 'La descripción no debe exceder los 225 caracteres.',
|
||||
'is_active.boolean' => 'El campo activo debe ser verdadero o falso.',
|
||||
];
|
||||
}
|
||||
}
|
||||
156
app/Http/Requests/App/InventoryEntryRequest.php
Normal file
156
app/Http/Requests/App/InventoryEntryRequest.php
Normal file
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InventoryEntryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
if ($this->has('products')) {
|
||||
return [
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'supplier_id' => 'nullable|exists:suppliers,id',
|
||||
'invoice_reference' => 'required|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
|
||||
// Validación del array de productos
|
||||
'products' => 'required|array|min:1',
|
||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
||||
'products.*.quantity' => 'required|numeric|min:0.001',
|
||||
'products.*.unit_cost' => 'required|numeric|min:0',
|
||||
'products.*.serial_numbers' => 'nullable|array',
|
||||
'products.*.serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
|
||||
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'inventory_id' => 'required|exists:inventories,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'supplier_id' => 'nullable|exists:suppliers,id',
|
||||
'quantity' => 'required|numeric|min:0.001',
|
||||
'unit_cost' => 'required|numeric|min:0',
|
||||
'invoice_reference' => 'required|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'serial_numbers' => 'nullable|array',
|
||||
'serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
|
||||
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes para entrada única
|
||||
'inventory_id.required' => 'El producto es requerido',
|
||||
'inventory_id.exists' => 'El producto no existe',
|
||||
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
|
||||
'serial_numbers.*.string' => 'El número de serie debe ser texto',
|
||||
'serial_numbers.*.distinct' => 'Los números de serie no pueden repetirse',
|
||||
'serial_numbers.*.unique' => 'El número de serie ya existe en el sistema',
|
||||
|
||||
// Mensajes para entrada múltiple
|
||||
'products.required' => 'Debe incluir al menos un producto',
|
||||
'products.*.inventory_id.required' => 'El producto es requerido',
|
||||
'products.*.inventory_id.exists' => 'El producto no existe',
|
||||
'products.*.quantity.required' => 'La cantidad es requerida',
|
||||
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
'products.*.unit_cost.required' => 'El costo unitario es requerido',
|
||||
'products.*.unit_cost.numeric' => 'El costo unitario debe ser un número',
|
||||
'products.*.unit_cost.min' => 'El costo unitario no puede ser negativo',
|
||||
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
|
||||
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
|
||||
'products.*.serial_numbers.*.distinct' => 'Los números de serie no pueden repetirse',
|
||||
'products.*.serial_numbers.*.unique' => 'El número de serie ya existe en el sistema',
|
||||
|
||||
// Mensajes comunes
|
||||
'warehouse_id.required' => 'El almacén es requerido',
|
||||
'warehouse_id.exists' => 'El almacén no existe',
|
||||
'quantity.required' => 'La cantidad es requerida',
|
||||
'quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
'unit_cost.required' => 'El costo unitario es requerido',
|
||||
'invoice_reference.required' => 'La referencia de la factura es requerida',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validaciones adicionales de negocio
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Extraer productos del request (entrada simple o múltiple)
|
||||
$products = $this->has('products') ? $this->products : [[
|
||||
'inventory_id' => $this->inventory_id,
|
||||
'quantity' => $this->quantity,
|
||||
'serial_numbers' => $this->serial_numbers ?? null,
|
||||
]];
|
||||
|
||||
foreach ($products as $index => $product) {
|
||||
$inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']);
|
||||
|
||||
if (! $inventory || ! $inventory->unitOfMeasure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// VALIDACIÓN 1: Cantidades decimales solo con unidades que permiten decimales
|
||||
$quantity = $product['quantity'];
|
||||
$isDecimal = floor($quantity) != $quantity;
|
||||
|
||||
if ($isDecimal && ! $inventory->unitOfMeasure->allows_decimals) {
|
||||
$field = $this->has('products') ? "products.{$index}.quantity" : 'quantity';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
"El producto '{$inventory->name}' usa la unidad '{$inventory->unitOfMeasure->name}' que no permite cantidades decimales. Use cantidades enteras."
|
||||
);
|
||||
}
|
||||
|
||||
// VALIDACIÓN 2: Validar equivalencia de unidad si se especifica
|
||||
$unitOfMeasureId = $product['unit_of_measure_id'] ?? null;
|
||||
if ($unitOfMeasureId && $unitOfMeasureId != $inventory->unit_of_measure_id) {
|
||||
$hasEquivalence = $inventory->unitEquivalences()
|
||||
->where('unit_of_measure_id', $unitOfMeasureId)
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
|
||||
if (! $hasEquivalence) {
|
||||
$field = $this->has('products') ? "products.{$index}.unit_of_measure_id" : 'unit_of_measure_id';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
"El producto '{$inventory->name}' no tiene equivalencia configurada para esta unidad de medida."
|
||||
);
|
||||
}
|
||||
|
||||
if ($inventory->track_serials) {
|
||||
$field = $this->has('products') ? "products.{$index}.unit_of_measure_id" : 'unit_of_measure_id';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
'No se pueden usar equivalencias de unidad para productos con rastreo de seriales.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// VALIDACIÓN 3: No permitir seriales con unidades decimales
|
||||
$serialNumbers = $product['serial_numbers'] ?? null;
|
||||
|
||||
if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) {
|
||||
$field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
"No se pueden registrar números de serie para el producto '{$inventory->name}' porque usa la unidad '{$inventory->unitOfMeasure->name}' que permite cantidades decimales. Los seriales solo son válidos para unidades discretas como 'Pieza'."
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
118
app/Http/Requests/App/InventoryExitRequest.php
Normal file
118
app/Http/Requests/App/InventoryExitRequest.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InventoryExitRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// Si tiene "products" array, es salida múltiple
|
||||
if ($this->has('products')) {
|
||||
return [
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
|
||||
// Validación del array de productos
|
||||
'products' => 'required|array|min:1',
|
||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
||||
'products.*.quantity' => 'required|numeric|min:0.001',
|
||||
'products.*.serial_numbers' => 'nullable|array',
|
||||
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
||||
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
|
||||
];
|
||||
}
|
||||
|
||||
// Salida única (formato original)
|
||||
return [
|
||||
'inventory_id' => 'required|exists:inventories,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'quantity' => 'required|numeric|min:0.001',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'serial_numbers' => 'nullable|array',
|
||||
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
||||
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes para salida única
|
||||
'inventory_id.required' => 'El producto es requerido',
|
||||
'inventory_id.exists' => 'El producto no existe',
|
||||
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
|
||||
'serial_numbers.*.string' => 'El número de serie debe ser texto',
|
||||
'serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
|
||||
|
||||
// Mensajes para salida múltiple
|
||||
'products.required' => 'Debe incluir al menos un producto',
|
||||
'products.*.inventory_id.required' => 'El producto es requerido',
|
||||
'products.*.inventory_id.exists' => 'El producto no existe',
|
||||
'products.*.quantity.required' => 'La cantidad es requerida',
|
||||
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
|
||||
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
|
||||
'products.*.serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
|
||||
|
||||
// Mensajes comunes
|
||||
'warehouse_id.required' => 'El almacén es requerido',
|
||||
'warehouse_id.exists' => 'El almacén no existe',
|
||||
'quantity.required' => 'La cantidad es requerida',
|
||||
'quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional: cantidades decimales solo con unidades que permiten decimales
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$products = $this->has('products') ? $this->products : [[
|
||||
'inventory_id' => $this->inventory_id,
|
||||
'quantity' => $this->quantity,
|
||||
'serial_numbers' => $this->serial_numbers ?? null,
|
||||
]];
|
||||
|
||||
foreach ($products as $index => $product) {
|
||||
$inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']);
|
||||
|
||||
if (! $inventory || ! $inventory->unitOfMeasure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quantity = $product['quantity'];
|
||||
$isDecimal = floor($quantity) != $quantity;
|
||||
|
||||
// Si la cantidad es decimal pero la unidad no permite decimales
|
||||
if ($isDecimal && ! $inventory->unitOfMeasure->allows_decimals) {
|
||||
$field = $this->has('products') ? "products.{$index}.quantity" : 'quantity';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
"El producto '{$inventory->name}' usa la unidad '{$inventory->unitOfMeasure->name}' que no permite cantidades decimales. Use cantidades enteras."
|
||||
);
|
||||
}
|
||||
|
||||
$serialNumbers = $product['serial_numbers'] ?? null;
|
||||
|
||||
if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) {
|
||||
$field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
"No se pueden registrar números de serie para el producto '{$inventory->name}' porque usa la unidad '{$inventory->unitOfMeasure->name}' que permite cantidades decimales. Los seriales solo son válidos para unidades discretas como 'Pieza'."
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
81
app/Http/Requests/App/InventoryImportRequest.php
Normal file
81
app/Http/Requests/App/InventoryImportRequest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InventoryImportRequest 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 [
|
||||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:xlsx,xls,csv',
|
||||
'max:10240', // 10MB máximo
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'file.required' => 'Debe seleccionar un archivo para importar.',
|
||||
'file.file' => 'El archivo no es válido.',
|
||||
'file.mimes' => 'El archivo debe ser de tipo Excel (.xlsx, .xls) o CSV (.csv).',
|
||||
'file.max' => 'El archivo no debe superar los 10MB.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reglas de validación para cada fila del Excel
|
||||
* Solo valida información del catálogo de productos.
|
||||
* Para agregar stock, usar movimientos de entrada (POST /movimientos/entrada).
|
||||
*/
|
||||
public static function rowRules(): array
|
||||
{
|
||||
return [
|
||||
'nombre' => ['required', 'string', 'max:100'],
|
||||
'sku' => ['nullable', 'string', 'max:50'],
|
||||
'codigo_barras' => ['nullable', 'string', 'max:100'],
|
||||
'categoria' => ['nullable', 'string', 'max:100'],
|
||||
'precio_venta' => ['required', 'numeric', 'min:0.01'],
|
||||
'impuesto' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mensajes personalizados de validación para las filas del Excel
|
||||
*/
|
||||
public static function rowMessages(): array
|
||||
{
|
||||
return [
|
||||
'nombre.required' => 'El nombre del producto es requerido.',
|
||||
'nombre.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
|
||||
'codigo_barras.max' => 'El código de barras no debe exceder los 100 caracteres.',
|
||||
'categoria.max' => 'El nombre de la categoría no debe exceder los 100 caracteres.',
|
||||
'precio_venta.required' => 'El precio de venta es requerido.',
|
||||
'precio_venta.numeric' => 'El precio de venta debe ser un número.',
|
||||
'precio_venta.min' => 'El precio de venta debe ser mayor a 0.',
|
||||
'impuesto.numeric' => 'El impuesto debe ser un número.',
|
||||
'impuesto.min' => 'El impuesto no puede ser negativo.',
|
||||
'impuesto.max' => 'El impuesto no puede exceder el 100%.',
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Http/Requests/App/InventoryMovementUpdateRequest.php
Normal file
50
app/Http/Requests/App/InventoryMovementUpdateRequest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InventoryMovementUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'quantity' => 'sometimes|integer|min:1',
|
||||
'unit_cost' => 'sometimes|numeric|min:0',
|
||||
'warehouse_from_id' => 'sometimes|nullable|exists:warehouses,id',
|
||||
'warehouse_to_id' => 'sometimes|nullable|exists:warehouses,id',
|
||||
'invoice_reference' => 'sometimes|nullable|string|max:255',
|
||||
'notes' => 'sometimes|nullable|string|max:500',
|
||||
'serial_numbers' => ['nullable', 'array'],
|
||||
'serial_numbers.*' => ['string', 'max:255'],
|
||||
'supplier_id' => 'sometimes|nullable|exists:suppliers,id',
|
||||
'unit_of_measure_id' => 'sometimes|nullable|exists:units_of_measurement,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'quantity.required' => 'La cantidad es requerida',
|
||||
'quantity.integer' => 'La cantidad debe ser un número entero',
|
||||
'quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
'unit_cost.required' => 'El costo unitario es requerido',
|
||||
'unit_cost.numeric' => 'El costo unitario debe ser un número',
|
||||
'unit_cost.min' => 'El costo unitario no puede ser negativo',
|
||||
'warehouse_from_id.required' => 'El almacén origen es requerido',
|
||||
'warehouse_from_id.exists' => 'El almacén origen no existe',
|
||||
'warehouse_from_id.different' => 'El almacén origen debe ser diferente al destino',
|
||||
'warehouse_to_id.required' => 'El almacén destino es requerido',
|
||||
'warehouse_to_id.exists' => 'El almacén destino no existe',
|
||||
'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen',
|
||||
'notes.max' => 'Las notas no pueden exceder 1000 caracteres',
|
||||
'invoice_reference.max' => 'La referencia de factura no puede exceder 255 caracteres',
|
||||
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Http/Requests/App/InventoryStoreRequest.php
Normal file
108
app/Http/Requests/App/InventoryStoreRequest.php
Normal file
@ -0,0 +1,108 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InventoryStoreRequest 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 [
|
||||
// Campos de Inventory
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'key_sat' => ['nullable', 'string', 'max:20'],
|
||||
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
|
||||
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'subcategory_id' => ['nullable', 'required_with:category_id', 'exists:subcategories,id'],
|
||||
'unit_of_measure_id' => ['required', 'exists:units_of_measurement,id'],
|
||||
'track_serials' => ['nullable', 'boolean'],
|
||||
|
||||
// Campos de Price
|
||||
'cost' => ['nullable', 'numeric', 'min:0'],
|
||||
'retail_price' => ['required', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes de Inventory
|
||||
'name.required' => 'El nombre es obligatorio.',
|
||||
'name.string' => 'El nombre debe ser una cadena de texto.',
|
||||
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'sku.string' => 'El SKU debe ser una cadena de texto.',
|
||||
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
|
||||
'sku.unique' => 'El SKU ya está en uso.',
|
||||
'barcode.string' => 'El código de barras debe ser una cadena de texto.',
|
||||
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
|
||||
'category_id.exists' => 'La clasificación seleccionada no es válida.',
|
||||
'subcategory_id.required_with' => 'La subclasificación es obligatoria cuando se asigna una clasificación.',
|
||||
'subcategory_id.exists' => 'La subclasificación seleccionada no es válida.',
|
||||
// Mensajes de Price
|
||||
'retail_price.required' => 'El precio de venta es obligatorio.',
|
||||
'retail_price.numeric' => 'El precio de venta debe ser un número.',
|
||||
'retail_price.min' => 'El precio de venta no puede ser negativo.',
|
||||
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
|
||||
'tax.numeric' => 'El impuesto debe ser un número.',
|
||||
'tax.min' => 'El impuesto no puede ser negativo.',
|
||||
'tax.max' => 'El impuesto no puede exceder el 100%.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación condicional: retail_price > cost solo si cost > 0
|
||||
* Y validación track_serials con unidades decimales
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Validar que la subcategoría pertenezca a la categoría seleccionada
|
||||
$categoryId = $this->input('category_id');
|
||||
$subcategoryId = $this->input('subcategory_id');
|
||||
|
||||
if ($categoryId && $subcategoryId) {
|
||||
$subcategory = \App\Models\Subcategory::find($subcategoryId);
|
||||
if ($subcategory && (int) $subcategory->category_id !== (int) $categoryId) {
|
||||
$validator->errors()->add(
|
||||
'subcategory_id',
|
||||
'La subclasificación no pertenece a la clasificación seleccionada.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$cost = $this->input('cost');
|
||||
$retailPrice = $this->input('retail_price');
|
||||
|
||||
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
|
||||
$validator->errors()->add(
|
||||
'retail_price',
|
||||
'El precio de venta debe ser mayor que el costo.'
|
||||
);
|
||||
}
|
||||
|
||||
// Validar incompatibilidad track_serials + unidades decimales
|
||||
if ($this->input('track_serials')) {
|
||||
$unit = \App\Models\UnitOfMeasurement::find($this->input('unit_of_measure_id'));
|
||||
if ($unit && $unit->allows_decimals) {
|
||||
$validator->errors()->add(
|
||||
'track_serials',
|
||||
'No se pueden usar seriales con unidades fraccionarias (kg, L, m). Cambia a Unidad/Pieza/Caja o desactiva los seriales.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
125
app/Http/Requests/App/InventoryTransferRequest.php
Normal file
125
app/Http/Requests/App/InventoryTransferRequest.php
Normal file
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InventoryTransferRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// Si tiene "products" array, es traspaso múltiple
|
||||
if ($this->has('products')) {
|
||||
return [
|
||||
'warehouse_from_id' => 'required|exists:warehouses,id',
|
||||
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
|
||||
// Validación del array de productos
|
||||
'products' => 'required|array|min:1',
|
||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
||||
'products.*.quantity' => 'required|numeric|min:0.001',
|
||||
'products.*.serial_numbers' => 'nullable|array',
|
||||
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
||||
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
|
||||
];
|
||||
}
|
||||
|
||||
// Traspaso único (formato original)
|
||||
return [
|
||||
'inventory_id' => 'required|exists:inventories,id',
|
||||
'warehouse_from_id' => 'required|exists:warehouses,id',
|
||||
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id',
|
||||
'quantity' => 'required|numeric|min:0.001',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'serial_numbers' => 'nullable|array',
|
||||
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
||||
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes para traspaso único
|
||||
'inventory_id.required' => 'El producto es requerido',
|
||||
'inventory_id.exists' => 'El producto no existe',
|
||||
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
|
||||
'serial_numbers.*.string' => 'El número de serie debe ser texto',
|
||||
'serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
|
||||
|
||||
// Mensajes para traspaso múltiple
|
||||
'products.required' => 'Debe incluir al menos un producto',
|
||||
'products.*.inventory_id.required' => 'El producto es requerido',
|
||||
'products.*.inventory_id.exists' => 'El producto no existe',
|
||||
'products.*.quantity.required' => 'La cantidad es requerida',
|
||||
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
|
||||
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
|
||||
'products.*.serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
|
||||
|
||||
// Mensajes comunes
|
||||
'warehouse_from_id.required' => 'El almacén origen es requerido',
|
||||
'warehouse_from_id.exists' => 'El almacén origen no existe',
|
||||
'warehouse_to_id.required' => 'El almacén destino es requerido',
|
||||
'warehouse_to_id.exists' => 'El almacén destino no existe',
|
||||
'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen',
|
||||
'quantity.required' => 'La cantidad es requerida',
|
||||
'quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional: cantidades decimales solo con unidades que permiten decimales
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Extraer productos del request (entrada simple o múltiple)
|
||||
$products = $this->has('products') ? $this->products : [[
|
||||
'inventory_id' => $this->inventory_id,
|
||||
'quantity' => $this->quantity,
|
||||
'serial_numbers' => $this->serial_numbers ?? null,
|
||||
]];
|
||||
|
||||
foreach ($products as $index => $product) {
|
||||
$inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']);
|
||||
|
||||
if (! $inventory || ! $inventory->unitOfMeasure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// VALIDACIÓN 1: Cantidades decimales solo con unidades que permiten decimales
|
||||
$quantity = $product['quantity'];
|
||||
$isDecimal = floor($quantity) != $quantity;
|
||||
|
||||
if ($isDecimal && ! $inventory->unitOfMeasure->allows_decimals) {
|
||||
$field = $this->has('products') ? "products.{$index}.quantity" : 'quantity';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
"El producto '{$inventory->name}' usa la unidad '{$inventory->unitOfMeasure->name}' que no permite cantidades decimales. Use cantidades enteras."
|
||||
);
|
||||
}
|
||||
|
||||
// VALIDACIÓN 2: No permitir seriales con unidades decimales
|
||||
$serialNumbers = $product['serial_numbers'] ?? null;
|
||||
|
||||
if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) {
|
||||
$field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers';
|
||||
$validator->errors()->add(
|
||||
$field,
|
||||
"No se pueden registrar números de serie para el producto '{$inventory->name}' porque usa la unidad '{$inventory->unitOfMeasure->name}' que permite cantidades decimales. Los seriales solo son válidos para unidades discretas como 'Pieza'."
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
132
app/Http/Requests/App/InventoryUpdateRequest.php
Normal file
132
app/Http/Requests/App/InventoryUpdateRequest.php
Normal file
@ -0,0 +1,132 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InventoryUpdateRequest 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
|
||||
{
|
||||
$inventoryId = $this->route('inventario')?->id;
|
||||
|
||||
return [
|
||||
// Campos de Inventory
|
||||
'name' => ['nullable', 'string', 'max:100'],
|
||||
'key_sat' => ['nullable', 'string', 'max:20'],
|
||||
'sku' => ['nullable', 'string', 'max:50'],
|
||||
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'subcategory_id' => ['nullable', 'required_with:category_id', 'exists:subcategories,id'],
|
||||
'unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
|
||||
'track_serials' => ['nullable', 'boolean'],
|
||||
|
||||
// Campos de Price
|
||||
'cost' => ['nullable', 'numeric', 'min:0'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes de Inventory
|
||||
'name.string' => 'El nombre debe ser una cadena de texto.',
|
||||
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'sku.string' => 'El SKU debe ser una cadena de texto.',
|
||||
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
|
||||
'sku.unique' => 'El SKU ya está en uso.',
|
||||
'barcode.string' => 'El código de barras debe ser una cadena de texto.',
|
||||
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
|
||||
'category_id.exists' => 'La clasificación seleccionada no es válida.',
|
||||
'subcategory_id.required_with' => 'La subclasificación es obligatoria cuando se asigna una clasificación.',
|
||||
'subcategory_id.exists' => 'La subclasificación seleccionada no es válida.',
|
||||
// Mensajes de Price
|
||||
'cost.numeric' => 'El costo debe ser un número.',
|
||||
'cost.min' => 'El costo no puede ser negativo.',
|
||||
'retail_price.numeric' => 'El precio de venta debe ser un número.',
|
||||
'retail_price.min' => 'El precio de venta no puede ser negativo.',
|
||||
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
|
||||
'tax.numeric' => 'El impuesto debe ser un número.',
|
||||
'tax.min' => 'El impuesto no puede ser negativo.',
|
||||
'tax.max' => 'El impuesto no puede exceder el 100%.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación condicional: retail_price > cost solo si cost > 0
|
||||
* Y validación track_serials con unidades decimales
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
/** @var \App\Models\Inventory $inventory */
|
||||
$inventory = $this->route('inventario');
|
||||
|
||||
// Validar que la subcategoría pertenezca a la categoría seleccionada
|
||||
$categoryId = $this->input('category_id', $inventory?->category_id);
|
||||
$subcategoryId = $this->input('subcategory_id');
|
||||
|
||||
if ($subcategoryId && $categoryId) {
|
||||
$subcategory = \App\Models\Subcategory::find($subcategoryId);
|
||||
if ($subcategory && (int) $subcategory->category_id !== (int) $categoryId) {
|
||||
$validator->errors()->add(
|
||||
'subcategory_id',
|
||||
'La subclasificación no pertenece a la clasificación seleccionada.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Bloquear cambio de unidad de medida si el producto ya tiene movimientos de inventario
|
||||
if ($this->has('unit_of_measure_id') && $inventory) {
|
||||
$newUnitId = $this->input('unit_of_measure_id');
|
||||
$currentUnitId = $inventory->unit_of_measure_id;
|
||||
|
||||
if ((int) $newUnitId !== (int) $currentUnitId) {
|
||||
$hasMovements = \App\Models\InventoryMovement::where('inventory_id', $inventory->id)->exists();
|
||||
if ($hasMovements) {
|
||||
$validator->errors()->add(
|
||||
'unit_of_measure_id',
|
||||
'No se puede cambiar la unidad de medida porque el producto ya tiene existencias registradas.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cost = $this->input('cost');
|
||||
$retailPrice = $this->input('retail_price');
|
||||
|
||||
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
|
||||
$validator->errors()->add(
|
||||
'retail_price',
|
||||
'El precio de venta debe ser mayor que el costo.'
|
||||
);
|
||||
}
|
||||
|
||||
// Validar incompatibilidad track_serials + unidades decimales
|
||||
$trackSerials = $this->input('track_serials', $inventory?->track_serials);
|
||||
$unitId = $this->input('unit_of_measure_id', $inventory?->unit_of_measure_id);
|
||||
|
||||
if ($trackSerials && $unitId) {
|
||||
$unit = \App\Models\UnitOfMeasurement::find($unitId);
|
||||
if ($unit && $unit->allows_decimals) {
|
||||
$validator->errors()->add(
|
||||
'track_serials',
|
||||
'No se pueden usar seriales con unidades fraccionarias (kg, L, m). Cambia a Unidad/Pieza/Caja o desactiva los seriales.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/App/InvoiceRequestProcessRequest.php
Normal file
36
app/Http/Requests/App/InvoiceRequestProcessRequest.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InvoiceRequestProcessRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Autorización manejada por middleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'notes.max' => 'Las notas no pueden exceder 500 caracteres',
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/App/InvoiceRequestRejectRequest.php
Normal file
38
app/Http/Requests/App/InvoiceRequestRejectRequest.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InvoiceRequestRejectRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Autorización manejada por middleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'notes' => ['required', 'string', 'min:10', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'notes.required' => 'Debe proporcionar una razón para rechazar la solicitud',
|
||||
'notes.min' => 'La razón debe tener al menos 10 caracteres',
|
||||
'notes.max' => 'La razón no puede exceder 500 caracteres',
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/App/InvoiceRequestUploadRequest.php
Normal file
53
app/Http/Requests/App/InvoiceRequestUploadRequest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class InvoiceRequestUploadRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determinar si el usuario está autorizado para realizar esta solicitud
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Autorización manejada por middleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener las reglas de validación que se aplican a la solicitud
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'invoice_xml' => ['nullable', 'file', 'mimes:xml', 'max:2048'], // Max 2MB
|
||||
'invoice_pdf' => ['required', 'file', 'mimes:pdf', 'max:5120'], // Max 5MB
|
||||
'cfdi_uuid' => ['required', 'string', 'size:36', 'regex:/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mensajes personalizados de validación
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'invoice_xml.mimes' => 'El archivo debe ser un XML válido',
|
||||
'invoice_xml.max' => 'El archivo XML no puede ser mayor a 2MB',
|
||||
'invoice_pdf.required' => 'El archivo PDF de la factura es obligatorio',
|
||||
'invoice_pdf.mimes' => 'El archivo debe ser un PDF válido',
|
||||
'invoice_pdf.max' => 'El archivo PDF no puede ser mayor a 5MB',
|
||||
'cfdi_uuid.required' => 'El UUID del CFDI es obligatorio',
|
||||
'cfdi_uuid.size' => 'El UUID del CFDI debe tener 36 caracteres',
|
||||
'cfdi_uuid.regex' => 'El formato del UUID del CFDI no es válido',
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Http/Requests/App/InvoiceStoreRequest.php
Normal file
50
app/Http/Requests/App/InvoiceStoreRequest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InvoiceStoreRequest 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 [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'rfc' => ['required', 'string', 'min:12', 'max:13', 'regex:/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i'],
|
||||
'razon_social' => ['required', 'string', 'max:255'],
|
||||
'regimen_fiscal' => ['required', 'string', 'max:100'],
|
||||
'cp_fiscal' => ['required', 'string', 'size:5', 'regex:/^\d{5}$/'],
|
||||
'uso_cfdi' => ['required', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'rfc.regex' => 'El RFC no tiene un formato válido',
|
||||
'rfc.min' => 'El RFC debe tener al menos 12 caracteres',
|
||||
'rfc.max' => 'El RFC debe tener máximo 13 caracteres',
|
||||
'cp_fiscal.regex' => 'El código postal debe ser de 5 dígitos',
|
||||
'cp_fiscal.size' => 'El código postal debe ser de 5 dígitos',
|
||||
'name.required' => 'El nombre es obligatorio',
|
||||
'email.required' => 'El correo electrónico es obligatorio',
|
||||
'email.email' => 'El correo electrónico debe ser válido',
|
||||
'razon_social.required' => 'La razón social es obligatoria',
|
||||
'regimen_fiscal.required' => 'El régimen fiscal es obligatorio',
|
||||
'uso_cfdi.required' => 'El uso de CFDI es obligatorio',
|
||||
];
|
||||
}
|
||||
}
|
||||
59
app/Http/Requests/App/PriceUpdateRequest.php
Normal file
59
app/Http/Requests/App/PriceUpdateRequest.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PriceUpdateRequest 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 [
|
||||
'cost' => ['nullable', 'numeric', 'min:0'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'cost.numeric' => 'El costo debe ser un número.',
|
||||
'cost.min' => 'El costo no puede ser negativo.',
|
||||
'retail_price.numeric' => 'El precio de venta debe ser un número.',
|
||||
'retail_price.min' => 'El precio de venta no puede ser negativo.',
|
||||
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
|
||||
'tax.numeric' => 'El impuesto debe ser un número.',
|
||||
'tax.min' => 'El impuesto no puede ser negativo.',
|
||||
'tax.max' => 'El impuesto no puede exceder el 100%.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación condicional: retail_price > cost solo si cost > 0
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$cost = $this->input('cost');
|
||||
$retailPrice = $this->input('retail_price');
|
||||
|
||||
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
|
||||
$validator->errors()->add(
|
||||
'retail_price',
|
||||
'El precio de venta debe ser mayor que el costo.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
154
app/Http/Requests/App/ReturnStoreRequest.php
Normal file
154
app/Http/Requests/App/ReturnStoreRequest.php
Normal file
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use App\Models\InventorySerial;
|
||||
use App\Models\ReturnDetail;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReturnStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'sale_id' => ['required', 'exists:sales,id'],
|
||||
'user_id' => ['required', 'exists:users,id'],
|
||||
'cash_register_id' => ['nullable', 'exists:cash_registers,id'],
|
||||
'reason' => ['required', 'in:defective,wrong_product,change_of_mind,damaged,other'],
|
||||
'refund_method' => ['nullable', 'in:cash,credit_card,debit_card'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
|
||||
// Items a devolver
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.sale_detail_id' => ['required', 'exists:sale_details,id'],
|
||||
'items.*.quantity_returned' => ['required', 'integer', 'min:1'],
|
||||
'items.*.serial_numbers' => ['nullable', 'array'],
|
||||
'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'],
|
||||
'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'sale_id.required' => 'La venta es obligatoria.',
|
||||
'sale_id.exists' => 'La venta seleccionada no existe.',
|
||||
'reason.required' => 'El motivo de devolución es obligatorio.',
|
||||
'reason.in' => 'El motivo debe ser: defectuoso, producto incorrecto, cambio de opinión, dañado u otro.',
|
||||
'refund_method.in' => 'El método de reembolso debe ser: efectivo, tarjeta de crédito o débito.',
|
||||
|
||||
'items.required' => 'Debe incluir al menos un producto a devolver.',
|
||||
'items.min' => 'Debe incluir al menos un producto.',
|
||||
'items.*.sale_detail_id.required' => 'El detalle de venta es obligatorio.',
|
||||
'items.*.sale_detail_id.exists' => 'El detalle de venta no existe.',
|
||||
'items.*.quantity_returned.required' => 'La cantidad devuelta es obligatoria.',
|
||||
'items.*.quantity_returned.integer' => 'La cantidad debe ser un número entero.',
|
||||
'items.*.quantity_returned.min' => 'La cantidad debe ser al menos 1.',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// 1. Validar que la venta exista y esté completada
|
||||
$sale = Sale::find($this->sale_id);
|
||||
|
||||
if (! $sale) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($sale->status !== 'completed') {
|
||||
$validator->errors()->add(
|
||||
'sale_id',
|
||||
'Solo se pueden devolver productos de ventas completadas.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Validar que refund_method coincida con payment_method (si se especifica)
|
||||
if ($this->refund_method && $this->refund_method !== $sale->payment_method) {
|
||||
$validator->errors()->add(
|
||||
'refund_method',
|
||||
"El método de reembolso debe coincidir con el método de pago original ({$sale->payment_method})."
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validar cada item
|
||||
foreach ($this->items ?? [] as $index => $item) {
|
||||
$saleDetail = SaleDetail::find($item['sale_detail_id']);
|
||||
|
||||
if (! $saleDetail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validar que el sale_detail pertenece a la venta
|
||||
if ($saleDetail->sale_id !== $sale->id) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.sale_detail_id",
|
||||
'El producto no pertenece a esta venta.'
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validar cantidades
|
||||
$alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id)
|
||||
->sum('quantity_returned');
|
||||
|
||||
$maxReturnable = $saleDetail->quantity - $alreadyReturned;
|
||||
|
||||
if ($item['quantity_returned'] > $maxReturnable) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.quantity_returned",
|
||||
"Solo puedes devolver {$maxReturnable} unidades de {$saleDetail->product_name}. ".
|
||||
"Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas."
|
||||
);
|
||||
}
|
||||
|
||||
// Validar seriales solo si se proporcionaron (array vacío = sin serials)
|
||||
if (! empty($item['serial_numbers'])) {
|
||||
if (count($item['serial_numbers']) !== $item['quantity_returned']) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.serial_numbers",
|
||||
"Debes especificar exactamente {$item['quantity_returned']} números de serie."
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($item['serial_numbers'] as $serialIndex => $serialNumber) {
|
||||
$serial = InventorySerial::where('serial_number', $serialNumber)
|
||||
->where('sale_detail_id', $saleDetail->id)
|
||||
->where('status', 'vendido')
|
||||
->first();
|
||||
|
||||
if (! $serial) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.serial_numbers.{$serialIndex}",
|
||||
"El serial {$serialNumber} no pertenece a esta venta o ya fue devuelto."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validar caja registradora si se especificó
|
||||
if ($this->cash_register_id) {
|
||||
$cashRegister = \App\Models\CashRegister::find($this->cash_register_id);
|
||||
|
||||
if ($cashRegister && ! $cashRegister->isOpen()) {
|
||||
$validator->errors()->add(
|
||||
'cash_register_id',
|
||||
'La caja registradora debe estar abierta para procesar devoluciones.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
124
app/Http/Requests/App/SaleStoreRequest.php
Normal file
124
app/Http/Requests/App/SaleStoreRequest.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SaleStoreRequest 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 [
|
||||
// Datos de la venta
|
||||
'client_id' => ['nullable', 'exists:clients,id'],
|
||||
'client_number' => ['nullable', 'exists:clients,client_number'],
|
||||
'user_id' => ['required', 'exists:users,id'],
|
||||
'subtotal' => ['required', 'numeric', 'min:0'],
|
||||
'tax' => ['required', 'numeric', 'min:0'],
|
||||
'total' => ['required', 'numeric', 'min:0'],
|
||||
'payment_method' => ['required', 'in:cash,credit_card,debit_card'],
|
||||
|
||||
// Campos para pagos en efectivo
|
||||
'cash_received' => ['required_if:payment_method,cash', 'nullable', 'numeric', 'min:0'],
|
||||
|
||||
// Items del carrito
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
|
||||
// Items pueden ser productos O bundles
|
||||
'items.*.type' => ['nullable', 'in:product,bundle'],
|
||||
'items.*.bundle_id' => ['required_if:items.*.type,bundle', 'exists:bundles,id'],
|
||||
|
||||
// Para productos normales
|
||||
'items.*.inventory_id' => ['required_if:items.*.type,product', 'exists:inventories,id'],
|
||||
'items.*.product_name' => ['required_if:items.*.type,product', 'string', 'max:255'],
|
||||
'items.*.unit_price' => ['required_if:items.*.type,product', 'numeric', 'min:0'],
|
||||
'items.*.subtotal' => ['required_if:items.*.type,product', 'numeric', 'min:0'],
|
||||
|
||||
// Comunes a ambos
|
||||
'items.*.quantity' => ['required', 'numeric', 'min:0.001'],
|
||||
'items.*.warehouse_id' => ['nullable', 'exists:warehouses,id'],
|
||||
|
||||
// Seriales
|
||||
// Productos normales: ["SN-001", "SN-002"]
|
||||
// Bundles: { inventory_id: ["SN-001", "SN-002"], ... }
|
||||
'items.*.serial_numbers' => ['nullable', 'array'],
|
||||
|
||||
// Equivalencia de unidad de medida
|
||||
'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes de Sale
|
||||
'user_id.required' => 'El usuario es obligatorio.',
|
||||
'user_id.exists' => 'El usuario seleccionado no existe.',
|
||||
'subtotal.required' => 'El subtotal es obligatorio.',
|
||||
'subtotal.numeric' => 'El subtotal debe ser un número.',
|
||||
'subtotal.min' => 'El subtotal no puede ser negativo.',
|
||||
'tax.required' => 'El impuesto es obligatorio.',
|
||||
'tax.numeric' => 'El impuesto debe ser un número.',
|
||||
'tax.min' => 'El impuesto no puede ser negativo.',
|
||||
'total.required' => 'El total es obligatorio.',
|
||||
'total.numeric' => 'El total debe ser un número.',
|
||||
'total.min' => 'El total no puede ser negativo.',
|
||||
'payment_method.required' => 'El método de pago es obligatorio.',
|
||||
'payment_method.in' => 'El método de pago debe ser: efectivo, tarjeta de crédito o débito.',
|
||||
|
||||
// Mensajes de cash_received
|
||||
'cash_received.required_if' => 'El dinero recibido es obligatorio para pagos en efectivo.',
|
||||
'cash_received.numeric' => 'El dinero recibido debe ser un número.',
|
||||
'cash_received.min' => 'El dinero recibido no puede ser negativo.',
|
||||
|
||||
// Mensajes de Items
|
||||
'items.required' => 'Debe incluir al menos un producto.',
|
||||
'items.array' => 'Los items deben ser un arreglo.',
|
||||
'items.min' => 'Debe incluir al menos un producto.',
|
||||
'items.*.inventory_id.required' => 'El ID del producto es obligatorio.',
|
||||
'items.*.inventory_id.exists' => 'El producto seleccionado no existe.',
|
||||
'items.*.product_name.required' => 'El nombre del producto es obligatorio.',
|
||||
'items.*.quantity.required' => 'La cantidad es obligatoria.',
|
||||
'items.*.quantity.numeric' => 'La cantidad debe ser un número.',
|
||||
'items.*.quantity.min' => 'La cantidad debe ser mayor a 0.',
|
||||
'items.*.unit_price.required' => 'El precio unitario es obligatorio.',
|
||||
'items.*.unit_price.numeric' => 'El precio unitario debe ser un número.',
|
||||
'items.*.unit_price.min' => 'El precio unitario no puede ser negativo.',
|
||||
'items.*.subtotal.required' => 'El subtotal del item es obligatorio.',
|
||||
'items.*.subtotal.numeric' => 'El subtotal del item debe ser un número.',
|
||||
'items.*.subtotal.min' => 'El subtotal del item no puede ser negativo.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional después de las reglas básicas
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Validar que el dinero recibido sea suficiente para pagos en efectivo
|
||||
if ($this->payment_method === 'cash' && $this->cash_received !== null) {
|
||||
if ($this->cash_received < $this->total) {
|
||||
$validator->errors()->add(
|
||||
'cash_received',
|
||||
'El dinero recibido debe ser mayor o igual al total de la venta ($'.number_format($this->total, 2).').'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
56
app/Http/Requests/App/SubcategoryStoreRequest.php
Normal file
56
app/Http/Requests/App/SubcategoryStoreRequest.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SubcategoryStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$uniqueRule = Rule::unique('subcategories', 'name')
|
||||
->where('category_id', $this->route('category')->id)
|
||||
->withoutTrashed();
|
||||
|
||||
if($this->isBulk()) {
|
||||
return [
|
||||
'*.name' => ['required', 'string', 'max:100', $uniqueRule],
|
||||
'*.description' => ['nullable', 'string', 'max:255'],
|
||||
'*.is_active' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', $uniqueRule],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'*.name.required' => 'El nombre es obligatorio.',
|
||||
'*.name.string' => 'El nombre debe ser una cadena de texto.',
|
||||
'*.name.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'*.description.string' => 'La descripción debe ser una cadena de texto.',
|
||||
'*.description.max' => 'La descripción no debe exceder los 255 caracteres.',
|
||||
'name.required' => 'El nombre es obligatorio.',
|
||||
'name.string' => 'El nombre debe ser una cadena de texto.',
|
||||
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'description.string' => 'La descripción debe ser una cadena de texto.',
|
||||
'description.max' => 'La descripción no debe exceder los 255 caracteres.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si el payload es un arreglo de objetos
|
||||
*/
|
||||
public function isBulk(): bool
|
||||
{
|
||||
return is_array($this->all()) && array_is_list($this->all());
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/App/SubcategoryUpdateRequest.php
Normal file
32
app/Http/Requests/App/SubcategoryUpdateRequest.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SubcategoryUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['nullable', 'string', 'max:100', Rule::unique('subcategories', 'name')->where('category_id', $this->route('category')->id)->ignore($this->route('subcategory'))->withoutTrashed()],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.string' => 'El nombre debe ser una cadena de texto.',
|
||||
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
|
||||
'description.string' => 'La descripción debe ser una cadena de texto.',
|
||||
'description.max' => 'La descripción no debe exceder los 255 caracteres.',
|
||||
'is_active.boolean' => 'El campo activo debe ser verdadero o falso.',
|
||||
];
|
||||
}
|
||||
}
|
||||
65
app/Http/Requests/App/UnitEquivalenceStoreRequest.php
Normal file
65
app/Http/Requests/App/UnitEquivalenceStoreRequest.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UnitEquivalenceStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'unit_of_measure_id' => [
|
||||
'required',
|
||||
'exists:units_of_measurement,id',
|
||||
Rule::unique('unit_equivalences')->where(function ($query) {
|
||||
return $query->where('inventory_id', $this->route('inventory')->id);
|
||||
}),
|
||||
],
|
||||
'conversion_factor' => ['required', 'numeric', 'min:0.001'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$inventory = $this->route('inventory');
|
||||
|
||||
if ($inventory->track_serials) {
|
||||
$validator->errors()->add(
|
||||
'unit_of_measure_id',
|
||||
'No se pueden crear equivalencias para productos con rastreo de seriales.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->unit_of_measure_id == $inventory->unit_of_measure_id) {
|
||||
$validator->errors()->add(
|
||||
'unit_of_measure_id',
|
||||
'No se puede crear una equivalencia con la unidad base del producto.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'unit_of_measure_id.required' => 'La unidad de medida es obligatoria.',
|
||||
'unit_of_measure_id.exists' => 'La unidad de medida seleccionada no existe.',
|
||||
'unit_of_measure_id.unique' => 'Ya existe una equivalencia para esta unidad en este producto.',
|
||||
'conversion_factor.required' => 'El factor de conversión es obligatorio.',
|
||||
'conversion_factor.numeric' => 'El factor de conversión debe ser un número.',
|
||||
'conversion_factor.min' => 'El factor de conversión debe ser mayor a 0.',
|
||||
'retail_price.numeric' => 'El precio de venta debe ser un número.',
|
||||
'retail_price.min' => 'El precio de venta no puede ser negativo.',
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/App/UnitEquivalenceUpdateRequest.php
Normal file
32
app/Http/Requests/App/UnitEquivalenceUpdateRequest.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UnitEquivalenceUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'conversion_factor' => ['sometimes', 'numeric', 'min:0.001'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'conversion_factor.numeric' => 'El factor de conversión debe ser un número.',
|
||||
'conversion_factor.min' => 'El factor de conversión debe ser mayor a 0.',
|
||||
'retail_price.numeric' => 'El precio de venta debe ser un número.',
|
||||
'retail_price.min' => 'El precio de venta no puede ser negativo.',
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/App/UnitOfMeasurementStoreRequest.php
Normal file
41
app/Http/Requests/App/UnitOfMeasurementStoreRequest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UnitOfMeasurementStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:50', 'unique:units_of_measurement,name'],
|
||||
'abbreviation' => ['required', 'string', 'max:10', 'unique:units_of_measurement,abbreviation'],
|
||||
'allows_decimals' => ['required', 'boolean'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'El nombre es obligatorio.',
|
||||
'name.unique' => 'Ya existe una unidad con este nombre.',
|
||||
'name.max' => 'El nombre no debe exceder los 50 caracteres.',
|
||||
'abbreviation.required' => 'La abreviatura es obligatoria.',
|
||||
'abbreviation.unique' => 'Ya existe una unidad con esta abreviatura.',
|
||||
'abbreviation.max' => 'La abreviatura no debe exceder los 10 caracteres.',
|
||||
'allows_decimals.required' => 'Debe especificar si la unidad permite cantidades decimales.',
|
||||
'allows_decimals.boolean' => 'El campo permite decimales debe ser verdadero o falso.',
|
||||
'is_active.boolean' => 'El campo activo debe ser verdadero o falso.',
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php
Normal file
74
app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use App\Models\UnitOfMeasurement;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UnitOfMeasurementUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$unitId = $this->route('unit')?->id ?? $this->route('unit');
|
||||
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:50', 'unique:units_of_measurement,name,' . $unitId],
|
||||
'abbreviation' => ['sometimes', 'string', 'max:10', 'unique:units_of_measurement,abbreviation,' . $unitId],
|
||||
'allows_decimals' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.unique' => 'Ya existe una unidad con este nombre.',
|
||||
'name.max' => 'El nombre no debe exceder los 50 caracteres.',
|
||||
'abbreviation.unique' => 'Ya existe una unidad con esta abreviatura.',
|
||||
'abbreviation.max' => 'La abreviatura no debe exceder los 10 caracteres.',
|
||||
'allows_decimals.boolean' => 'El campo permite decimales debe ser verdadero o falso.',
|
||||
'is_active.boolean' => 'El campo activo debe ser verdadero o falso.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional: no permitir cambiar allows_decimals si hay productos con seriales
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$unit = $this->route('unit');
|
||||
|
||||
if ($unit && !($unit instanceof UnitOfMeasurement)) {
|
||||
$unit = UnitOfMeasurement::find($unit);
|
||||
}
|
||||
|
||||
if (!$unit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si se intenta cambiar allows_decimals
|
||||
if ($this->has('allows_decimals') && (bool) $this->allows_decimals !== (bool) $unit->allows_decimals) {
|
||||
// Verificar si hay productos con track_serials usando esta unidad
|
||||
$hasProductsWithSerials = $unit->inventories()
|
||||
->where('track_serials', true)
|
||||
->exists();
|
||||
|
||||
if ($hasProductsWithSerials) {
|
||||
$validator->errors()->add(
|
||||
'allows_decimals',
|
||||
'No se puede cambiar el tipo de unidad (decimal/entero) porque hay productos con números de serie que la utilizan'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/App/WarehouseStoreRequest.php
Normal file
32
app/Http/Requests/App/WarehouseStoreRequest.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class WarehouseStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => 'required|string|max:50|unique:warehouses,code',
|
||||
'name' => 'required|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'is_main' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => 'El código es requerido',
|
||||
'code.unique' => 'El código ya existe',
|
||||
'name.required' => 'El nombre es requerido',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/App/WarehouseUpdateRequest.php
Normal file
36
app/Http/Requests/App/WarehouseUpdateRequest.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class WarehouseUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:50',
|
||||
Rule::unique('warehouses', 'code')->ignore($this->route('id')),
|
||||
],
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'is_main' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.unique' => 'El código ya existe',
|
||||
];
|
||||
}
|
||||
}
|
||||
47
app/Http/Requests/ClientTiers/ClientTierStoreRequest.php
Normal file
47
app/Http/Requests/ClientTiers/ClientTierStoreRequest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ClientTiers;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ClientTierStoreRequest 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 [
|
||||
'tier_name' => ['required', 'string', 'max:100', 'unique:client_tiers,tier_name'],
|
||||
'min_purchase_amount' => ['required', 'numeric', 'min:0'],
|
||||
'max_purchase_amount' => ['nullable', 'numeric', 'gt:min_purchase_amount'],
|
||||
'discount_percentage' => ['required', 'numeric', 'min:0', 'max:100'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'tier_name.required' => 'El nombre del tier es requerido',
|
||||
'tier_name.unique' => 'Ya existe un tier con este nombre',
|
||||
'min_purchase_amount.required' => 'El monto mínimo es requerido',
|
||||
'min_purchase_amount.min' => 'El monto mínimo debe ser mayor o igual a 0',
|
||||
'max_purchase_amount.gt' => 'El monto máximo debe ser mayor al monto mínimo',
|
||||
'discount_percentage.required' => 'El porcentaje de descuento es requerido',
|
||||
'discount_percentage.min' => 'El descuento debe ser mayor o igual a 0',
|
||||
'discount_percentage.max' => 'El descuento no puede ser mayor a 100%',
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Http/Requests/ClientTiers/ClientTierUpdateRequest.php
Normal file
56
app/Http/Requests/ClientTiers/ClientTierUpdateRequest.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ClientTiers;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ClientTierUpdateRequest 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
|
||||
{
|
||||
$tierId = $this->route('tier')->id ?? $this->route('id');
|
||||
|
||||
return [
|
||||
'tier_name' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'max:100',
|
||||
Rule::unique('client_tiers', 'tier_name')->ignore($tierId),
|
||||
],
|
||||
'min_purchase_amount' => ['sometimes', 'required', 'numeric', 'min:0'],
|
||||
'max_purchase_amount' => ['nullable', 'numeric', 'gt:min_purchase_amount'],
|
||||
'discount_percentage' => ['sometimes', 'required', 'numeric', 'min:0', 'max:100'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'tier_name.required' => 'El nombre del tier es requerido',
|
||||
'tier_name.unique' => 'Ya existe un tier con este nombre',
|
||||
'min_purchase_amount.required' => 'El monto mínimo es requerido',
|
||||
'min_purchase_amount.min' => 'El monto mínimo debe ser mayor o igual a 0',
|
||||
'max_purchase_amount.gt' => 'El monto máximo debe ser mayor al monto mínimo',
|
||||
'discount_percentage.required' => 'El porcentaje de descuento es requerido',
|
||||
'discount_percentage.min' => 'El descuento debe ser mayor o igual a 0',
|
||||
'discount_percentage.max' => 'El descuento no puede ser mayor a 100%',
|
||||
];
|
||||
}
|
||||
}
|
||||
275
app/Imports/ProductsImport.php
Normal file
275
app/Imports/ProductsImport.php
Normal file
@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Price;
|
||||
use App\Models\Category;
|
||||
use App\Http\Requests\App\InventoryImportRequest;
|
||||
use App\Models\UnitOfMeasurement;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
use Maatwebsite\Excel\Concerns\Importable;
|
||||
use Maatwebsite\Excel\Concerns\WithChunkReading;
|
||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
|
||||
/**
|
||||
* Import de productos desde Excel
|
||||
*
|
||||
* Solo crea/actualiza el CATÁLOGO de productos.
|
||||
* Para agregar stock, usar movimientos de entrada (POST /movimientos/entrada).
|
||||
*
|
||||
* Formato esperado del Excel:
|
||||
* - nombre: Nombre del producto (requerido)
|
||||
* - sku: Código SKU (opcional, único)
|
||||
* - codigo_barras: Código de barras (opcional, único)
|
||||
* - categoria: Nombre de la categoría (opcional)
|
||||
* - precio_venta: Precio de venta (requerido, mayor a 0)
|
||||
* - impuesto: Porcentaje de impuesto (opcional, 0-100)
|
||||
*/
|
||||
class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, SkipsEmptyRows, WithMapping
|
||||
{
|
||||
use Importable;
|
||||
|
||||
private $errors = [];
|
||||
private $imported = 0;
|
||||
private $updated = 0;
|
||||
private $skipped = 0;
|
||||
|
||||
/**
|
||||
* Mapea y transforma los datos de cada fila antes de la validación
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
return [
|
||||
'nombre' => $row['nombre'] ?? null,
|
||||
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
|
||||
'codigo_barras' => isset($row['codigo_barras']) ? (string) $row['codigo_barras'] : null,
|
||||
'categoria' => $row['categoria'] ?? null,
|
||||
'unidad_medida' => $row['unidad_medida'] ?? null,
|
||||
'precio_venta' => $row['precio_venta'] ?? null,
|
||||
'impuesto' => $row['impuesto'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa cada fila del Excel
|
||||
*/
|
||||
public function model(array $row)
|
||||
{
|
||||
// Ignorar filas completamente vacías
|
||||
if (empty($row['nombre']) && empty($row['sku']) && empty($row['precio_venta'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
$existingInventory = null;
|
||||
if(!empty($row['sku'])) {
|
||||
$existingInventory = Inventory::where('sku', trim($row['sku']))->first();
|
||||
}
|
||||
if(!$existingInventory && !empty($row['codigo_barras'])) {
|
||||
$existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first();
|
||||
}
|
||||
|
||||
// Si el producto ya existe, actualizar información
|
||||
if ($existingInventory) {
|
||||
return $this->updateExistingProduct($existingInventory, $row);
|
||||
}
|
||||
|
||||
// Producto nuevo
|
||||
return $this->createNewProduct($row);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Error en fila: " . $e->getMessage();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function createNewProduct(array $row)
|
||||
{
|
||||
try {
|
||||
// Validar nombre del producto
|
||||
if (!isset($row['nombre']) || empty(trim($row['nombre']))) {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Fila sin nombre de producto";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validar precio de venta
|
||||
$precioVenta = (float) $row['precio_venta'];
|
||||
if ($precioVenta <= 0) {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta debe ser mayor a 0";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Buscar o crear categoría si se proporciona
|
||||
$categoryId = null;
|
||||
if (!empty($row['categoria'])) {
|
||||
$category = Category::firstOrCreate(
|
||||
['name' => trim($row['categoria'])],
|
||||
['is_active' => true]
|
||||
);
|
||||
$categoryId = $category->id;
|
||||
}
|
||||
|
||||
// Buscar unidad de medida (requerida)
|
||||
$unitId = null;
|
||||
if (!empty($row['unidad_medida'])) {
|
||||
$unit = UnitOfMeasurement::where('name', trim($row['unidad_medida']))
|
||||
->orWhere('abbreviation', trim($row['unidad_medida']))
|
||||
->first();
|
||||
|
||||
if ($unit) {
|
||||
$unitId = $unit->id;
|
||||
} else {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Fila con producto '{$row['nombre']}': Unidad de medida '{$row['unidad_medida']}' no encontrada";
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Si no se proporciona, usar 'Pieza' por defecto
|
||||
$unit = UnitOfMeasurement::where('name', 'Pieza')->first();
|
||||
if ($unit) {
|
||||
$unitId = $unit->id;
|
||||
} else {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Fila con producto '{$row['nombre']}': No se proporcionó unidad de medida y no existe 'Pieza' por defecto";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Crear el producto en inventario
|
||||
$inventory = new Inventory();
|
||||
$inventory->name = trim($row['nombre']);
|
||||
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
|
||||
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
|
||||
$inventory->category_id = $categoryId;
|
||||
$inventory->unit_of_measure_id = $unitId;
|
||||
$inventory->is_active = true;
|
||||
$inventory->track_serials = false;
|
||||
$inventory->save();
|
||||
|
||||
// Crear el precio del producto
|
||||
Price::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'cost' => 0,
|
||||
'retail_price' => $precioVenta,
|
||||
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
|
||||
]);
|
||||
|
||||
$this->imported++;
|
||||
|
||||
return $inventory;
|
||||
} catch (\Exception $e) {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Error creando producto '{$row['nombre']}': " . $e->getMessage();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un producto existente con nueva información
|
||||
*/
|
||||
private function updateExistingProduct(Inventory $inventory, array $row)
|
||||
{
|
||||
try {
|
||||
// Actualizar información básica del producto
|
||||
if (!empty($row['nombre'])) {
|
||||
$inventory->name = trim($row['nombre']);
|
||||
}
|
||||
|
||||
if (!empty($row['codigo_barras'])) {
|
||||
$inventory->barcode = trim($row['codigo_barras']);
|
||||
}
|
||||
|
||||
// Actualizar categoría si se proporciona
|
||||
if (!empty($row['categoria'])) {
|
||||
$category = Category::firstOrCreate(
|
||||
['name' => trim($row['categoria'])],
|
||||
['is_active' => true]
|
||||
);
|
||||
$inventory->category_id = $category->id;
|
||||
}
|
||||
|
||||
// Actualizar unidad de medida si se proporciona
|
||||
if (!empty($row['unidad_medida'])) {
|
||||
$unit = UnitOfMeasurement::where('name', trim($row['unidad_medida']))
|
||||
->orWhere('abbreviation', trim($row['unidad_medida']))
|
||||
->first();
|
||||
|
||||
if ($unit) {
|
||||
$inventory->unit_of_measure_id = $unit->id;
|
||||
}
|
||||
}
|
||||
|
||||
$inventory->save();
|
||||
|
||||
// Actualizar precio de venta e impuesto (NO el costo)
|
||||
if ($inventory->price) {
|
||||
$updateData = [];
|
||||
|
||||
if (!empty($row['precio_venta'])) {
|
||||
$updateData['retail_price'] = (float) $row['precio_venta'];
|
||||
}
|
||||
|
||||
if (isset($row['impuesto'])) {
|
||||
$updateData['tax'] = (float) $row['impuesto'];
|
||||
}
|
||||
|
||||
if (!empty($updateData)) {
|
||||
$inventory->price->update($updateData);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updated++;
|
||||
|
||||
return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo
|
||||
} catch (\Exception $e) {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Error actualizando producto '{$inventory->name}': " . $e->getMessage();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reglas de validación para cada fila
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return InventoryImportRequest::rowRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mensajes personalizados de validación
|
||||
*/
|
||||
public function customValidationMessages()
|
||||
{
|
||||
return InventoryImportRequest::rowMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk size for reading
|
||||
*/
|
||||
public function chunkSize(): int
|
||||
{
|
||||
return 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas de la importación
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
return [
|
||||
'imported' => $this->imported,
|
||||
'updated' => $this->updated,
|
||||
'skipped' => $this->skipped,
|
||||
'errors' => $this->errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Models/Bill.php
Normal file
37
app/Models/Bill.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php namespace App\Models;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Bill extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'supplier_id',
|
||||
'cost',
|
||||
'file_path',
|
||||
'deadline',
|
||||
'paid',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cost' => 'decimal:2',
|
||||
'paid' => 'boolean',
|
||||
];
|
||||
|
||||
protected $appends = ['file_url'];
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function getFileUrlAttribute(): ?string
|
||||
{
|
||||
if (!$this->file_path) return null;
|
||||
return asset('storage/' . $this->file_path);
|
||||
}
|
||||
}
|
||||
107
app/Models/Bundle.php
Normal file
107
app/Models/Bundle.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Bundle extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'sku',
|
||||
'barcode',
|
||||
];
|
||||
|
||||
protected $appends = ['available_stock', 'total_cost'];
|
||||
|
||||
/**
|
||||
* Componentes del kit (items individuales)
|
||||
*/
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(BundleItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Productos que componen el kit (relación many-to-many)
|
||||
*/
|
||||
public function inventories()
|
||||
{
|
||||
return $this->belongsToMany(Inventory::class, 'bundle_items')
|
||||
->withPivot('quantity')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Precio del kit
|
||||
*/
|
||||
public function price()
|
||||
{
|
||||
return $this->hasOne(BundlePrice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock disponible del kit en el almacén principal (único para venta)
|
||||
* = mínimo(stock_almacén_principal_componente / cantidad_requerida)
|
||||
*/
|
||||
public function getAvailableStockAttribute(): int
|
||||
{
|
||||
$mainWarehouseId = Warehouse::where('is_main', true)->value('id');
|
||||
|
||||
if (! $mainWarehouseId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->stockInWarehouse($mainWarehouseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock disponible en un almacén específico
|
||||
*/
|
||||
public function stockInWarehouse(int $warehouseId): int
|
||||
{
|
||||
if ($this->items->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$minStock = PHP_INT_MAX;
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$inventory = $item->inventory;
|
||||
$warehouseStock = $inventory->stockInWarehouse($warehouseId);
|
||||
|
||||
$possibleKits = $warehouseStock > 0 ? floor($warehouseStock / $item->quantity) : 0;
|
||||
$minStock = min($minStock, $possibleKits);
|
||||
}
|
||||
|
||||
return $minStock === PHP_INT_MAX ? 0 : (int) $minStock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Costo total del kit (suma de costos de componentes)
|
||||
*/
|
||||
public function getTotalCostAttribute(): float
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$componentCost = $item->inventory->price->cost ?? 0;
|
||||
$total += $componentCost * $item->quantity;
|
||||
}
|
||||
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar si el kit tiene stock disponible
|
||||
*/
|
||||
public function hasStock(int $quantity = 1, ?int $warehouseId = null): bool
|
||||
{
|
||||
if ($warehouseId) {
|
||||
return $this->stockInWarehouse($warehouseId) >= $quantity;
|
||||
}
|
||||
|
||||
return $this->available_stock >= $quantity;
|
||||
}
|
||||
}
|
||||
32
app/Models/BundleItem.php
Normal file
32
app/Models/BundleItem.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BundleItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'bundle_id',
|
||||
'inventory_id',
|
||||
'quantity',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bundle al que pertenece este item
|
||||
*/
|
||||
public function bundle()
|
||||
{
|
||||
return $this->belongsTo(Bundle::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Producto componente
|
||||
*/
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
}
|
||||
27
app/Models/BundlePrice.php
Normal file
27
app/Models/BundlePrice.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BundlePrice extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'bundle_id',
|
||||
'cost',
|
||||
'retail_price',
|
||||
'tax',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cost' => 'decimal:2',
|
||||
'retail_price' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bundle al que pertenece este precio
|
||||
*/
|
||||
public function bundle()
|
||||
{
|
||||
return $this->belongsTo(Bundle::class);
|
||||
}
|
||||
}
|
||||
76
app/Models/CashRegister.php
Normal file
76
app/Models/CashRegister.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CashRegister extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'opened_at',
|
||||
'closed_at',
|
||||
'initial_cash',
|
||||
'final_cash',
|
||||
'expected_cash',
|
||||
'difference',
|
||||
'total_sales',
|
||||
'sales_count',
|
||||
'total_returns',
|
||||
'cash_returns',
|
||||
'card_returns',
|
||||
'returns_count',
|
||||
'total_discounts',
|
||||
'notes',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'opened_at' => 'datetime',
|
||||
'closed_at' => 'datetime',
|
||||
'initial_cash' => 'decimal:2',
|
||||
'final_cash' => 'decimal:2',
|
||||
'expected_cash' => 'decimal:2',
|
||||
'difference' => 'decimal:2',
|
||||
'total_sales' => 'decimal:2',
|
||||
'total_returns' => 'decimal:2',
|
||||
'cash_returns' => 'decimal:2',
|
||||
'card_returns' => 'decimal:2',
|
||||
'total_discounts' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function sales()
|
||||
{
|
||||
return $this->hasMany(Sale::class);
|
||||
}
|
||||
|
||||
public function returns()
|
||||
{
|
||||
return $this->hasMany(Returns::class, 'cash_register_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si la caja está abierta
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->status === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular totales por método de pago
|
||||
*/
|
||||
public function getTotalsByPaymentMethod()
|
||||
{
|
||||
return $this->sales()
|
||||
->where('status', 'completed')
|
||||
->selectRaw('payment_method, SUM(total) as total, COUNT(*) as count')
|
||||
->groupBy('payment_method')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
40
app/Models/Category.php
Normal file
40
app/Models/Category.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php namespace App\Models;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Category extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function subcategories()
|
||||
{
|
||||
return $this->hasMany(Subcategory::class);
|
||||
}
|
||||
|
||||
public function inventories()
|
||||
{
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
}
|
||||
78
app/Models/Client.php
Normal file
78
app/Models/Client.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Client extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'client_number',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'rfc',
|
||||
'razon_social',
|
||||
'regimen_fiscal',
|
||||
'cp_fiscal',
|
||||
'uso_cfdi',
|
||||
'tier_id',
|
||||
'total_purchases',
|
||||
'total_transactions',
|
||||
'lifetime_returns',
|
||||
'last_purchase_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_purchases' => 'decimal:2',
|
||||
'total_transactions' => 'integer',
|
||||
'lifetime_returns' => 'decimal:2',
|
||||
'last_purchase_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function sales(): HasMany
|
||||
{
|
||||
return $this->hasMany(Sale::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nivel o rango del cliente
|
||||
*/
|
||||
public function tier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientTier::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Historial de cambios de nivel del cliente
|
||||
*/
|
||||
public function tierHistory(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientTierHistory::class)->orderBy('changed_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Descuento aplicable según el nivel del cliente
|
||||
*/
|
||||
public function getDiscountPercentageAttribute(): float
|
||||
{
|
||||
return $this->tier?->discount_percentage ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compras netas del cliente (total compras - devoluciones)
|
||||
*/
|
||||
public function getNetPurchasesAttribute(): float
|
||||
{
|
||||
return $this->total_purchases - $this->lifetime_returns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicitudes de factura del cliente
|
||||
*/
|
||||
public function invoiceRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvoiceRequest::class);
|
||||
}
|
||||
}
|
||||
76
app/Models/ClientTier.php
Normal file
76
app/Models/ClientTier.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ClientTier extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tier_name',
|
||||
'min_purchase_amount',
|
||||
'max_purchase_amount',
|
||||
'discount_percentage',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'min_purchase_amount' => 'decimal:2',
|
||||
'max_purchase_amount' => 'decimal:2',
|
||||
'discount_percentage' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Clientes que pertenecen a este nivel
|
||||
*/
|
||||
public function clients(): HasMany
|
||||
{
|
||||
return $this->hasMany(Client::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Niveles nuevos en el historial
|
||||
*/
|
||||
public function tierHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientTierHistory::class, 'new_tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Niveles antiguos en el historial
|
||||
*/
|
||||
public function oldTierHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientTierHistory::class, 'old_tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Alcance de consulta para niveles activos
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un monto de compra califica para este nivel
|
||||
*/
|
||||
public function qualifies(float $amount): bool
|
||||
{
|
||||
if (!$this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($amount < $this->min_purchase_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->max_purchase_amount !== null && $amount > $this->max_purchase_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
51
app/Models/ClientTierHistory.php
Normal file
51
app/Models/ClientTierHistory.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ClientTierHistory extends Model
|
||||
{
|
||||
protected $table = 'client_tier_history';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'old_tier_id',
|
||||
'new_tier_id',
|
||||
'total_at_change',
|
||||
'reason',
|
||||
'changed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_at_change' => 'decimal:2',
|
||||
'changed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cliente con historial de niveles
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nivel anterior
|
||||
*/
|
||||
public function oldTier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientTier::class, 'old_tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nivel nuevo
|
||||
*/
|
||||
public function newTier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientTier::class, 'new_tier_id');
|
||||
}
|
||||
}
|
||||
189
app/Models/Inventory.php
Normal file
189
app/Models/Inventory.php
Normal file
@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
use App\Services\InventoryMovementService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Modelo de Inventario (Catálogo de productos)
|
||||
*
|
||||
* El stock NO vive aquí, vive en inventory_warehouse
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Inventory extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'subcategory_id',
|
||||
'unit_of_measure_id',
|
||||
'name',
|
||||
'key_sat',
|
||||
'sku',
|
||||
'barcode',
|
||||
'track_serials',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'track_serials' => 'boolean',
|
||||
];
|
||||
|
||||
protected $appends = ['has_serials', 'inventory_value', 'stock'];
|
||||
|
||||
public function warehouses()
|
||||
{
|
||||
return $this->belongsToMany(Warehouse::class, 'inventory_warehouse')
|
||||
->withPivot('stock', 'min_stock', 'max_stock')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock total en todos los almacenes (ahora soporta decimales)
|
||||
*/
|
||||
public function getStockAttribute(): float
|
||||
{
|
||||
return (float) $this->warehouses()->sum('inventory_warehouse.stock');
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias para compatibilidad
|
||||
*/
|
||||
public function getTotalStockAttribute(): float
|
||||
{
|
||||
return $this->stock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock en un almacén específico (ahora soporta decimales)
|
||||
*/
|
||||
public function stockInWarehouse(int $warehouseId): float
|
||||
{
|
||||
return (float) ($this->warehouses()
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->value('inventory_warehouse.stock') ?? 0);
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function subcategory()
|
||||
{
|
||||
return $this->belongsTo(Subcategory::class);
|
||||
}
|
||||
|
||||
public function unitOfMeasure()
|
||||
{
|
||||
return $this->belongsTo(UnitOfMeasurement::class);
|
||||
}
|
||||
|
||||
public function price()
|
||||
{
|
||||
return $this->hasOne(Price::class);
|
||||
}
|
||||
|
||||
public function serials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener seriales disponibles
|
||||
*/
|
||||
public function availableSerials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class)
|
||||
->where('status', 'disponible');
|
||||
}
|
||||
|
||||
public function unitEquivalences()
|
||||
{
|
||||
return $this->hasMany(UnitEquivalence::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener factor de conversión para una unidad dada.
|
||||
* Retorna 1.0 si es la unidad base del producto.
|
||||
*/
|
||||
public function getConversionFactor(int $unitOfMeasureId): float
|
||||
{
|
||||
if ($this->unit_of_measure_id === $unitOfMeasureId) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$equivalence = $this->unitEquivalences()
|
||||
->where('unit_of_measure_id', $unitOfMeasureId)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $equivalence) {
|
||||
throw new \Exception(
|
||||
"No existe equivalencia activa para la unidad seleccionada en el producto '{$this->name}'."
|
||||
);
|
||||
}
|
||||
|
||||
return (float) $equivalence->conversion_factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir cantidad de cualquier unidad a unidades base
|
||||
*/
|
||||
public function convertToBaseUnits(float $quantity, int $unitOfMeasureId): float
|
||||
{
|
||||
return round($quantity * $this->getConversionFactor($unitOfMeasureId), 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir costo por unidad de entrada a costo por unidad base
|
||||
*/
|
||||
public function convertCostToBaseUnit(float $costPerUnit, int $unitOfMeasureId): float
|
||||
{
|
||||
$factor = $this->getConversionFactor($unitOfMeasureId);
|
||||
|
||||
if ($factor <= 0) {
|
||||
throw new \Exception('Factor de conversión inválido.');
|
||||
}
|
||||
|
||||
return round($costPerUnit / $factor, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock basado en seriales disponibles (para productos con track_serials)
|
||||
*/
|
||||
public function getAvailableStockAttribute(): int
|
||||
{
|
||||
return $this->availableSerials()->count();
|
||||
}
|
||||
|
||||
public function getHasSerialsAttribute(): bool
|
||||
{
|
||||
return isset($this->attributes['serials_count']) && $this->attributes['serials_count'] > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valor total del inventario (stock * costo)
|
||||
*/
|
||||
public function getInventoryValueAttribute(): float
|
||||
{
|
||||
return $this->stock * ($this->price?->cost ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizar stock basado en seriales disponibles
|
||||
* Delega al servicio para mantener la lógica centralizada
|
||||
*/
|
||||
public function syncStock(): void
|
||||
{
|
||||
app(InventoryMovementService::class)->syncStockFromSerials($this);
|
||||
}
|
||||
}
|
||||
115
app/Models/InventoryMovement.php
Normal file
115
app/Models/InventoryMovement.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\InventoryMovementObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Notsoweb\LaravelCore\Traits\Models\Extended;
|
||||
|
||||
#[ObservedBy([InventoryMovementObserver::class])]
|
||||
class InventoryMovement extends Model
|
||||
{
|
||||
use Extended;
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'warehouse_from_id',
|
||||
'warehouse_to_id',
|
||||
'movement_type',
|
||||
'quantity',
|
||||
'unit_of_measure_id',
|
||||
'unit_quantity',
|
||||
'unit_cost',
|
||||
'unit_cost_original',
|
||||
'supplier_id',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
'user_id',
|
||||
'notes',
|
||||
'invoice_reference',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:3',
|
||||
'unit_quantity' => 'decimal:3',
|
||||
'unit_cost' => 'decimal:2',
|
||||
'unit_cost_original' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relaciones
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function warehouseFrom()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'warehouse_from_id');
|
||||
}
|
||||
|
||||
public function warehouseTo()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'warehouse_to_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function unitOfMeasure()
|
||||
{
|
||||
return $this->belongsTo(UnitOfMeasurement::class);
|
||||
}
|
||||
|
||||
public function serials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class, 'movement_id');
|
||||
}
|
||||
|
||||
public function transferredSerials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class, 'transfer_movement_id');
|
||||
}
|
||||
|
||||
public function exitedSerials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class, 'exit_movement_id');
|
||||
}
|
||||
|
||||
// Relación polimórfica para la referencia
|
||||
public function reference()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('movement_type', $type);
|
||||
}
|
||||
|
||||
public function scopeEntry($query)
|
||||
{
|
||||
return $query->where('movement_type', 'entry');
|
||||
}
|
||||
|
||||
public function scopeExit($query)
|
||||
{
|
||||
return $query->where('movement_type', 'exit');
|
||||
}
|
||||
|
||||
public function scopeTransfer($query)
|
||||
{
|
||||
return $query->where('movement_type', 'transfer');
|
||||
}
|
||||
}
|
||||
145
app/Models/InventorySerial.php
Normal file
145
app/Models/InventorySerial.php
Normal file
@ -0,0 +1,145 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Modelo para números de serie de inventario
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class InventorySerial extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'warehouse_id',
|
||||
'movement_id',
|
||||
'transfer_movement_id',
|
||||
'exit_movement_id',
|
||||
'serial_number',
|
||||
'status',
|
||||
'sale_detail_id',
|
||||
'return_detail_id',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'status' => 'string',
|
||||
];
|
||||
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function movement()
|
||||
{
|
||||
return $this->belongsTo(InventoryMovement::class);
|
||||
}
|
||||
|
||||
public function transferMovement()
|
||||
{
|
||||
return $this->belongsTo(InventoryMovement::class, 'transfer_movement_id');
|
||||
}
|
||||
|
||||
public function exitMovement()
|
||||
{
|
||||
return $this->belongsTo(InventoryMovement::class, 'exit_movement_id');
|
||||
}
|
||||
|
||||
public function markAsExited(int $exitMovementId): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'salida',
|
||||
'exit_movement_id' => $exitMovementId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function restoreFromExit(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'disponible',
|
||||
'exit_movement_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isExited(): bool
|
||||
{
|
||||
return $this->status === 'salida';
|
||||
}
|
||||
|
||||
public function saleDetail()
|
||||
{
|
||||
return $this->belongsTo(SaleDetail::class);
|
||||
}
|
||||
|
||||
public function returnDetail()
|
||||
{
|
||||
return $this->belongsTo(ReturnDetail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si el serial está disponible
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->status === 'disponible';
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar como vendido
|
||||
*/
|
||||
public function markAsSold(int $saleDetailId, ?int $warehouseId = null): void {
|
||||
$this->update([
|
||||
'status' => 'vendido',
|
||||
'sale_detail_id' => $saleDetailId,
|
||||
'warehouse_id' => $warehouseId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar como disponible (ej: cancelación de venta)
|
||||
*/
|
||||
public function markAsAvailable(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'disponible',
|
||||
'sale_detail_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar como devuelto
|
||||
*/
|
||||
public function markAsReturned(int $returnDetailId): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'devuelto',
|
||||
'return_detail_id' => $returnDetailId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurar a disponible desde devuelto
|
||||
*/
|
||||
public function restoreFromReturn(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'disponible',
|
||||
'sale_detail_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si el serial está devuelto
|
||||
*/
|
||||
public function isReturned(): bool
|
||||
{
|
||||
return $this->status === 'devuelto';
|
||||
}
|
||||
}
|
||||
47
app/Models/InventoryWarehouse.php
Normal file
47
app/Models/InventoryWarehouse.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use App\Observers\InventoryWarehouseObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[ObservedBy([InventoryWarehouseObserver::class])]
|
||||
class InventoryWarehouse extends Model
|
||||
{
|
||||
protected $table = 'inventory_warehouse';
|
||||
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'warehouse_id',
|
||||
'stock',
|
||||
'min_stock',
|
||||
'max_stock',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'stock' => 'decimal:3',
|
||||
'min_stock' => 'decimal:3',
|
||||
'max_stock' => 'decimal:3',
|
||||
];
|
||||
|
||||
// Relaciones
|
||||
public function inventory() {
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
public function warehouse() {
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
// Métodos útiles
|
||||
public function isLowStock(): bool {
|
||||
return $this->min_stock && $this->stock <= $this->min_stock;
|
||||
}
|
||||
|
||||
public function isOverStock(): bool {
|
||||
return $this->max_stock && $this->stock >= $this->max_stock;
|
||||
}
|
||||
|
||||
public function hasAvailableStock(float $quantity): bool {
|
||||
return $this->stock >= $quantity;
|
||||
}
|
||||
}
|
||||
112
app/Models/InvoiceRequest.php
Normal file
112
app/Models/InvoiceRequest.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php namespace App\Models;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class InvoiceRequest extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'sale_id',
|
||||
'client_id',
|
||||
'status',
|
||||
'requested_at',
|
||||
'processed_at',
|
||||
'processed_by',
|
||||
'notes',
|
||||
'invoice_xml_path',
|
||||
'invoice_pdf_path',
|
||||
'cfdi_uuid',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'requested_at' => 'datetime',
|
||||
'processed_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Atributos adicionales para serializar
|
||||
*/
|
||||
protected $appends = ['invoice_xml_url', 'invoice_pdf_url'];
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PROCESSED = 'processed';
|
||||
const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public function sale()
|
||||
{
|
||||
return $this->belongsTo(Sale::class);
|
||||
}
|
||||
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function processedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'processed_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar como procesada
|
||||
*/
|
||||
public function markAsProcessed(int $userId, ?string $notes = null): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_PROCESSED,
|
||||
'processed_at' => now(),
|
||||
'processed_by' => $userId,
|
||||
'notes' => $notes ?? $this->notes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar como rechazada
|
||||
*/
|
||||
public function markAsRejected(int $userId, ?string $notes = null): bool
|
||||
{
|
||||
return $this->update([
|
||||
'status' => self::STATUS_REJECTED,
|
||||
'processed_at' => now(),
|
||||
'processed_by' => $userId,
|
||||
'notes' => $notes ?? $this->notes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener la URL completa del archivo XML
|
||||
*/
|
||||
public function getInvoiceXmlUrlAttribute()
|
||||
{
|
||||
if (!$this->invoice_xml_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('public')->url($this->invoice_xml_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener la URL completa del archivo PDF
|
||||
*/
|
||||
public function getInvoicePdfUrlAttribute()
|
||||
{
|
||||
if (!$this->invoice_pdf_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('public')->url($this->invoice_pdf_path);
|
||||
}
|
||||
}
|
||||
35
app/Models/Price.php
Normal file
35
app/Models/Price.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php namespace App\Models;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Price extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'cost',
|
||||
'retail_price',
|
||||
'tax',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cost' => 'decimal:2',
|
||||
'retail_price' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
}
|
||||
67
app/Models/ReturnDetail.php
Normal file
67
app/Models/ReturnDetail.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReturnDetail extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'return_id',
|
||||
'sale_detail_id',
|
||||
'inventory_id',
|
||||
'product_name',
|
||||
'quantity_returned',
|
||||
'unit_of_measure_id',
|
||||
'unit_quantity_returned',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_returned' => 'decimal:3',
|
||||
'unit_quantity_returned' => 'decimal:3',
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relación con devolución principal
|
||||
*/
|
||||
public function return()
|
||||
{
|
||||
return $this->belongsTo(Returns::class, 'return_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relación con detalle de venta original
|
||||
*/
|
||||
public function saleDetail()
|
||||
{
|
||||
return $this->belongsTo(SaleDetail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relación con inventario
|
||||
*/
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seriales devueltos en este detalle
|
||||
*/
|
||||
public function serials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class, 'return_detail_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener números de serie devueltos
|
||||
*/
|
||||
public function getSerialNumbersAttribute(): array
|
||||
{
|
||||
return $this->serials()->pluck('serial_number')->toArray();
|
||||
}
|
||||
}
|
||||
78
app/Models/Returns.php
Normal file
78
app/Models/Returns.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Returns extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'sale_id',
|
||||
'user_id',
|
||||
'cash_register_id',
|
||||
'return_number',
|
||||
'subtotal',
|
||||
'tax',
|
||||
'total',
|
||||
'discount_refund',
|
||||
'refund_method',
|
||||
'reason',
|
||||
'notes'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'subtotal' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'discount_refund' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relación con la venta original
|
||||
*/
|
||||
public function sale()
|
||||
{
|
||||
return $this->belongsTo(Sale::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relación con usuario que procesó la devolución
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relación con caja registradora
|
||||
*/
|
||||
public function cashRegister()
|
||||
{
|
||||
return $this->belongsTo(CashRegister::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detalles de la devolución
|
||||
*/
|
||||
public function details()
|
||||
{
|
||||
return $this->hasMany(ReturnDetail::class, 'return_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener texto descriptivo de la razón
|
||||
*/
|
||||
public function getReasonTextAttribute(): string
|
||||
{
|
||||
return match($this->reason) {
|
||||
'defective' => 'Producto defectuoso',
|
||||
'wrong_product' => 'Producto incorrecto',
|
||||
'change_of_mind' => 'Cambio de opinión',
|
||||
'damaged' => 'Producto dañado',
|
||||
'other' => 'Otra razón',
|
||||
default => 'No especificado',
|
||||
};
|
||||
}
|
||||
}
|
||||
105
app/Models/Sale.php
Normal file
105
app/Models/Sale.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php namespace App\Models;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Sale extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'client_id',
|
||||
'cash_register_id',
|
||||
'invoice_number',
|
||||
'subtotal',
|
||||
'tax',
|
||||
'total',
|
||||
'discount_percentage',
|
||||
'discount_amount',
|
||||
'client_tier_name',
|
||||
'cash_received',
|
||||
'change',
|
||||
'payment_method',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'subtotal' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'discount_percentage' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'cash_received' => 'decimal:2',
|
||||
'change' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function details()
|
||||
{
|
||||
return $this->hasMany(SaleDetail::class);
|
||||
}
|
||||
|
||||
public function cashRegister()
|
||||
{
|
||||
return $this->belongsTo(CashRegister::class);
|
||||
}
|
||||
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Devoluciones asociadas a esta venta
|
||||
*/
|
||||
public function returns()
|
||||
{
|
||||
return $this->hasMany(Returns::class, 'sale_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Total devuelto de esta venta
|
||||
*/
|
||||
public function getTotalReturnedAttribute(): float
|
||||
{
|
||||
return (float) $this->returns()->sum('total');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si la venta tiene devoluciones
|
||||
*/
|
||||
public function hasReturns(): bool
|
||||
{
|
||||
return $this->returns()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Total neto (total - devoluciones)
|
||||
*/
|
||||
public function getNetTotalAttribute(): float
|
||||
{
|
||||
return (float) ($this->total - $this->getTotalReturnedAttribute());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Solicitudes de factura asociadas a esta venta
|
||||
*/
|
||||
public function invoiceRequests()
|
||||
{
|
||||
return $this->hasMany(InvoiceRequest::class);
|
||||
}
|
||||
}
|
||||
148
app/Models/SaleDetail.php
Normal file
148
app/Models/SaleDetail.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class SaleDetail extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'sale_id',
|
||||
'inventory_id',
|
||||
'bundle_id',
|
||||
'bundle_sale_group',
|
||||
'warehouse_id',
|
||||
'unit_of_measure_id',
|
||||
'unit_quantity',
|
||||
'product_name',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
'discount_percentage',
|
||||
'discount_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:3',
|
||||
'unit_quantity' => 'decimal:3',
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'discount_percentage' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function sale()
|
||||
{
|
||||
return $this->belongsTo(Sale::class);
|
||||
}
|
||||
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
public function unitOfMeasure()
|
||||
{
|
||||
return $this->belongsTo(UnitOfMeasurement::class);
|
||||
}
|
||||
|
||||
public function serials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class, 'sale_detail_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener números de serie vendidos
|
||||
*/
|
||||
public function getSerialNumbersAttribute(): array
|
||||
{
|
||||
return $this->serials()->pluck('serial_number')->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Devoluciones de este detalle
|
||||
*/
|
||||
public function returnDetails()
|
||||
{
|
||||
return $this->hasMany(ReturnDetail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cantidad total devuelta
|
||||
*/
|
||||
public function getQuantityReturnedAttribute(): int
|
||||
{
|
||||
return $this->returnDetails()->sum('quantity_returned');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cantidad restante (no devuelta)
|
||||
*/
|
||||
public function getQuantityRemainingAttribute(): int
|
||||
{
|
||||
return $this->quantity - $this->getQuantityReturnedAttribute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si se puede devolver más
|
||||
*/
|
||||
public function canReturn(): bool
|
||||
{
|
||||
return $this->getQuantityRemainingAttribute() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cantidad máxima que se puede devolver
|
||||
*/
|
||||
public function getMaxReturnableQuantityAttribute(): int
|
||||
{
|
||||
return $this->getQuantityRemainingAttribute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle al que pertenece este sale_detail (si es componente de un kit)
|
||||
*/
|
||||
public function bundle()
|
||||
{
|
||||
return $this->belongsTo(Bundle::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si este sale_detail es parte de un kit
|
||||
*/
|
||||
public function isPartOfBundle(): bool
|
||||
{
|
||||
return ! is_null($this->bundle_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener todos los sale_details del mismo kit vendido
|
||||
* (todos los componentes con el mismo bundle_sale_group)
|
||||
*/
|
||||
public function bundleComponents()
|
||||
{
|
||||
if (! $this->isPartOfBundle()) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
return SaleDetail::where('sale_id', $this->sale_id)
|
||||
->where('bundle_sale_group', $this->bundle_sale_group)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
30
app/Models/Subcategory.php
Normal file
30
app/Models/Subcategory.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Subcategory extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function inventories()
|
||||
{
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
}
|
||||
31
app/Models/Supplier.php
Normal file
31
app/Models/Supplier.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Descripción
|
||||
*/
|
||||
class Supplier extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_name',
|
||||
'rfc',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'postal_code',
|
||||
'notes'
|
||||
];
|
||||
|
||||
public function inventoryMovements()
|
||||
{
|
||||
return $this->hasMany(InventoryMovement::class)->where('movement_type', 'entry');
|
||||
}
|
||||
|
||||
public function suppliedProducts()
|
||||
{
|
||||
return $this->hasManyThrough(Inventory::class, InventoryMovement::class, 'supplier_id', 'id', 'id', 'product_id')
|
||||
->where('inventory_movements.movement_type', 'entry')
|
||||
->distinct();
|
||||
}
|
||||
}
|
||||
37
app/Models/UnitEquivalence.php
Normal file
37
app/Models/UnitEquivalence.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UnitEquivalence extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'unit_of_measure_id',
|
||||
'conversion_factor',
|
||||
'retail_price',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'conversion_factor' => 'decimal:3',
|
||||
'retail_price' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
public function unitOfMeasure()
|
||||
{
|
||||
return $this->belongsTo(UnitOfMeasurement::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
50
app/Models/UnitOfMeasurement.php
Normal file
50
app/Models/UnitOfMeasurement.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php namespace App\Models;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Unidad de Medida
|
||||
*
|
||||
* Define las unidades de medida disponibles para los productos
|
||||
* (Unidad, Kilogramo, Litro, Metro, etc.)
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class UnitOfMeasurement extends Model
|
||||
{
|
||||
protected $table = 'units_of_measurement';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'abbreviation',
|
||||
'allows_decimals',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'allows_decimals' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Scope para unidades activas
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relación con inventarios
|
||||
*/
|
||||
public function inventories()
|
||||
{
|
||||
return $this->hasMany(Inventory::class, 'unit_of_measure_id');
|
||||
}
|
||||
}
|
||||
61
app/Models/Warehouse.php
Normal file
61
app/Models/Warehouse.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Modelo para almacenes
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class Warehouse extends Model
|
||||
{
|
||||
protected $fillable = ['code', 'name', 'is_active', 'is_main'];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'is_main' => 'boolean',
|
||||
];
|
||||
|
||||
// Relaciones
|
||||
public function inventories()
|
||||
{
|
||||
return $this->belongsToMany(Inventory::class, 'inventory_warehouse')
|
||||
->withPivot('stock', 'min_stock', 'max_stock')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function inventorySerials()
|
||||
{
|
||||
return $this->hasMany(InventorySerial::class);
|
||||
}
|
||||
|
||||
public function movementsFrom()
|
||||
{
|
||||
return $this->hasMany(InventoryMovement::class, 'warehouse_from_id');
|
||||
}
|
||||
|
||||
public function movementsTo()
|
||||
{
|
||||
return $this->hasMany(InventoryMovement::class, 'warehouse_to_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeMain($query)
|
||||
{
|
||||
return $query->where('is_main', true);
|
||||
}
|
||||
}
|
||||
39
app/Observers/InventoryMovementObserver.php
Normal file
39
app/Observers/InventoryMovementObserver.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php namespace App\Observers;
|
||||
|
||||
use App\Models\InventoryMovement;
|
||||
use App\Models\UserEvent;
|
||||
|
||||
class InventoryMovementObserver
|
||||
{
|
||||
/**
|
||||
* Manipulador del evento "created" del modelo InventoryMovement
|
||||
*/
|
||||
public function created(InventoryMovement $inventoryMovement): void
|
||||
{
|
||||
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manipulador del evento "deleted" del modelo InventoryMovement
|
||||
*/
|
||||
public function deleted(InventoryMovement $inventoryMovement): void
|
||||
{
|
||||
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manipulador del evento "restored" del modelo InventoryMovement
|
||||
*/
|
||||
public function restored(InventoryMovement $inventoryMovement): void
|
||||
{
|
||||
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manipulador del evento "force deleted" del modelo InventoryMovement
|
||||
*/
|
||||
public function forceDeleted(InventoryMovement $inventoryMovement): void
|
||||
{
|
||||
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
|
||||
}
|
||||
}
|
||||
69
app/Observers/InventoryWarehouseObserver.php
Normal file
69
app/Observers/InventoryWarehouseObserver.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php namespace App\Observers;
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
use App\Models\InventoryWarehouse;
|
||||
use App\Models\User;
|
||||
use App\Notifications\UserNotification;
|
||||
|
||||
/**
|
||||
* Observer de stock por almacén
|
||||
*
|
||||
* Envía notificación a administradores cuando el stock de un producto
|
||||
* cruza el umbral mínimo (de disponible a bajo stock).
|
||||
*
|
||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class InventoryWarehouseObserver
|
||||
{
|
||||
/**
|
||||
* Roles que reciben alertas de stock bajo
|
||||
*/
|
||||
protected array $notifiableRoles = ['developer', 'admin'];
|
||||
|
||||
/**
|
||||
* Umbral por defecto cuando min_stock no está configurado
|
||||
*/
|
||||
protected int $defaultThreshold = 5;
|
||||
|
||||
/**
|
||||
* Detectar cuando el stock cruza el umbral mínimo hacia abajo
|
||||
*/
|
||||
public function updated(InventoryWarehouse $inventoryWarehouse): void
|
||||
{
|
||||
$previousStock = (float) $inventoryWarehouse->getOriginal('stock');
|
||||
$currentStock = (float) $inventoryWarehouse->stock;
|
||||
$threshold = (float) ($inventoryWarehouse->min_stock ?? $this->defaultThreshold);
|
||||
|
||||
// Solo notificar si el stock CRUZÓ el umbral (no en cada update mientras ya esté bajo)
|
||||
if ($previousStock >= $threshold && $currentStock < $threshold) {
|
||||
$this->notifyLowStock($inventoryWarehouse, $currentStock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar notificación de stock bajo a usuarios con rol relevante
|
||||
*/
|
||||
protected function notifyLowStock(InventoryWarehouse $inventoryWarehouse, float $currentStock): void
|
||||
{
|
||||
$inventoryWarehouse->load('inventory', 'warehouse');
|
||||
|
||||
$productName = $inventoryWarehouse->inventory?->name ?? 'Producto desconocido';
|
||||
$warehouseName = $inventoryWarehouse->warehouse?->name ?? 'Almacén desconocido';
|
||||
|
||||
$users = User::role($this->notifiableRoles)->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new UserNotification(
|
||||
title: 'Stock bajo: ' . $productName,
|
||||
description: "El producto \"{$productName}\" tiene {$currentStock} unidad(es) disponible(s) en \"{$warehouseName}\".",
|
||||
type: 'warning',
|
||||
timeout: 30,
|
||||
save: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
175
app/Services/BundleService.php
Normal file
175
app/Services/BundleService.php
Normal file
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Bundle;
|
||||
use App\Models\BundleItem;
|
||||
use App\Models\BundlePrice;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BundleService
|
||||
{
|
||||
/**
|
||||
* Crear un nuevo kit/bundle con sus componentes y precio
|
||||
*/
|
||||
public function createBundle(array $data): Bundle
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// 1. Crear el bundle principal
|
||||
$bundle = Bundle::create([
|
||||
'name' => $data['name'],
|
||||
'sku' => $data['sku'],
|
||||
'barcode' => $data['barcode'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. Agregar componentes al kit
|
||||
foreach ($data['items'] as $item) {
|
||||
BundleItem::create([
|
||||
'bundle_id' => $bundle->id,
|
||||
'inventory_id' => $item['inventory_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Calcular costos y crear precio
|
||||
$bundle->load('items.inventory.price');
|
||||
|
||||
$totalCost = 0;
|
||||
$totalRetailPrice = 0;
|
||||
|
||||
foreach ($bundle->items as $item) {
|
||||
$totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity;
|
||||
$totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity;
|
||||
}
|
||||
|
||||
// Permitir override de precio (para promociones)
|
||||
$finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice;
|
||||
$tax = $data['tax'] ?? 16.00;
|
||||
|
||||
BundlePrice::create([
|
||||
'bundle_id' => $bundle->id,
|
||||
'cost' => $totalCost,
|
||||
'retail_price' => $finalRetailPrice,
|
||||
'tax' => $tax,
|
||||
]);
|
||||
|
||||
return $bundle->fresh(['items.inventory.price', 'price']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un kit existente
|
||||
*/
|
||||
public function updateBundle(Bundle $bundle, array $data): Bundle
|
||||
{
|
||||
return DB::transaction(function () use ($bundle, $data) {
|
||||
// 1. Actualizar datos básicos del bundle
|
||||
$bundle->update([
|
||||
'name' => $data['name'] ?? $bundle->name,
|
||||
'sku' => $data['sku'] ?? $bundle->sku,
|
||||
'barcode' => $data['barcode'] ?? $bundle->barcode,
|
||||
]);
|
||||
|
||||
// 2. Actualizar componentes si se proporcionan
|
||||
if (isset($data['items'])) {
|
||||
// Eliminar componentes actuales
|
||||
$bundle->items()->delete();
|
||||
|
||||
// Crear nuevos componentes
|
||||
foreach ($data['items'] as $item) {
|
||||
BundleItem::create([
|
||||
'bundle_id' => $bundle->id,
|
||||
'inventory_id' => $item['inventory_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Recalcular o actualizar precio
|
||||
if (isset($data['recalculate_price']) && $data['recalculate_price']) {
|
||||
// Recalcular precio basado en componentes actuales
|
||||
$bundle->load('items.inventory.price');
|
||||
|
||||
$totalCost = 0;
|
||||
$totalRetailPrice = 0;
|
||||
|
||||
foreach ($bundle->items as $item) {
|
||||
$totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity;
|
||||
$totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity;
|
||||
}
|
||||
|
||||
$finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice;
|
||||
$tax = $data['tax'] ?? 16.00;
|
||||
|
||||
$bundle->price->update([
|
||||
'cost' => $totalCost,
|
||||
'retail_price' => $finalRetailPrice,
|
||||
'tax' => $tax,
|
||||
]);
|
||||
} elseif (isset($data['retail_price'])) {
|
||||
// Solo actualizar precio sin recalcular componentes
|
||||
$bundle->price->update([
|
||||
'retail_price' => $data['retail_price'],
|
||||
'tax' => $data['tax'] ?? 16.00,
|
||||
]);
|
||||
}
|
||||
|
||||
return $bundle->fresh(['items.inventory.price', 'price']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar disponibilidad de stock del kit
|
||||
*/
|
||||
public function validateBundleAvailability(Bundle $bundle, int $quantity, ?int $warehouseId = null): void
|
||||
{
|
||||
if (!$bundle->hasStock($quantity, $warehouseId)) {
|
||||
$availableStock = $warehouseId
|
||||
? $bundle->stockInWarehouse($warehouseId)
|
||||
: $bundle->available_stock;
|
||||
|
||||
throw new \Exception(
|
||||
"Stock insuficiente del kit '{$bundle->name}'. " .
|
||||
"Disponibles: {$availableStock}, Requeridos: {$quantity}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener precio total de componentes (sin promoción)
|
||||
*/
|
||||
public function getComponentsValue(Bundle $bundle): float
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($bundle->items as $item) {
|
||||
$componentPrice = $item->inventory->price->retail_price ?? 0;
|
||||
$total += $componentPrice * $item->quantity;
|
||||
}
|
||||
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar (soft delete) un bundle
|
||||
*/
|
||||
public function deleteBundle(Bundle $bundle): bool
|
||||
{
|
||||
return $bundle->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurar un bundle eliminado
|
||||
*/
|
||||
public function restoreBundle(int $bundleId): ?Bundle
|
||||
{
|
||||
$bundle = Bundle::withTrashed()->find($bundleId);
|
||||
|
||||
if ($bundle && $bundle->trashed()) {
|
||||
$bundle->restore();
|
||||
return $bundle->fresh(['items.inventory.price', 'price']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
155
app/Services/CashRegisterService.php
Normal file
155
app/Services/CashRegisterService.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CashRegister;
|
||||
use App\Models\Sale;
|
||||
use App\Models\Returns;
|
||||
|
||||
class CashRegisterService
|
||||
{
|
||||
/**
|
||||
* Abrir caja
|
||||
*/
|
||||
public function openRegister(array $data)
|
||||
{
|
||||
// Verificar que el usuario no tenga una caja abierta
|
||||
$openRegister = CashRegister::where('user_id', $data['user_id'])
|
||||
->where('status', 'open')
|
||||
->first();
|
||||
|
||||
if ($openRegister) {
|
||||
throw new \Exception('Ya tienes una caja abierta. Debes cerrarla antes de abrir una nueva.');
|
||||
}
|
||||
|
||||
return CashRegister::create([
|
||||
'user_id' => $data['user_id'],
|
||||
'opened_at' => now(),
|
||||
'initial_cash' => $data['initial_cash'] ?? 0,
|
||||
'status' => 'open',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cerrar caja
|
||||
*/
|
||||
public function closeRegister(CashRegister $register, array $data)
|
||||
{
|
||||
$sales = Sale::where('cash_register_id', $register->id)
|
||||
->where('status', 'completed')
|
||||
->get();
|
||||
|
||||
// Calcular devoluciones
|
||||
$returns = Returns::where('cash_register_id', $register->id)->get();
|
||||
|
||||
// Calcular efectivo real (recibido - devuelto)
|
||||
$cashSales = $sales->where('payment_method', 'cash')
|
||||
->sum(function ($sale) {
|
||||
return ($sale->cash_received ?? 0) - ($sale->change ?? 0);
|
||||
});
|
||||
|
||||
// Efectivo de devoluciones
|
||||
$cashReturns = $returns->where('refund_method', 'cash')->sum('total');
|
||||
|
||||
// Devoluciones por tarjeta
|
||||
$cardReturns = $returns->whereIn('refund_method', ['credit_card', 'debit_card'])->sum('total');
|
||||
|
||||
$totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received');
|
||||
$totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change');
|
||||
$cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total');
|
||||
$totalSales = $sales->sum('total');
|
||||
$totalReturns = $returns->sum('total');
|
||||
|
||||
// Efectivo esperado (ajustado por devoluciones)
|
||||
$expectedCash = $register->initial_cash + $cashSales - $cashReturns;
|
||||
|
||||
// Diferencia (sobrante o faltante)
|
||||
$difference = $data['final_cash'] - $expectedCash;
|
||||
|
||||
// Cerrar caja
|
||||
$register->update([
|
||||
'closed_at' => now(),
|
||||
'final_cash' => $data['final_cash'],
|
||||
'expected_cash' => $expectedCash,
|
||||
'difference' => $difference,
|
||||
'total_sales' => $totalSales,
|
||||
'cash_sales' => $cashSales,
|
||||
'card_sales' => $cardSales,
|
||||
'total_cash_received' => $totalCashReceived,
|
||||
'total_change_given' => $totalChangeGiven,
|
||||
|
||||
// Campos nuevos de devoluciones
|
||||
'total_returns' => $totalReturns,
|
||||
'cash_returns' => $cashReturns,
|
||||
'card_returns' => $cardReturns,
|
||||
'returns_count' => $returns->count(),
|
||||
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'status' => 'closed',
|
||||
]);
|
||||
|
||||
return $register;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener resumen de caja actual
|
||||
*/
|
||||
public function getCurrentSummary(CashRegister $register)
|
||||
{
|
||||
$sales = Sale::where('cash_register_id', $register->id)
|
||||
->where('status', 'completed')
|
||||
->get();
|
||||
|
||||
$returns = Returns::where('cash_register_id', $register->id)->get();
|
||||
|
||||
// Calcular efectivo real en caja (recibido - devuelto)
|
||||
$cashSales = $sales->where('payment_method', 'cash')
|
||||
->sum(function ($sale) {
|
||||
return ($sale->cash_received ?? 0) - ($sale->change ?? 0);
|
||||
});
|
||||
|
||||
// Devoluciones
|
||||
$cashReturns = $returns->where('refund_method', 'cash')->sum('total');
|
||||
$cardReturns = $returns->whereIn('refund_method', ['credit_card', 'debit_card'])->sum('total');
|
||||
$totalReturns = $returns->sum('total');
|
||||
|
||||
// Confirmación, envio de los totales
|
||||
$totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received');
|
||||
$totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change');
|
||||
|
||||
$cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total');
|
||||
$totalSales = $sales->sum('total');
|
||||
$transactionCount = $sales->count();
|
||||
|
||||
return [
|
||||
'id' => $register->id,
|
||||
'user_id' => $register->user_id,
|
||||
'status' => $register->status,
|
||||
'opened_at' => $register->opened_at,
|
||||
'closed_at' => $register->closed_at,
|
||||
'initial_cash' => (float) $register->initial_cash,
|
||||
|
||||
// Totales calculados
|
||||
'total_sales' => (float) $totalSales,
|
||||
'transaction_count' => $transactionCount,
|
||||
'cash_sales' => (float) $cashSales,
|
||||
'card_sales' => (float) $cardSales,
|
||||
|
||||
// Totales de devoluciones
|
||||
'total_returns' => (float) $totalReturns,
|
||||
'cash_returns' => (float) $cashReturns,
|
||||
'card_returns' => (float) $cardReturns,
|
||||
'returns_count' => $returns->count(),
|
||||
|
||||
//Desglose de efectivo
|
||||
'total_cash_received' => (float) $totalCashReceived,
|
||||
'total_change_given' => (float) $totalChangeGiven,
|
||||
|
||||
// Efectivo esperado (ajustado por devoluciones en efectivo)
|
||||
'expected_cash' => (float) ($register->initial_cash + $cashSales - $cashReturns),
|
||||
|
||||
// Ventas netas (después de devoluciones)
|
||||
'net_sales' => (float) ($totalSales - $totalReturns),
|
||||
];
|
||||
}
|
||||
}
|
||||
166
app/Services/ClientTierService.php
Normal file
166
app/Services/ClientTierService.php
Normal file
@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientTier;
|
||||
use App\Models\ClientTierHistory;
|
||||
|
||||
class ClientTierService
|
||||
{
|
||||
/**
|
||||
* Calcular y actualizar el tier del cliente basado en sus compras totales
|
||||
*/
|
||||
public function recalculateClientTier(Client $client): ?ClientTier
|
||||
{
|
||||
// Calcular compras netas (total - devoluciones)
|
||||
$netPurchases = $client->total_purchases - $client->lifetime_returns;
|
||||
|
||||
// Buscar el tier apropiado ordenado por monto mínimo descendente
|
||||
$newTier = ClientTier::active()
|
||||
->where('min_purchase_amount', '<=', $netPurchases)
|
||||
->where(function ($query) use ($netPurchases) {
|
||||
$query->whereNull('max_purchase_amount')
|
||||
->orWhere('max_purchase_amount', '>=', $netPurchases);
|
||||
})
|
||||
->orderBy('min_purchase_amount', 'desc')
|
||||
->first();
|
||||
|
||||
// Si cambió el tier, registrar en historial
|
||||
if ($newTier && $client->tier_id !== $newTier->id) {
|
||||
$this->recordTierChange($client, $newTier, $netPurchases);
|
||||
|
||||
$client->tier_id = $newTier->id;
|
||||
$client->save();
|
||||
}
|
||||
|
||||
return $newTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar cambio de tier en el historial
|
||||
*/
|
||||
protected function recordTierChange(Client $client, ClientTier $newTier, float $totalAtChange): void
|
||||
{
|
||||
ClientTierHistory::create([
|
||||
'client_id' => $client->id,
|
||||
'old_tier_id' => $client->tier_id,
|
||||
'new_tier_id' => $newTier->id,
|
||||
'total_at_change' => $totalAtChange,
|
||||
'reason' => 'Actualización automática por cambio en compras acumuladas',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener descuento aplicable para un cliente
|
||||
*/
|
||||
public function getApplicableDiscount(Client $client): float
|
||||
{
|
||||
if (!$client->tier) {
|
||||
return 0.00;
|
||||
}
|
||||
|
||||
return $client->tier->discount_percentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar estadísticas de compra del cliente
|
||||
*/
|
||||
public function updateClientPurchaseStats(Client $client, float $amount, int $quantity = 1): void
|
||||
{
|
||||
$client->total_purchases += $amount;
|
||||
$client->total_transactions += 1;
|
||||
$client->last_purchase_at = now();
|
||||
$client->save();
|
||||
|
||||
// Recalcular tier después de actualizar stats
|
||||
$this->recalculateClientTier($client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revertir estadísticas de compra del cliente (para devoluciones)
|
||||
*/
|
||||
public function revertClientPurchaseStats(Client $client, float $amount): void
|
||||
{
|
||||
$client->lifetime_returns += $amount;
|
||||
$client->save();
|
||||
|
||||
// Recalcular tier después de registrar devolución
|
||||
$this->recalculateClientTier($client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener todos los tiers activos
|
||||
*/
|
||||
public function getActiveTiers()
|
||||
{
|
||||
return ClientTier::active()
|
||||
->orderBy('min_purchase_amount')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener tier apropiado para un monto específico
|
||||
*/
|
||||
public function getTierForAmount(float $amount): ?ClientTier
|
||||
{
|
||||
return ClientTier::active()
|
||||
->where('min_purchase_amount', '<=', $amount)
|
||||
->where(function ($query) use ($amount) {
|
||||
$query->whereNull('max_purchase_amount')
|
||||
->orWhere('max_purchase_amount', '>=', $amount);
|
||||
})
|
||||
->orderBy('min_purchase_amount', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas del cliente
|
||||
*/
|
||||
public function getClientStats(Client $client): array
|
||||
{
|
||||
return [
|
||||
'client_number' => $client->client_number,
|
||||
'total_purchases' => $client->total_purchases,
|
||||
'lifetime_returns' => $client->lifetime_returns,
|
||||
'net_purchases' => $client->net_purchases,
|
||||
'total_transactions' => $client->total_transactions,
|
||||
'last_purchase_at' => $client->last_purchase_at,
|
||||
'average_purchase' => $client->total_transactions > 0
|
||||
? $client->total_purchases / $client->total_transactions
|
||||
: 0,
|
||||
'current_tier' => $client->tier ? [
|
||||
'id' => $client->tier->id,
|
||||
'name' => $client->tier->tier_name,
|
||||
'discount' => $client->tier->discount_percentage,
|
||||
] : null,
|
||||
'next_tier' => $this->getNextTier($client),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener información del siguiente tier disponible
|
||||
*/
|
||||
protected function getNextTier(Client $client): ?array
|
||||
{
|
||||
$currentAmount = $client->net_purchases;
|
||||
$currentTierId = $client->tier_id;
|
||||
|
||||
$nextTier = ClientTier::active()
|
||||
->where('min_purchase_amount', '>', $currentAmount)
|
||||
->orderBy('min_purchase_amount')
|
||||
->first();
|
||||
|
||||
if ($nextTier) {
|
||||
return [
|
||||
'id' => $nextTier->id,
|
||||
'name' => $nextTier->tier_name,
|
||||
'discount' => $nextTier->discount_percentage,
|
||||
'required_amount' => $nextTier->min_purchase_amount,
|
||||
'remaining_amount' => $nextTier->min_purchase_amount - $currentAmount,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
1348
app/Services/InventoryMovementService.php
Normal file
1348
app/Services/InventoryMovementService.php
Normal file
File diff suppressed because it is too large
Load Diff
70
app/Services/ProductService.php
Normal file
70
app/Services/ProductService.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php namespace App\Services;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Price;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
public function createProduct(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$inventory = Inventory::create([
|
||||
'name' => $data['name'],
|
||||
'key_sat' => $data['key_sat'] ?? null,
|
||||
'sku' => $data['sku'],
|
||||
'barcode' => $data['barcode'] ?? null,
|
||||
'category_id' => $data['category_id'],
|
||||
'subcategory_id' => $data['subcategory_id'] ?? null,
|
||||
'unit_of_measure_id' => $data['unit_of_measure_id'],
|
||||
'track_serials' => $data['track_serials'] ?? false,
|
||||
]);
|
||||
|
||||
Price::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'cost' => $data['cost'] ?? 0,
|
||||
'retail_price' => $data['retail_price'],
|
||||
'tax' => $data['tax'] ?? 16.00,
|
||||
]);
|
||||
|
||||
return $inventory->load(['category', 'price', 'unitOfMeasure']);
|
||||
});
|
||||
}
|
||||
|
||||
public function updateProduct(Inventory $inventory, array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($inventory, $data) {
|
||||
// Actualizar campos de Inventory solo si están presentes
|
||||
$inventoryData = array_filter([
|
||||
'name' => $data['name'] ?? null,
|
||||
'key_sat' => $data['key_sat'] ?? null,
|
||||
'sku' => $data['sku'] ?? null,
|
||||
'barcode' => $data['barcode'] ?? null,
|
||||
'category_id' => $data['category_id'] ?? null,
|
||||
'subcategory_id' => $data['subcategory_id'] ?? null,
|
||||
'unit_of_measure_id' => $data['unit_of_measure_id'] ?? null,
|
||||
'track_serials' => $data['track_serials'] ?? null,
|
||||
], fn($value) => $value !== null);
|
||||
|
||||
if (!empty($inventoryData)) {
|
||||
$inventory->update($inventoryData);
|
||||
}
|
||||
|
||||
// Actualizar campos de Price solo si están presentes
|
||||
$priceData = array_filter([
|
||||
'cost' => $data['cost'] ?? null,
|
||||
'retail_price' => $data['retail_price'] ?? null,
|
||||
'tax' => $data['tax'] ?? null,
|
||||
], fn($value) => $value !== null);
|
||||
|
||||
if (!empty($priceData)) {
|
||||
$inventory->price()->updateOrCreate(
|
||||
['inventory_id' => $inventory->id],
|
||||
$priceData
|
||||
);
|
||||
}
|
||||
|
||||
return $inventory->fresh(['category', 'price', 'unitOfMeasure']);
|
||||
});
|
||||
}
|
||||
}
|
||||
76
app/Services/ReportService.php
Normal file
76
app/Services/ReportService.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Models\SaleDetail;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReportService
|
||||
{
|
||||
/**
|
||||
* Obtener el producto más vendido
|
||||
*
|
||||
* @param string|null $fromDate Fecha inicial (formato: Y-m-d)
|
||||
* @param string|null $toDate Fecha final (formato: Y-m-d)
|
||||
* @return array|null Retorna el producto más vendido o null si no hay ventas
|
||||
*/
|
||||
public function getTopSellingProduct(?string $fromDate = null, ?string $toDate = null): ?array
|
||||
{
|
||||
$query = SaleDetail::query()
|
||||
->selectRaw('
|
||||
inventories.id,
|
||||
inventories.name,
|
||||
inventories.sku,
|
||||
categories.name as category_name,
|
||||
SUM(sale_details.quantity) as total_quantity_sold,
|
||||
SUM(sale_details.subtotal) as total_revenue,
|
||||
COUNT(DISTINCT sale_details.sale_id) as times_sold,
|
||||
MAX(sales.created_at) as last_sale_date,
|
||||
inventories.created_at as added_date
|
||||
')
|
||||
->join('inventories', 'sale_details.inventory_id', '=', 'inventories.id')
|
||||
->join('categories', 'inventories.category_id', '=', 'categories.id')
|
||||
->join('sales', 'sale_details.sale_id', '=', 'sales.id')
|
||||
->where('sales.status', 'completed')
|
||||
->whereNull('sales.deleted_at');
|
||||
|
||||
// Aplicar filtro de fechas si se proporcionan ambas
|
||||
if ($fromDate && $toDate) {
|
||||
$query->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate]);
|
||||
}
|
||||
|
||||
$result = $query
|
||||
->groupBy('inventories.id', 'inventories.name', 'inventories.sku',
|
||||
'categories.name', 'inventories.created_at')
|
||||
->orderByDesc('total_quantity_sold')
|
||||
->first();
|
||||
|
||||
return $result ? $result->toArray() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener productos sin movimiento
|
||||
*/
|
||||
public function getProductsWithoutMovement(?string $fromDate = null, ?string $toDate = null)
|
||||
{
|
||||
// Obtener IDs de productos que SÍ tienen ventas
|
||||
$inventoriesWithSales = SaleDetail::query()
|
||||
->join('sales', 'sale_details.sale_id', '=', 'sales.id')
|
||||
->where('sales.status', 'completed')
|
||||
->whereNull('sales.deleted_at')
|
||||
->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate])
|
||||
->distinct()
|
||||
->pluck('sale_details.inventory_id')
|
||||
->toArray();
|
||||
|
||||
// Construir query para productos SIN ventas usando relaciones de Eloquent
|
||||
$query = Inventory::query()
|
||||
->with(['category', 'price'])
|
||||
->where('is_active', true)
|
||||
->whereNotIn('id', $inventoriesWithSales)
|
||||
->orderBy('created_at');
|
||||
|
||||
return $query->paginate(config('app.pagination', 10));
|
||||
}
|
||||
}
|
||||
344
app/Services/ReturnService.php
Normal file
344
app/Services/ReturnService.php
Normal file
@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CashRegister;
|
||||
use App\Models\Client;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\InventorySerial;
|
||||
use App\Models\ReturnDetail;
|
||||
use App\Models\Returns;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReturnService
|
||||
{
|
||||
public function __construct(
|
||||
protected ClientTierService $clientTierService,
|
||||
protected InventoryMovementService $movementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Crear una nueva devolución con sus detalles
|
||||
*/
|
||||
public function createReturn(array $data): Returns
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// 1. Validaciones de negocio
|
||||
$sale = Sale::with(['details.serials', 'cashRegister'])->findOrFail($data['sale_id']);
|
||||
|
||||
if ($sale->status !== 'completed') {
|
||||
throw new \Exception('Solo se pueden devolver productos de ventas completadas.');
|
||||
}
|
||||
|
||||
// Validar que la caja esté abierta si se especificó cash_register_id
|
||||
if (isset($data['cash_register_id'])) {
|
||||
$cashRegister = CashRegister::findOrFail($data['cash_register_id']);
|
||||
if (! $cashRegister->isOpen()) {
|
||||
throw new \Exception('La caja registradora debe estar abierta para procesar devoluciones.');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Calcular totales
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
|
||||
foreach ($data['items'] as &$item) {
|
||||
$saleDetail = SaleDetail::findOrFail($item['sale_detail_id']);
|
||||
$inventory = Inventory::find($saleDetail->inventory_id);
|
||||
|
||||
// Conversión de equivalencia de unidades en devolución
|
||||
$inputUnitId = $item['unit_of_measure_id'] ?? null;
|
||||
$inputQuantityReturned = (float) $item['quantity_returned'];
|
||||
|
||||
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
|
||||
$baseQuantityReturned = $usesEquivalence
|
||||
? $inventory->convertToBaseUnits($inputQuantityReturned, $inputUnitId)
|
||||
: $inputQuantityReturned;
|
||||
|
||||
// Guardar la cantidad convertida para uso posterior
|
||||
$item['_base_quantity_returned'] = $baseQuantityReturned;
|
||||
$item['_uses_equivalence'] = $usesEquivalence;
|
||||
$item['_input_unit_id'] = $inputUnitId;
|
||||
$item['_input_quantity_returned'] = $inputQuantityReturned;
|
||||
|
||||
// Validar que no se devuelva más de lo vendido (restando lo ya devuelto)
|
||||
$alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id)
|
||||
->sum('quantity_returned');
|
||||
|
||||
$maxReturnable = $saleDetail->quantity - $alreadyReturned;
|
||||
|
||||
if ($baseQuantityReturned > $maxReturnable) {
|
||||
throw new \Exception(
|
||||
"Solo puedes devolver {$maxReturnable} unidades base de {$saleDetail->product_name}. ".
|
||||
"Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas."
|
||||
);
|
||||
}
|
||||
|
||||
$itemSubtotal = $saleDetail->unit_price * $baseQuantityReturned;
|
||||
$subtotal += $itemSubtotal;
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// Calcular impuesto proporcional
|
||||
if ($sale->subtotal > 0) {
|
||||
$taxRate = $sale->tax / $sale->subtotal;
|
||||
$tax = $subtotal * $taxRate;
|
||||
}
|
||||
|
||||
$total = $subtotal + $tax;
|
||||
|
||||
// Calcular descuento devuelto proporcional si la venta tenía descuento
|
||||
$discountRefund = 0;
|
||||
if ($sale->discount_amount > 0 && $sale->total > 0) {
|
||||
$returnPercentage = $total / $sale->total;
|
||||
$discountRefund = round($sale->discount_amount * $returnPercentage, 2);
|
||||
}
|
||||
|
||||
// 3. Crear registro de devolución
|
||||
$return = Returns::create([
|
||||
'sale_id' => $sale->id,
|
||||
'user_id' => $data['user_id'],
|
||||
'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']),
|
||||
'return_number' => $this->generateReturnNumber(),
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'discount_refund' => $discountRefund,
|
||||
'refund_method' => $data['refund_method'] ?? $sale->payment_method,
|
||||
'reason' => $data['reason'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// 4. Crear detalles y restaurar seriales
|
||||
foreach ($data['items'] as $item) {
|
||||
$saleDetail = SaleDetail::findOrFail($item['sale_detail_id']);
|
||||
$baseQuantityReturned = $item['_base_quantity_returned'];
|
||||
$usesEquivalence = $item['_uses_equivalence'];
|
||||
|
||||
$returnDetail = ReturnDetail::create([
|
||||
'return_id' => $return->id,
|
||||
'sale_detail_id' => $saleDetail->id,
|
||||
'inventory_id' => $saleDetail->inventory_id,
|
||||
'product_name' => $saleDetail->product_name,
|
||||
'quantity_returned' => $baseQuantityReturned,
|
||||
'unit_of_measure_id' => $usesEquivalence ? $item['_input_unit_id'] : null,
|
||||
'unit_quantity_returned' => $usesEquivalence ? $item['_input_quantity_returned'] : null,
|
||||
'unit_price' => $saleDetail->unit_price,
|
||||
'subtotal' => $saleDetail->unit_price * $baseQuantityReturned,
|
||||
]);
|
||||
|
||||
$inventory = $saleDetail->inventory;
|
||||
|
||||
if ($inventory->track_serials) {
|
||||
// Validación de cantidad de seriales
|
||||
if (! empty($item['serial_numbers'])) {
|
||||
if (count($item['serial_numbers']) != $baseQuantityReturned) {
|
||||
throw new \Exception(
|
||||
'La cantidad de seriales proporcionados no coincide con la cantidad a devolver '.
|
||||
"para {$saleDetail->product_name}."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gestionar seriales
|
||||
if (! empty($item['serial_numbers'])) {
|
||||
// Seriales específicos proporcionados
|
||||
foreach ($item['serial_numbers'] as $serialNumber) {
|
||||
$serial = InventorySerial::where('serial_number', $serialNumber)
|
||||
->where('sale_detail_id', $saleDetail->id)
|
||||
->where('status', 'vendido')
|
||||
->first();
|
||||
|
||||
if (! $serial) {
|
||||
throw new \Exception(
|
||||
"El serial {$serialNumber} no pertenece a esta venta o ya fue devuelto."
|
||||
);
|
||||
}
|
||||
|
||||
// Marcar como devuelto y vincular a devolución
|
||||
$serial->markAsReturned($returnDetail->id);
|
||||
|
||||
// Luego restaurar a disponible
|
||||
$serial->restoreFromReturn();
|
||||
}
|
||||
} else {
|
||||
// Seleccionar automáticamente los primeros N seriales vendidos
|
||||
$serials = InventorySerial::where('sale_detail_id', $saleDetail->id)
|
||||
->where('status', 'vendido')
|
||||
->limit($baseQuantityReturned)
|
||||
->get();
|
||||
|
||||
if ($serials->count() < $baseQuantityReturned) {
|
||||
throw new \Exception(
|
||||
"No hay suficientes seriales disponibles para devolver de {$saleDetail->product_name}"
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($serials as $serial) {
|
||||
$serial->markAsReturned($returnDetail->id);
|
||||
$serial->restoreFromReturn();
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar el stock del inventario
|
||||
$saleDetail->inventory->syncStock();
|
||||
} else {
|
||||
// Restaurar stock en el almacén
|
||||
$warehouseId = $saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
|
||||
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $baseQuantityReturned);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Actualizar estadísticas del cliente si existe
|
||||
if ($sale->client_id) {
|
||||
$client = Client::find($sale->client_id);
|
||||
if ($client) {
|
||||
$this->clientTierService->revertClientPurchaseStats($client, $return->total);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Actualizar cash register con descuento devuelto y returns
|
||||
if ($return->cash_register_id) {
|
||||
$cashRegister = CashRegister::find($return->cash_register_id);
|
||||
if ($cashRegister) {
|
||||
// Actualizar tracking de devoluciones
|
||||
$cashRegister->increment('total_returns', $return->total);
|
||||
$cashRegister->increment('returns_count', 1);
|
||||
|
||||
// Actualizar tracking de descuentos devueltos (restar de total_discounts)
|
||||
if ($discountRefund > 0) {
|
||||
$cashRegister->decrement('total_discounts', $discountRefund);
|
||||
}
|
||||
|
||||
// Actualizar por método de pago
|
||||
if ($return->refund_method === 'cash') {
|
||||
$cashRegister->increment('cash_returns', $return->total);
|
||||
} else {
|
||||
$cashRegister->increment('card_returns', $return->total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Retornar con relaciones cargadas
|
||||
return $return->load([
|
||||
'details.inventory',
|
||||
'details.serials',
|
||||
'sale.client.tier',
|
||||
'user',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelar una devolución (caso excepcional, restaurar venta)
|
||||
*/
|
||||
public function cancelReturn(Returns $return): Returns
|
||||
{
|
||||
return DB::transaction(function () use ($return) {
|
||||
// Restaurar seriales a estado vendido
|
||||
foreach ($return->details as $detail) {
|
||||
if ($detail->inventory->track_serials) {
|
||||
$serials = InventorySerial::where('return_detail_id', $detail->id)->get();
|
||||
|
||||
foreach ($serials as $serial) {
|
||||
$serial->update([
|
||||
'status' => 'vendido',
|
||||
'sale_detail_id' => $detail->sale_detail_id,
|
||||
'return_detail_id' => null,
|
||||
]);
|
||||
}
|
||||
// Sincronizar stock
|
||||
$detail->inventory->syncStock();
|
||||
} else {
|
||||
// Revertir stock (la devolución lo había incrementado)
|
||||
$warehouseId = $detail->saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
|
||||
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, -$detail->quantity_returned);
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminar la devolución (soft delete)
|
||||
$return->delete();
|
||||
|
||||
return $return->fresh(['details.inventory', 'details.serials', 'sale']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener items elegibles para devolución de una venta
|
||||
*/
|
||||
public function getReturnableItems(Sale $sale): array
|
||||
{
|
||||
if ($sale->status !== 'completed') {
|
||||
throw new \Exception('Solo se pueden ver items de ventas completadas.');
|
||||
}
|
||||
|
||||
$returnableItems = [];
|
||||
|
||||
foreach ($sale->details as $detail) {
|
||||
$alreadyReturned = ReturnDetail::where('sale_detail_id', $detail->id)
|
||||
->sum('quantity_returned');
|
||||
|
||||
$maxReturnable = $detail->quantity - $alreadyReturned;
|
||||
|
||||
if ($maxReturnable > 0) {
|
||||
$availableSerials = [];
|
||||
if ($detail->inventory->track_serials) {
|
||||
// Obtener seriales vendidos que aún no han sido devueltos
|
||||
$availableSerials = InventorySerial::where('sale_detail_id', $detail->id)
|
||||
->where('status', 'vendido')
|
||||
->get()
|
||||
->map(fn ($serial) => [
|
||||
'serial_number' => $serial->serial_number,
|
||||
'status' => $serial->status,
|
||||
]);
|
||||
}
|
||||
$returnableItems[] = [
|
||||
'sale_detail_id' => $detail->id,
|
||||
'inventory_id' => $detail->inventory_id,
|
||||
'product_name' => $detail->product_name,
|
||||
'quantity_sold' => $detail->quantity,
|
||||
'quantity_already_returned' => $alreadyReturned,
|
||||
'quantity_returnable' => $maxReturnable,
|
||||
'unit_price' => $detail->unit_price,
|
||||
'available_serials' => $availableSerials,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $returnableItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar número de devolución único
|
||||
* Formato: RET-YYYYMMDD-0001
|
||||
*/
|
||||
private function generateReturnNumber(): string
|
||||
{
|
||||
$prefix = 'DEV-';
|
||||
$date = now()->format('Ymd');
|
||||
|
||||
$lastReturn = Returns::whereDate('created_at', today())
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
|
||||
$sequential = $lastReturn
|
||||
? (intval(substr($lastReturn->return_number, -4)) + 1)
|
||||
: 1;
|
||||
|
||||
return $prefix.$date.'-'.str_pad($sequential, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener caja registradora activa del usuario
|
||||
*/
|
||||
private function getCurrentCashRegister($userId): ?int
|
||||
{
|
||||
$register = CashRegister::where('user_id', $userId)
|
||||
->where('status', 'open')
|
||||
->first();
|
||||
|
||||
return $register?->id;
|
||||
}
|
||||
}
|
||||
425
app/Services/SaleService.php
Normal file
425
app/Services/SaleService.php
Normal file
@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Bundle;
|
||||
use App\Models\CashRegister;
|
||||
use App\Models\Client;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\InventorySerial;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SaleService
|
||||
{
|
||||
public function __construct(
|
||||
protected ClientTierService $clientTierService,
|
||||
protected InventoryMovementService $movementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Crear una nueva venta con sus detalles
|
||||
*/
|
||||
public function createSale(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// Obtener cliente si existe
|
||||
$client = null;
|
||||
if (isset($data['client_id'])) {
|
||||
$client = Client::find($data['client_id']);
|
||||
} elseif (isset($data['client_number'])) {
|
||||
$client = Client::where('client_number', $data['client_number'])->first();
|
||||
}
|
||||
|
||||
// Calcular descuento si el cliente tiene tier
|
||||
$discountPercentage = 0;
|
||||
$discountAmount = 0;
|
||||
$clientTierName = null;
|
||||
|
||||
if ($client && $client->tier) {
|
||||
$discountPercentage = $this->clientTierService->getApplicableDiscount($client);
|
||||
$clientTierName = $client->tier->tier_name;
|
||||
|
||||
// Calcular descuento solo sobre el subtotal (sin IVA)
|
||||
$discountAmount = round($data['subtotal'] * ($discountPercentage / 100), 2);
|
||||
|
||||
// Recalcular total: subtotal - descuento + IVA
|
||||
$data['total'] = ($data['subtotal'] - $discountAmount) + $data['tax'];
|
||||
}
|
||||
|
||||
// Calcular el cambio si es pago en efectivo
|
||||
$cashReceived = null;
|
||||
$change = null;
|
||||
|
||||
if ($data['payment_method'] === 'cash' && isset($data['cash_received'])) {
|
||||
$cashReceived = $data['cash_received'];
|
||||
$change = $cashReceived - $data['total'];
|
||||
}
|
||||
|
||||
// 1. Crear la venta principal
|
||||
$sale = Sale::create([
|
||||
'user_id' => $data['user_id'],
|
||||
'client_id' => $client?->id ?? $data['client_id'] ?? null,
|
||||
'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']),
|
||||
'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(),
|
||||
'subtotal' => $data['subtotal'],
|
||||
'tax' => $data['tax'],
|
||||
'total' => $data['total'],
|
||||
'discount_percentage' => $discountPercentage,
|
||||
'discount_amount' => $discountAmount,
|
||||
'client_tier_name' => $clientTierName,
|
||||
'cash_received' => $cashReceived,
|
||||
'change' => $change,
|
||||
'payment_method' => $data['payment_method'],
|
||||
'status' => $data['status'] ?? 'completed',
|
||||
]);
|
||||
|
||||
// 2. Expandir bundles en componentes individuales
|
||||
$expandedItems = $this->expandBundlesIntoComponents($data['items']);
|
||||
|
||||
// 2.1. Validar stock de TODOS los items (componentes + productos normales)
|
||||
$this->validateStockForAllItems($expandedItems);
|
||||
|
||||
// 3. Crear los detalles de la venta y asignar seriales
|
||||
foreach ($expandedItems as $item) {
|
||||
// Calcular descuento por detalle si aplica
|
||||
$itemDiscountAmount = 0;
|
||||
if ($discountPercentage > 0) {
|
||||
$itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2);
|
||||
}
|
||||
|
||||
// Obtener el inventario para conversión de unidades
|
||||
$inventory = Inventory::find($item['inventory_id']);
|
||||
|
||||
// Conversión de equivalencia de unidades
|
||||
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
|
||||
$inputQuantity = (float) $item['quantity'];
|
||||
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
|
||||
|
||||
if ($usesEquivalence && $inventory->track_serials) {
|
||||
throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name}).");
|
||||
}
|
||||
|
||||
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
|
||||
|
||||
// Crear detalle de venta
|
||||
$saleDetail = SaleDetail::create([
|
||||
'sale_id' => $sale->id,
|
||||
'inventory_id' => $item['inventory_id'],
|
||||
'bundle_id' => $item['bundle_id'] ?? null,
|
||||
'bundle_sale_group' => $item['bundle_sale_group'] ?? null,
|
||||
'warehouse_id' => $item['warehouse_id'] ?? null,
|
||||
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
|
||||
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
|
||||
'product_name' => $item['product_name'],
|
||||
'quantity' => $baseQuantity,
|
||||
'unit_price' => $item['unit_price'],
|
||||
'subtotal' => $item['subtotal'],
|
||||
'discount_percentage' => $discountPercentage,
|
||||
'discount_amount' => $itemDiscountAmount,
|
||||
]);
|
||||
|
||||
if ($inventory->track_serials) {
|
||||
$serialWarehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
|
||||
|
||||
// Si se proporcionaron números de serie específicos
|
||||
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
|
||||
foreach ($item['serial_numbers'] as $serialNumber) {
|
||||
$serial = InventorySerial::where('inventory_id', $inventory->id)
|
||||
->where('serial_number', $serialNumber)
|
||||
->where('warehouse_id', $serialWarehouseId)
|
||||
->where('status', 'disponible')
|
||||
->first();
|
||||
|
||||
if ($serial) {
|
||||
$serial->markAsSold($saleDetail->id, $serialWarehouseId);
|
||||
} else {
|
||||
throw new \Exception("Serial {$serialNumber} no disponible en el almacén");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Asignar automáticamente los primeros N seriales disponibles en el almacén
|
||||
$serials = InventorySerial::where('inventory_id', $inventory->id)
|
||||
->where('warehouse_id', $serialWarehouseId)
|
||||
->where('status', 'disponible')
|
||||
->limit($baseQuantity)
|
||||
->get();
|
||||
|
||||
if ($serials->count() < $baseQuantity) {
|
||||
throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}");
|
||||
}
|
||||
|
||||
foreach ($serials as $serial) {
|
||||
$serial->markAsSold($saleDetail->id, $serialWarehouseId);
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar el stock
|
||||
$inventory->syncStock();
|
||||
} else {
|
||||
// Obtener almacén (del item o el principal)
|
||||
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
|
||||
|
||||
$this->movementService->validateStock($inventory->id, $warehouseId, $baseQuantity);
|
||||
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$baseQuantity);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Actualizar estadísticas del cliente si existe
|
||||
if ($client && $sale->status === 'completed') {
|
||||
$this->clientTierService->updateClientPurchaseStats($client, $sale->total);
|
||||
}
|
||||
|
||||
// 4. Actualizar cash register con descuentos
|
||||
if ($sale->cash_register_id && $discountAmount > 0) {
|
||||
$cashRegister = CashRegister::find($sale->cash_register_id);
|
||||
if ($cashRegister) {
|
||||
$cashRegister->increment('total_discounts', $discountAmount);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Retornar la venta con sus relaciones cargadas
|
||||
return $sale->load(['details.inventory', 'details.serials', 'user', 'client.tier']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelar una venta y restaurar el stock
|
||||
*/
|
||||
public function cancelSale(Sale $sale)
|
||||
{
|
||||
return DB::transaction(function () use ($sale) {
|
||||
// Verificar que la venta esté completada
|
||||
if ($sale->status !== 'completed') {
|
||||
throw new \Exception('Solo se pueden cancelar ventas completadas.');
|
||||
}
|
||||
|
||||
// Verificar que la venta no tenga devoluciones procesadas
|
||||
if ($sale->returns()->exists()) {
|
||||
throw new \Exception('No se puede cancelar una venta que tiene devoluciones procesadas.');
|
||||
}
|
||||
|
||||
// Restaurar seriales a disponible
|
||||
foreach ($sale->details as $detail) {
|
||||
if ($detail->inventory->track_serials) {
|
||||
// Restaurar seriales
|
||||
$serials = InventorySerial::where('sale_detail_id', $detail->id)->get();
|
||||
foreach ($serials as $serial) {
|
||||
$serial->markAsAvailable();
|
||||
}
|
||||
$detail->inventory->syncStock();
|
||||
} else {
|
||||
// Restaurar stock en el almacén
|
||||
$warehouseId = $detail->warehouse_id ?? $this->movementService->getMainWarehouseId();
|
||||
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, $detail->quantity);
|
||||
}
|
||||
}
|
||||
|
||||
// Revertir estadísticas del cliente si existe
|
||||
if ($sale->client_id) {
|
||||
$client = Client::find($sale->client_id);
|
||||
if ($client) {
|
||||
// Restar de total_purchases y decrementar transacciones
|
||||
$client->decrement('total_purchases', $sale->total);
|
||||
$client->decrement('total_transactions', 1);
|
||||
|
||||
// Recalcular tier después de cancelación
|
||||
$this->clientTierService->recalculateClientTier($client);
|
||||
}
|
||||
}
|
||||
|
||||
// Revertir descuentos del cash register
|
||||
if ($sale->cash_register_id && $sale->discount_amount > 0) {
|
||||
$cashRegister = CashRegister::find($sale->cash_register_id);
|
||||
if ($cashRegister) {
|
||||
$cashRegister->decrement('total_discounts', $sale->discount_amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Marcar venta como cancelada
|
||||
$sale->update(['status' => 'cancelled']);
|
||||
|
||||
return $sale->fresh(['details.inventory', 'details.serials', 'user', 'client.tier']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar número de factura único
|
||||
* Formato: INV-YYYYMMDD-0001
|
||||
*/
|
||||
private function generateInvoiceNumber(): string
|
||||
{
|
||||
$prefix = 'INV-';
|
||||
$date = now()->format('Ymd');
|
||||
|
||||
// Obtener la última venta del día
|
||||
$lastSale = Sale::whereDate('created_at', today())
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
|
||||
// Incrementar secuencial
|
||||
$sequential = $lastSale ? (intval(substr($lastSale->invoice_number, -4)) + 1) : 1;
|
||||
|
||||
return $prefix.$date.'-'.str_pad($sequential, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function getCurrentCashRegister($userId)
|
||||
{
|
||||
$register = CashRegister::where('user_id', $userId)
|
||||
->where('status', 'open')
|
||||
->first();
|
||||
|
||||
return $register ? $register->id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandir bundles en componentes individuales
|
||||
*/
|
||||
private function expandBundlesIntoComponents(array $items): array
|
||||
{
|
||||
$expanded = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
// Detectar si es un bundle
|
||||
if (isset($item['type']) && $item['type'] === 'bundle') {
|
||||
// Es un kit, expandir en componentes
|
||||
$bundle = Bundle::with(['items.inventory.price', 'price'])->findOrFail($item['bundle_id']);
|
||||
$bundleQuantity = $item['quantity'];
|
||||
$bundleSaleGroup = Str::uuid()->toString();
|
||||
|
||||
// Calcular precio por unidad de kit (para distribuir)
|
||||
$bundleTotalPrice = $bundle->price->retail_price;
|
||||
$bundleComponentsValue = $this->calculateBundleComponentsValue($bundle);
|
||||
|
||||
foreach ($bundle->items as $bundleItem) {
|
||||
$componentInventory = $bundleItem->inventory;
|
||||
$componentQuantity = $bundleItem->quantity * $bundleQuantity;
|
||||
|
||||
// Calcular precio proporcional del componente
|
||||
$componentValue = ($componentInventory->price->retail_price ?? 0) * $bundleItem->quantity;
|
||||
$priceRatio = $bundleComponentsValue > 0 ? $componentValue / $bundleComponentsValue : 0;
|
||||
$componentUnitPrice = round($bundleTotalPrice * $priceRatio / $bundleItem->quantity, 2);
|
||||
|
||||
$expanded[] = [
|
||||
'inventory_id' => $componentInventory->id,
|
||||
'bundle_id' => $bundle->id,
|
||||
'bundle_sale_group' => $bundleSaleGroup,
|
||||
'warehouse_id' => $item['warehouse_id'] ?? null,
|
||||
'product_name' => $componentInventory->name,
|
||||
'quantity' => $componentQuantity,
|
||||
'unit_price' => $componentUnitPrice,
|
||||
'subtotal' => $componentUnitPrice * $componentQuantity,
|
||||
'serial_numbers' => $item['serial_numbers'][$componentInventory->id] ?? null,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Producto normal, agregar tal cual
|
||||
$expanded[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar stock de todos los items (antes de crear sale_details)
|
||||
*/
|
||||
private function validateStockForAllItems(array $items): void
|
||||
{
|
||||
// Agrupar cantidad total por producto+almacén para evitar que un bundle y un producto
|
||||
// suelto pasen la validación individualmente pero no haya stock suficiente en conjunto
|
||||
$aggregated = [];
|
||||
$serialsByProduct = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$inventoryId = $item['inventory_id'];
|
||||
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
|
||||
$key = "{$inventoryId}_{$warehouseId}";
|
||||
|
||||
if (! isset($aggregated[$key])) {
|
||||
$aggregated[$key] = [
|
||||
'inventory_id' => $inventoryId,
|
||||
'warehouse_id' => $warehouseId,
|
||||
'quantity' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Convertir a unidades base si usa equivalencia
|
||||
$inventory = Inventory::find($inventoryId);
|
||||
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
|
||||
$inputQuantity = (float) $item['quantity'];
|
||||
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
|
||||
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
|
||||
|
||||
$aggregated[$key]['quantity'] += $baseQuantity;
|
||||
|
||||
// Acumular seriales específicos por producto
|
||||
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
|
||||
if (! isset($serialsByProduct[$inventoryId])) {
|
||||
$serialsByProduct[$inventoryId] = [];
|
||||
}
|
||||
$serialsByProduct[$inventoryId] = array_merge(
|
||||
$serialsByProduct[$inventoryId],
|
||||
$item['serial_numbers']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validar stock total agrupado
|
||||
foreach ($aggregated as $entry) {
|
||||
$inventory = Inventory::find($entry['inventory_id']);
|
||||
|
||||
if ($inventory->track_serials) {
|
||||
if (! empty($serialsByProduct[$entry['inventory_id']])) {
|
||||
// Validar que los seriales específicos existan, estén disponibles y en el almacén correcto
|
||||
foreach ($serialsByProduct[$entry['inventory_id']] as $serialNumber) {
|
||||
$serial = InventorySerial::where('inventory_id', $inventory->id)
|
||||
->where('serial_number', $serialNumber)
|
||||
->where('warehouse_id', $entry['warehouse_id'])
|
||||
->where('status', 'disponible')
|
||||
->first();
|
||||
|
||||
if (! $serial) {
|
||||
throw new \Exception(
|
||||
"Serial {$serialNumber} no disponible en el almacén para {$inventory->name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Validar que haya suficientes seriales disponibles en el almacén
|
||||
$availableSerials = InventorySerial::where('inventory_id', $inventory->id)
|
||||
->where('warehouse_id', $entry['warehouse_id'])
|
||||
->where('status', 'disponible')
|
||||
->count();
|
||||
|
||||
if ($availableSerials < $entry['quantity']) {
|
||||
throw new \Exception(
|
||||
"Stock insuficiente de seriales para {$inventory->name}. ".
|
||||
"Disponibles: {$availableSerials}, Requeridos: {$entry['quantity']}"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Validar stock total en almacén
|
||||
$this->movementService->validateStock($inventory->id, $entry['warehouse_id'], $entry['quantity']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener precio total de componentes de un bundle
|
||||
*/
|
||||
private function calculateBundleComponentsValue(Bundle $bundle): float
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($bundle->items as $item) {
|
||||
$componentPrice = $item->inventory->price->retail_price ?? 0;
|
||||
$total += $componentPrice * $item->quantity;
|
||||
}
|
||||
|
||||
return round($total, 2);
|
||||
}
|
||||
}
|
||||
95
app/Services/WarehouseService.php
Normal file
95
app/Services/WarehouseService.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php namespace App\Services;
|
||||
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Servicio para gestión de almacenes
|
||||
*/
|
||||
class WarehouseService
|
||||
{
|
||||
/**
|
||||
* Obtener almacén principal
|
||||
*/
|
||||
public function getMainWarehouse(): ?Warehouse
|
||||
{
|
||||
return Warehouse::main()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar almacén
|
||||
*/
|
||||
public function updateWarehouse(Warehouse $warehouse, array $data): Warehouse
|
||||
{
|
||||
return DB::transaction(function () use ($warehouse, $data) {
|
||||
// Si se marca como principal, desactivar otros principales
|
||||
if (isset($data['is_main']) && $data['is_main']) {
|
||||
Warehouse::where('id', '!=', $warehouse->id)
|
||||
->where('is_main', true)
|
||||
->update(['is_main' => false]);
|
||||
}
|
||||
|
||||
// No permitir desactivar el último almacén principal
|
||||
if (isset($data['is_main']) && !$data['is_main'] && $warehouse->is_main) {
|
||||
if (Warehouse::main()->count() === 1) {
|
||||
throw new \Exception('No se puede desactivar el único almacén principal. Asigna otro almacén como principal primero.');
|
||||
}
|
||||
}
|
||||
|
||||
$warehouse->update($data);
|
||||
return $warehouse->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Activar/Desactivar almacén
|
||||
*/
|
||||
public function toggleActive(Warehouse $warehouse): Warehouse
|
||||
{
|
||||
// No permitir desactivar el almacén principal
|
||||
if ($warehouse->is_main && $warehouse->is_active) {
|
||||
throw new \Exception('No se puede desactivar el almacén principal.');
|
||||
}
|
||||
|
||||
$warehouse->update(['is_active' => !$warehouse->is_active]);
|
||||
return $warehouse->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener stock de un almacén
|
||||
*/
|
||||
public function getWarehouseStock(Warehouse $warehouse)
|
||||
{
|
||||
return $warehouse->inventories()
|
||||
->with(['category', 'price'])
|
||||
->select('inventories.*')
|
||||
->get()
|
||||
->map(function ($inventory) use ($warehouse) {
|
||||
$pivot = $inventory->warehouses()
|
||||
->where('warehouse_id', $warehouse->id)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'inventory_id' => $inventory->id,
|
||||
'name' => $inventory->name,
|
||||
'sku' => $inventory->sku,
|
||||
'category' => $inventory->category?->name,
|
||||
'stock' => $pivot?->pivot->stock ?? 0,
|
||||
'min_stock' => $pivot?->pivot->min_stock,
|
||||
'max_stock' => $pivot?->pivot->max_stock,
|
||||
'cost' => $inventory->price?->cost,
|
||||
'retail_price' => $inventory->price?->retail_price,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar que existe almacén principal
|
||||
*/
|
||||
public function ensureMainWarehouse(): void
|
||||
{
|
||||
if (!Warehouse::main()->exists()) {
|
||||
throw new \Exception('No existe un almacén principal configurado.');
|
||||
}
|
||||
}
|
||||
}
|
||||
187
app/Services/WhatsappService.php
Normal file
187
app/Services/WhatsappService.php
Normal file
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Servicio para envío de mensajes de WhatsApp
|
||||
*/
|
||||
class WhatsAppService
|
||||
{
|
||||
protected string $apiUrl;
|
||||
protected int $orgId;
|
||||
protected string $token;
|
||||
protected string $companyName;
|
||||
protected string $email = 'juan.zapata@golsystems.com.mx';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiUrl = config('services.whatsapp.api_url', 'https://whatsapp.golsystems.mx/api/send-whatsapp');
|
||||
$this->orgId = config('services.whatsapp.org_id', 1);
|
||||
$this->token = config('services.whatsapp.token');
|
||||
$this->companyName = config('services.whatsapp.company_name', 'PDV');
|
||||
|
||||
if (!$this->token) {
|
||||
throw new \Exception('El token de WhatsApp no está configurado. Agrega WHATSAPP_TOKEN en tu archivo .env');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar documento por WhatsApp usando plantilla facturas_pdv
|
||||
*/
|
||||
public function sendDocument(
|
||||
string $phoneNumber,
|
||||
string $documentUrl,
|
||||
string $filename,
|
||||
string $userEmail,
|
||||
string $ticket,
|
||||
string $customerName
|
||||
): array {
|
||||
try {
|
||||
// Construir el mensaje de WhatsApp usando plantilla
|
||||
$whatsappMessage = json_encode([
|
||||
'messaging_product' => 'whatsapp',
|
||||
'to' => $this->cleanPhoneNumber($phoneNumber),
|
||||
'type' => 'template',
|
||||
'template' => [
|
||||
'name' => 'facturas_pdv',
|
||||
'language' => [
|
||||
'code' => 'es_MX',
|
||||
],
|
||||
'components' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
[
|
||||
'type' => 'document',
|
||||
'document' => [
|
||||
'filename' => $filename,
|
||||
'link' => $documentUrl,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
[
|
||||
'type' => 'text',
|
||||
'parameter_name' => 'nombre_cliente',
|
||||
'text' => $customerName,
|
||||
],
|
||||
[
|
||||
'type' => 'text',
|
||||
'parameter_name' => 'nombre_empresa',
|
||||
'text' => $this->companyName,
|
||||
],
|
||||
[
|
||||
'type' => 'text',
|
||||
'parameter_name' => 'referencia_factura',
|
||||
'text' => $ticket,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Preparar payload completo
|
||||
$payload = [
|
||||
'message' => $whatsappMessage,
|
||||
'email' => $userEmail,
|
||||
'org_id' => $this->orgId,
|
||||
'ticket' => $ticket,
|
||||
'customer' => $customerName,
|
||||
];
|
||||
|
||||
// Enviar petición HTTP con token Bearer
|
||||
$response = Http::timeout(30)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])
|
||||
->post($this->apiUrl, $payload);
|
||||
|
||||
// Registrar en logs
|
||||
Log::channel('single')->info('WhatsApp message sent', [
|
||||
'phone' => $phoneNumber,
|
||||
'ticket' => $ticket,
|
||||
'customer' => $customerName,
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Mensaje enviado correctamente',
|
||||
'data' => $response->json(),
|
||||
];
|
||||
}
|
||||
|
||||
// Log detallado del error
|
||||
Log::channel('single')->error('WhatsApp API error', [
|
||||
'phone' => $phoneNumber,
|
||||
'status_code' => $response->status(),
|
||||
'error_body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Error al enviar el mensaje',
|
||||
'error' => $response->body(),
|
||||
'status_code' => $response->status(),
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('single')->error('WhatsApp sending failed', [
|
||||
'phone' => $phoneNumber,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Error al conectar con el servicio de WhatsApp',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar factura por WhatsApp
|
||||
*/
|
||||
public function sendInvoice(
|
||||
string $phoneNumber,
|
||||
string $pdfUrl,
|
||||
string $invoiceNumber,
|
||||
string $customerName
|
||||
): array {
|
||||
return $this->sendDocument(
|
||||
phoneNumber: $phoneNumber,
|
||||
documentUrl: $pdfUrl,
|
||||
filename: "{$invoiceNumber}.pdf",
|
||||
userEmail: $this->email,
|
||||
ticket: $invoiceNumber,
|
||||
customerName: $customerName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar número de teléfono
|
||||
*/
|
||||
protected function cleanPhoneNumber(string $phone): string
|
||||
{
|
||||
// Eliminar todo excepto números
|
||||
$cleaned = preg_replace('/[^0-9]/', '', $phone);
|
||||
|
||||
// Asegurar que tenga código de país (52 para México)
|
||||
if (strlen($cleaned) === 10) {
|
||||
$cleaned = '52' . $cleaned;
|
||||
}
|
||||
|
||||
return $cleaned;
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
"laravel/pulse": "^1.4",
|
||||
"laravel/reverb": "^1.4",
|
||||
"laravel/tinker": "^2.10",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"notsoweb/laravel-core": "dev-main",
|
||||
"spatie/laravel-permission": "^6.16",
|
||||
"tightenco/ziggy": "^2.5"
|
||||
@ -74,6 +75,10 @@
|
||||
"@php artisan migrate:fresh --seeder=DevSeeder",
|
||||
"@php artisan passport:client --personal --name=Holos"
|
||||
],
|
||||
"db:update": [
|
||||
"@php artisan migrate",
|
||||
"@php artisan migrate --path=database/migrations/seed"
|
||||
],
|
||||
"db:prod": [
|
||||
"@php artisan migrate:fresh --seed",
|
||||
"@php artisan passport:client --personal --name=Holos"
|
||||
|
||||
2576
composer.lock
generated
2576
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -35,4 +35,11 @@
|
||||
],
|
||||
],
|
||||
|
||||
'whatsapp' => [
|
||||
'api_url' => env('WHATSAPP_API_URL', 'https://whatsapp.golsystems.mx/api/send-whatsapp'),
|
||||
'org_id' => env('WHATSAPP_ORG_ID', 1),
|
||||
'token' => env('WHATSAPP_TOKEN'),
|
||||
'company_name' => env('APP_NAME', 'PDV'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('categories');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inventories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')->constrained()->onDelete('cascade');
|
||||
$table->string('name');
|
||||
$table->string('sku')->unique();
|
||||
$table->integer('stock')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventories');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('prices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('inventory_id')->constrained()->onDelete('cascade');
|
||||
$table->decimal('cost', 10, 2);
|
||||
$table->decimal('retail_price', 10, 2);
|
||||
$table->decimal('tax', 5, 2);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('prices');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user