feat: Implementación catalogo de razones de cancelación, actualizar manual, pdf contancia dañada

This commit is contained in:
Juan Felipe Zapata Moreno 2025-11-27 17:23:04 -06:00
parent a305c82956
commit 684315eb18
21 changed files with 958 additions and 98 deletions

View File

@ -4,9 +4,12 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Repuve\CancelConstanciaRequest;
use App\Models\CatalogCancellationReason;
use App\Models\Record;
use App\Models\Tag;
use App\Models\TagCancellationLog;
use App\Models\VehicleTagLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -59,7 +62,7 @@ public function cancelarConstancia(CancelConstanciaRequest $request)
'vehicle_id' => $vehicle->id,
'tag_id' => $tag->id,
'action_type' => 'cancelacion',
'cancellation_reason' => $request->cancellation_reason,
'cancellation_reason_id' => $request->cancellation_reason_id,
'cancellation_observations' => $request->cancellation_observations,
'cancellation_at' => now(),
'cancelled_by' => Auth::id(),
@ -100,7 +103,7 @@ public function cancelarConstancia(CancelConstanciaRequest $request)
'vehicle_id' => $vehicle->id,
'tag_id' => $newTag->id,
'action_type' => 'sustitucion',
'cancellation_reason' => $request->cancellation_reason,
'cancellation_reason_id' => $request->cancellation_reason_id,
'cancellation_observations' => 'Tag sustituido. Tag anterior: ' . $oldTagNumber . ' (Folio: ' . $oldFolio . '). Motivo: ' . ($request->cancellation_observations ?? ''),
'performed_by' => Auth::id(),
]);
@ -136,7 +139,7 @@ public function cancelarConstancia(CancelConstanciaRequest $request)
'tag_number' => $newTag->tag_number,
'status' => $newTag->status->name,
] : null,
'cancellation_reason' => $request->cancellation_reason,
'cancellation_reason_id' => $cancellationLog->cancellationReason->name,
'cancellation_observations' => $request->cancellation_observations,
'cancelled_at' => $cancellationLog->cancellation_at->toDateTimeString(),
'cancelled_by' => Auth::user()->name,
@ -157,4 +160,74 @@ public function cancelarConstancia(CancelConstanciaRequest $request)
]);
}
}
public function cancelarTagNoAsignado(Request $request)
{
try {
$request->validate([
'tag_number' => 'required|string|exists:tags,tag_number',
'cancellation_reason_id' => 'required|exists:catalog_cancellation_reasons,id',
'cancellation_observations' => 'nullable|string',
]);
DB::beginTransaction();
$tag = Tag::where('tag_number', $request->tag_number)->first();
// Validar que el tag NO esté asignado
if ($tag->isAssigned()) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Este tag está asignado a un vehículo. Usa el endpoint de cancelación de constancia.',
'tag_status' => $tag->status->name,
]);
}
// Validar que no esté ya cancelado
if ($tag->isCancelled()) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'El tag ya está cancelado.',
]);
}
// Crear log de cancelación ANTES de cancelar el tag
$cancellationLog = TagCancellationLog::create([
'tag_id' => $tag->id,
'cancellation_reason_id' => $request->cancellation_reason_id,
'cancellation_observations' => $request->cancellation_observations,
'cancellation_at' => now(),
'cancelled_by' => Auth::id(),
]);
// Cancelar el tag
$tag->markAsCancelled();
DB::commit();
return ApiResponse::OK->response([
'message' => 'Tag cancelado exitosamente',
'tag' => [
'id' => $tag->id,
'tag_number' => $tag->tag_number,
'folio' => $tag->folio,
'previous_status' => 'Disponible',
'new_status' => 'Cancelado',
],
'cancellation' => [
'id' => $cancellationLog->id,
'reason' => $cancellationLog->cancellationReason->name,
'observations' => $cancellationLog->cancellation_observations,
'cancelled_at' => $cancellationLog->cancellation_at->toDateTimeString(),
'cancelled_by' => Auth::user()->name,
],
]);
} catch (\Exception $e) {
DB::rollBack();
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al cancelar el tag',
'error' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Repuve;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Http\Controllers\Controller;
use App\Http\Requests\Repuve\CancelConstanciaRequest;
use App\Models\CatalogCancellationReason;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Descripción
*/
class CatalogController extends Controller
{
public function index(Request $request)
{
$type = $request->query('type');
$query = CatalogCancellationReason::query();
if ($type === 'cancelacion') {
$query->forCancellation();
} elseif ($type === 'sustitucion') {
$query->forSubstitution();
} else {
$query->orderBy('id');
}
$reasons = $query->get(['id', 'code', 'name', 'description', 'applies_to']);
return ApiResponse::OK->response([
'message' => 'Razones de cancelación obtenidas exitosamente',
'data' => $reasons,
]);
}
public function show($id)
{
$reason = CatalogCancellationReason::find($id);
if (!$reason) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Razón de cancelación no encontrada',
]);
}
return ApiResponse::OK->response([
'message' => 'Razón de cancelación obtenida exitosamente',
'data' => $reason,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'code' => 'required|string|unique:catalog_cancellation_reasons,code',
'name' => 'required|string',
'description' => 'nullable|string',
'applies_to' => 'required|in:cancelacion,sustitucion,ambos',
]);
$reason = CatalogCancellationReason::create($validated);
return ApiResponse::CREATED->response([
'message' => 'Razón de cancelación creada exitosamente',
'data' => $reason,
]);
}
public function update(Request $request, $id)
{
$reason = CatalogCancellationReason::find($id);
if (!$reason) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Razón de cancelación no encontrada',
]);
}
$validated = $request->validate([
'name' => 'required|string',
'description' => 'nullable|string',
'applies_to' => 'required|in:cancelacion,sustitucion,ambos',
]);
$reason->update($validated);
return ApiResponse::OK->response([
'message' => 'Razón de cancelación actualizada exitosamente',
'data' => $reason,
]);
}
public function destroy($id)
{
$reason = CatalogCancellationReason::find($id);
if (!$reason) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Razón de cancelación no encontrada',
]);
}
$reason->delete();
return ApiResponse::OK->response([
'message' => 'Razón de cancelación eliminada exitosamente',
]);
}
}

View File

@ -45,6 +45,7 @@ public function constanciasSustituidas(Request $request)
$logs = VehicleTagLog::with([
'vehicle',
'tag',
'cancellationReason'
])
->where('action_type', 'sustitucion')
->whereHas('vehicle.records', function ($query) use ($moduleId) {

View File

@ -5,6 +5,8 @@
use App\Http\Controllers\Controller;
use Barryvdh\DomPDF\Facade\Pdf;
use App\Models\Record;
use App\Models\Tag;
use Carbon\Carbon;
use Illuminate\Support\Facades\Storage;
use Notsoweb\ApiResponse\Enums\ApiResponse;
use Codedge\Fpdf\Fpdf\Fpdf;
@ -182,9 +184,6 @@ public function generatePdfImages($id)
public function generatePdfForm(Request $request)
{
$request->validate([
'fecha' => 'nullable|string',
'mes' => 'nullable|string',
'anio' => 'nullable|string',
'marca' => 'required|string',
'linea' => 'required|string',
'modelo' => 'required|string',
@ -195,21 +194,21 @@ public function generatePdfForm(Request $request)
'telefono' => 'nullable|string',
]);
$now = Carbon::now()->locale('es_MX');
$data = [
'fecha' => $request->input('fecha', ''),
'mes' => $request->input('mes', ''),
'anio' => $request->input('anio', ''),
'marca' => $request->input('marca'),
'linea' => $request->input('linea'),
'modelo' => $request->input('modelo'),
'niv' => $request->input('niv'),
'numero_motor' => $request->input('numero_motor'),
'placa' => $request->input('placa'),
'folio' => $request->input('folio'),
'telefono' => $request->input('telefono', ''),
'fecha' => $now->format('d'),
'mes' => ucfirst($now->translatedFormat('F')),
'anio' => $now->format('Y'),
];
$pdf = Pdf::loadView('pdfs.form', $data)
$pdf = Pdf::loadView('pdfs.tag', $data)
->setPaper('a4', 'portrait')
->setOptions([
'defaultFont' => 'sans-serif',
@ -220,6 +219,128 @@ public function generatePdfForm(Request $request)
return $pdf->stream('solicitud-sustitucion-' . time() . '.pdf');
}
public function pdfCancelledTag(Tag $tag)
{
try {
// Validar que el tag esté cancelado
if (!$tag->isCancelled()) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Solo se puede generar PDF para tags cancelados.',
'current_status' => $tag->status->name,
]);
}
// Obtener datos de cancelación
$cancellationData = $this->cancellationData($tag);
$pdf = Pdf::loadView('pdfs.tag', [
'cancellation' => $cancellationData,
])
->setPaper('a4', 'portrait')
->setOptions([
'defaultFont' => 'sans-serif',
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => true,
]);
return $pdf->stream('constancia_cancelada_' . $tag->tag_number . '.pdf');
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al generar el PDF.',
'error' => $e->getMessage(),
]);
}
}
private function cancellationData(Tag $tag)
{
$data = [
'fecha' => now()->format('d/m/Y'),
'folio' => $tag->folio ?? '',
'id_chip' => '',
'placa' => '',
'niv' => '',
'motivo' => 'N/A',
'operador' => 'N/A',
'modulo' => '',
'ubicacion' => '',
];
// Intentar obtener datos del vehículo si existe
if ($tag->vehicle_id && $tag->vehicle) {
$data['id_chip'] = $tag->vehicle->id_chip ?? '';
$data['placa'] = $tag->vehicle->placa ?? '';
$data['niv'] = $tag->vehicle->niv ?? '';
}
// Buscar log de cancelación directa
$tagCancellationLog = $tag->cancellationLogs()
->with(['cancellationReason', 'cancelledBy'])
->latest()
->first();
if ($tagCancellationLog) {
$data['motivo'] = $tagCancellationLog->cancellationReason->name ?? 'No especificado';
$data['operador'] = $tagCancellationLog->cancelledBy->name ?? 'Sistema';
// Cargar módulo del cual el usuario es responsable
if ($tagCancellationLog->cancelledBy) {
$user = $tagCancellationLog->cancelledBy;
$this->loadUserModule($user, $data);
}
return $data;
}
// Buscar log de vehículo (tag asignado y luego cancelado)
$vehicleTagLog = $tag->vehicleTagLogs()
->where('action_type', 'cancelacion')
->with(['cancellationReason', 'cancelledBy', 'vehicle'])
->latest()
->first();
if ($vehicleTagLog) {
$data['motivo'] = $vehicleTagLog->cancellationReason->name ?? 'No especificado';
$data['operador'] = $vehicleTagLog->cancelledBy->name ?? 'Sistema';
// Cargar módulo del cual el usuario es responsable
if ($vehicleTagLog->cancelledBy) {
$user = $vehicleTagLog->cancelledBy;
$this->loadUserModule($user, $data);
}
if ($vehicleTagLog->vehicle) {
$data['id_chip'] = $vehicleTagLog->vehicle->id_chip ?? '';
$data['placa'] = $vehicleTagLog->vehicle->placa ?? '';
$data['niv'] = $vehicleTagLog->vehicle->niv ?? '';
}
}
return $data;
}
/**
* Cargar módulo del usuario
*/
private function loadUserModule($user, &$data)
{
// Intentar cargar module
$user->load('module');
// Si no tiene module, usar responsibleModule
if (!$user->module) {
$user->load('responsibleModule');
if ($user->responsibleModule) {
$data['modulo'] = $user->responsibleModule->name;
$data['ubicacion'] = $user->responsibleModule->address;
}
} else {
$data['modulo'] = $user->module->name;
$data['ubicacion'] = $user->module->address;
}
}
public function errors(Request $request)
{
$request->validate([

View File

@ -9,10 +9,16 @@
class TagsController extends Controller
{
public function index()
public function index(Request $request)
{
try {
$tags = Tag::with('vehicle:id,placa,niv', 'package:id,lot,box_number')->orderBy('id', 'ASC');
$tags = Tag::with('vehicle:id,placa,niv', 'package:id,lot,box_number', 'status:id,name')->orderBy('id', 'ASC');
if ($request->has('status')) {
$tags->whereHas('status', function ($q) use ($request) {
$q->where('name', $request->status);
});
}
return ApiResponse::OK->response([
'tag' => $tags->paginate(config('app.pagination')),

View File

@ -161,7 +161,7 @@ public function vehicleUpdate(VehicleUpdateRequest $request)
if (!$tag) {
return ApiResponse::NOT_FOUND->response([
'message' => 'No se encontró el tag con el tag_number proporcionado',
'message' => 'El TAG no coincide con el registrado en el expediente',
'tag_number' => $tagNumber,
]);
}
@ -174,14 +174,100 @@ public function vehicleUpdate(VehicleUpdateRequest $request)
DB::beginTransaction();
$isManualUpdate = $request->has('vehicle') || $request->has('owner');
$hasVehicleChanges = false;
$hasOwnerChanges = false;
$owner = $vehicle->owner;
if ($isManualUpdate) {
if ($request->has('vehicle')) {
$vehicleData = $request->input('vehicle', []);
$allowedVehicleFields = [
'placa',
'marca',
'linea',
'sublinea',
'modelo',
'color',
'numero_motor',
'clase_veh',
'tipo_servicio',
'rfv',
'ofcexpedicion',
'fechaexpedicion',
'tipo_veh',
'numptas',
'observac',
'cve_vehi',
'nrpv',
'tipo_mov'
];
$vehicleData = array_filter(
array_intersect_key($vehicleData, array_flip($allowedVehicleFields)),
fn($value) => $value !== null && $value !== ''
);
if (!empty($vehicleData)) {
$vehicle->update($vehicleData);
$hasVehicleChanges = true;
}
}
if ($request->has('owner')) {
$ownerInput = $request->input('owner', []);
$allowedOwnerFields = [
'name',
'paternal',
'maternal',
'rfc',
'curp',
'address',
'tipopers',
'pasaporte',
'licencia',
'ent_fed',
'munic',
'callep',
'num_ext',
'num_int',
'colonia',
'cp',
'telefono'
];
$ownerData = array_filter(
array_intersect_key($ownerInput, array_flip($allowedOwnerFields)),
fn($value) => $value !== null && $value !== ''
);
if (!empty($ownerData)) {
// Si se cambió el RFC, buscar/crear otro propietario
if (isset($ownerData['rfc']) && $ownerData['rfc'] !== $owner->rfc) {
$owner = Owner::updateOrCreate(
['rfc' => $ownerData['rfc']],
$ownerData
);
$vehicle->update(['owner_id' => $owner->id]);
} else {
// Actualizar propietario existente
$owner->update($ownerData);
}
$hasOwnerChanges = true;
}
}
} else {
// Intentar obtener datos del caché primero
$vehicleData = Cache::get("update_vehicle_{$niv}");
$ownerData = Cache::get("update_owner_{$niv}");
$vehicleDataEstatal = Cache::get("update_vehicle_{$niv}");
$ownerDataEstatal = Cache::get("update_owner_{$niv}");
// Si no hay
if (!$vehicleData || !$ownerData) {
$vehicleData = $this->getVehicle($niv);
$ownerData = $this->getOwner($niv);
if (!$vehicleDataEstatal || !$ownerDataEstatal) {
$vehicleDataEstatal = $this->getVehicle($niv);
$ownerDataEstatal = $this->getOwner($niv);
}
// Limpiar caché
@ -189,59 +275,25 @@ public function vehicleUpdate(VehicleUpdateRequest $request)
Cache::forget("update_owner_{$niv}");
// Detectar si hay cambios
$hasVehicleChanges = $this->detectVehicleChanges($vehicle, $vehicleData);
$hasOwnerChanges = $this->detectOwnerChanges($vehicle->owner, $ownerData);
$hasVehicleChanges = $this->detectVehicleChanges($vehicle, $vehicleDataEstatal);
$hasOwnerChanges = $this->detectOwnerChanges($vehicle->owner, $ownerDataEstatal);
// Actualizar vehículo solo si hay cambios
if ($hasVehicleChanges) {
$vehicle->update($vehicleDataEstatal);
}
// Actualizar propietario solo si hay cambios
$owner = $vehicle->owner;
if ($hasOwnerChanges) {
$owner = Owner::updateOrCreate(
['rfc' => $ownerData['rfc']],
[
'name' => $ownerData['name'],
'paternal' => $ownerData['paternal'],
'maternal' => $ownerData['maternal'],
'curp' => $ownerData['curp'],
'address' => $ownerData['address'],
'tipopers' => $ownerData['tipopers'],
'pasaporte' => $ownerData['pasaporte'],
'licencia' => $ownerData['licencia'],
'ent_fed' => $ownerData['ent_fed'],
'munic' => $ownerData['munic'],
'callep' => $ownerData['callep'],
'num_ext' => $ownerData['num_ext'],
'num_int' => $ownerData['num_int'],
'colonia' => $ownerData['colonia'],
'cp' => $ownerData['cp'],
]
['rfc' => $ownerDataEstatal['rfc']],
$ownerDataEstatal
);
$vehicle->update(['owner_id' => $owner->id]);
}
}
// Actualizar vehículo solo si hay cambios
if ($hasVehicleChanges) {
$vehicle->update([
'placa' => $vehicleData['placa'],
'marca' => $vehicleData['marca'],
'linea' => $vehicleData['linea'],
'sublinea' => $vehicleData['sublinea'],
'modelo' => $vehicleData['modelo'],
'color' => $vehicleData['color'],
'numero_motor' => $vehicleData['numero_motor'],
'clase_veh' => $vehicleData['clase_veh'],
'tipo_servicio' => $vehicleData['tipo_servicio'],
'rfv' => $vehicleData['rfv'],
'rfc' => $vehicleData['rfc'],
'ofcexpedicion' => $vehicleData['ofcexpedicion'],
'fechaexpedicion' => $vehicleData['fechaexpedicion'],
'tipo_veh' => $vehicleData['tipo_veh'],
'numptas' => $vehicleData['numptas'],
'observac' => $vehicleData['observac'],
'cve_vehi' => $vehicleData['cve_vehi'],
'nrpv' => $vehicleData['nrpv'],
'tipo_mov' => $vehicleData['tipo_mov'],
'owner_id' => $owner->id,
]);
}
$uploadedFiles = [];
$replacedFiles = [];
@ -268,7 +320,7 @@ public function vehicleUpdate(VehicleUpdateRequest $request)
if ($nameId === null) {
DB::rollBack();
return ApiResponse::BAD_REQUEST->response([
'message' => "Falta el name_id para el archivo",
'message' => "Falta el nombre para el archivo, busca en el catálogo de nombres",
]);
}
@ -317,7 +369,7 @@ public function vehicleUpdate(VehicleUpdateRequest $request)
'vehicle_id' => $vehicle->id,
'tag_id' => $tag->id,
'action_type' => 'actualizacion',
'performed_by' => Auth::id(),
'performed_by' => Auth::id()
]);
}
@ -510,7 +562,6 @@ private function detectVehicleChanges($vehicle, array $vehicleDataEstatal): bool
'clase_veh',
'tipo_servicio',
'rfv',
'rfc',
'ofcexpedicion',
'tipo_veh',
'numptas',

View File

@ -20,9 +20,9 @@ public function authorize(): bool
public function rules(): array
{
return [
'record_id' => 'required|exists:records,id',
'record_id' => 'nullable|exists:records,id',
'folio' => 'required|string',
'cancellation_reason' => 'required|in:fallo_lectura_handheld,cambio_parabrisas,roto_al_pegarlo,extravio,otro',
'cancellation_reason_id' => 'required|exists:catalog_cancellation_reasons,id',
'cancellation_observations' => 'nullable|string',
'new_tag_number' => 'nullable|exists:tags,tag_number',
];
@ -38,11 +38,11 @@ public function messages(): array
'record_id.integer' => 'El id del expediente debe ser un número entero.',
'record_id.exists' => 'El expediente especificado no existe.',
'cancellation_reason.required' => 'El motivo de cancelación es obligatorio.',
'cancellation_reason.in' => 'El motivo de cancelación no es válido. Opciones: fallo_lectura_handheld, cambio_parabrisas, roto_al_pegarlo, extravio, otro.',
'cancellation_reason_id.required' => 'El motivo de cancelación es obligatorio.',
'cancellation_reason_id.exists' => 'El motivo de cancelación no es válido.',
'cancellation_observations.string' => 'Las observaciones deben ser texto.',
'cancellation_observations.max' => 'Las observaciones no pueden exceder 1000 caracteres.',
'new_tag_number.exists' => 'El nuevo tag no existe',
'folio.required_with' => 'El folio es requerido cuando se proporciona un nuevo tag',
];
}

View File

@ -1,4 +1,6 @@
<?php namespace App\Http\Requests\Repuve;
<?php
namespace App\Http\Requests\Repuve;
use Illuminate\Foundation\Http\FormRequest;
@ -13,18 +15,63 @@ public function authorize(): bool
public function rules(): array
{
return [
'files' => ['nullable', 'array', 'min:1'],
'files.*' => ['file', 'mimes:jpeg,png,jpg,pdf', 'max:10240'],
'names' => ['nullable', 'array'],
'names.*' => ['string', 'max:255'],
// --- DATOS DEL VEHÍCULO ---
'vehicle.placa' => 'nullable|string|max:20',
'vehicle.marca' => 'nullable|string|max:100',
'vehicle.linea' => 'nullable|string|max:100',
'vehicle.sublinea' => 'nullable|string|max:100',
'vehicle.modelo' => 'nullable|string',
'vehicle.color' => 'nullable|string|max:50',
'vehicle.numero_motor' => 'nullable|string|max:50',
'vehicle.clase_veh' => 'nullable|string|max:50',
'vehicle.tipo_servicio' => 'nullable|string|max:50',
'vehicle.rfv' => 'nullable|string|max:50',
'vehicle.rfc' => 'nullable|string|max:13',
'vehicle.ofcexpedicion' => 'nullable|string|max:100',
'vehicle.fechaexpedicion' => 'nullable|date',
'vehicle.tipo_veh' => 'nullable|string|max:50',
'vehicle.numptas' => 'nullable|string',
'vehicle.observac' => 'nullable|string|max:500',
'vehicle.cve_vehi' => 'nullable|string|max:50',
'vehicle.nrpv' => 'nullable|string|max:50',
'vehicle.tipo_mov' => 'nullable|string|max:50',
// --- DATOS DEL PROPIETARIO ---
'owner.name' => 'nullable|string|max:100',
'owner.paternal' => 'nullable|string|max:100',
'owner.maternal' => 'nullable|string|max:100',
'owner.rfc' => 'nullable|string|max:13',
'owner.curp' => 'nullable|string|max:18',
'owner.address' => 'nullable|string|max:255',
'owner.tipopers' => 'nullable|boolean',
'owner.pasaporte' => 'nullable|string|max:20',
'owner.licencia' => 'nullable|string|max:20',
'owner.ent_fed' => 'nullable|string|max:50',
'owner.munic' => 'nullable|string|max:100',
'owner.callep' => 'nullable|string|max:100',
'owner.num_ext' => 'nullable|string|max:10',
'owner.num_int' => 'nullable|string|max:10',
'owner.colonia' => 'nullable|string|max:100',
'owner.cp' => 'nullable|string|max:5',
'owner.telefono' => 'nullable|string|max:15',
// --- ARCHIVOS ---
'files' => 'nullable|array|min:1',
'files.*' => 'file|mimes:jpeg,png,jpg|max:2048',
'name_id' => 'nullable|array',
'name_id.*' => 'integer|exists:catalog_name_img,id',
];
}
public function messages(): array
{
return [
'vehicle.modelo.string' => 'El modelo debe ser texto',
'vehicle.numptas.string' => 'El número de puertas debe ser texto',
'owner.tipopers.boolean' => 'El tipo de persona debe ser física o Moral',
'owner.cp.max' => 'El código postal debe tener máximo 5 caracteres',
'files.*.mimes' => 'Solo se permiten archivos JPG, PNG o JPEG',
'files.*.max' => 'El archivo no debe superar 3MB',
'files.*.max' => 'El archivo no debe superar 2MB',
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CatalogCancellationReason extends Model
{
use HasFactory;
protected $fillable = [
'code',
'name',
'description',
'applies_to'
];
/**
* Obtener razones para cancelación
*/
public function scopeForCancellation($query)
{
return $query->whereIn('applies_to', ['cancelacion', 'ambos']);
}
/**
* Obtener razones para sustitución
*/
public function scopeForSubstitution($query)
{
return $query->whereIn('applies_to', ['sustitucion', 'ambos']);
}
/**
* Logs que usan esta razón
*/
public function vehicleTagLogs()
{
return $this->hasMany(VehicleTagLog::class, 'cancellation_reason_id');
}
}

View File

@ -48,6 +48,11 @@ public function scanHistories()
return $this->hasMany(ScanHistory::class);
}
public function cancellationLogs()
{
return $this->hasMany(TagCancellationLog::class);
}
/**
* Marcar tag como asignado a un vehículo
*/

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Log de cancelación de tags no asignados
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class TagCancellationLog extends Model
{
use HasFactory;
protected $table = 'tag_cancellation_logs';
protected $fillable = [
'tag_id',
'cancellation_reason_id',
'cancellation_observations',
'cancellation_at',
'cancelled_by',
];
protected function casts(): array
{
return [
'cancellation_at' => 'datetime',
];
}
// Relaciones
public function tag()
{
return $this->belongsTo(Tag::class);
}
public function cancellationReason()
{
return $this->belongsTo(CatalogCancellationReason::class, 'cancellation_reason_id');
}
public function cancelledBy()
{
return $this->belongsTo(User::class, 'cancelled_by');
}
}

View File

@ -133,4 +133,12 @@ public function module()
{
return $this->belongsTo(Module::class);
}
/**
* Módulo del cual el usuario es responsable
*/
public function responsibleModule()
{
return $this->hasOne(Module::class, 'responsible_id');
}
}

View File

@ -15,7 +15,7 @@ class VehicleTagLog extends Model
'vehicle_id',
'tag_id',
'action_type',
'cancellation_reason',
'cancellation_reason_id',
'cancellation_observations',
'cancellation_at',
'cancelled_by',
@ -41,6 +41,11 @@ public function cancelledBy() {
return $this->belongsTo(User::class, 'cancelled_by');
}
public function cancellationReason()
{
return $this->belongsTo(CatalogCancellationReason::class, 'cancellation_reason_id');
}
public function isInscription()
{
return $this->action_type === 'inscripcion';

View File

@ -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('catalog_cancellation_reasons', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->text('description')->nullable();
$table->enum('applies_to', ['cancelacion', 'sustitucion', 'ambos']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('catalog_cancellation_reasons');
}
};

View File

@ -0,0 +1,46 @@
<?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::table('vehicle_tags_logs', function (Blueprint $table) {
// Agregar nueva columna con foreign key
$table->foreignId('cancellation_reason_id')
->nullable()
->after('tag_id')
->constrained('catalog_cancellation_reasons')
->nullOnDelete();
// Eliminar el enum viejo
$table->dropColumn('cancellation_reason');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('vehicle_tags_logs', function (Blueprint $table) {
$table->dropForeign(['cancellation_reason_id']);
$table->dropColumn('cancellation_reason_id');
// Restaurar enum (solo si es necesario hacer rollback)
$table->enum('cancellation_reason', [
'fallo_lectura_handheld',
'cambio_parabrisas',
'roto_al_pegarlo',
'extravio',
'otro'
])->nullable();
});
}
};

View File

@ -0,0 +1,32 @@
<?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('tag_cancellation_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete();
$table->foreignId('cancellation_reason_id')->nullable()->constrained('catalog_cancellation_reasons')->nullOnDelete();
$table->text('cancellation_observations')->nullable();
$table->timestamp('cancellation_at');
$table->foreignId('cancelled_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tag_cancellation_logs');
}
};

View File

@ -0,0 +1,70 @@
<?php namespace Database\Seeders;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Models\CatalogCancellationReason;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class CatalogCancellationReasonSeeder extends Seeder
{
/**
* Ejecutar sembrado de base de datos
*/
public function run(): void
{
$reasons = [
[
'code' => '01',
'name' => 'Fallo de lectura en handheld',
'description' => 'El dispositivo handheld no puede leer el TAG correctamente',
'applies_to' => 'ambos',
],
[
'code' => '02',
'name' => 'Cambio de parabrisas',
'description' => 'El vehículo requirió cambio de parabrisas',
'applies_to' => 'sustitucion',
],
[
'code' => '03',
'name' => 'TAG roto al pegarlo',
'description' => 'El TAG se dañó durante la instalación',
'applies_to' => 'sustitucion',
],
[
'code' => '04',
'name' => 'Extravío del TAG',
'description' => 'El TAG fue extraviado o perdido',
'applies_to' => 'ambos',
],
[
'code' => '05',
'name' => 'Daño físico del TAG',
'description' => 'El TAG presenta daño físico que impide su funcionamiento',
'applies_to' => 'ambos',
],
[
'code' => '06',
'name' => 'Otro motivo',
'description' => 'Motivo no especificado en las opciones anteriores',
'applies_to' => 'ambos',
],
];
foreach ($reasons as $reason) {
CatalogCancellationReason::updateOrCreate(
['code' => $reason['code']],
$reason
);
}
}
}

View File

@ -27,5 +27,6 @@ public function run(): void
$this->call(MunicipalitySeeder::class);
$this->call(ModuleSeeder::class);
$this->call(CatalogTagStatusSeeder::class);
$this->call(CatalogCancellationReasonSeeder::class);
}
}

View File

@ -200,8 +200,7 @@
<!-- Fecha -->
<div class="date-line">
<span class="date-blank">{{ $fecha ?? '' }}</span> de
<span class="date-blank" style="min-width: 90px;">{{ $mes ?? '' }}</span> del 20<span class="date-blank" style="min-width: 30px;">{{ $anio ?? '' }}</span>
{{ $fecha }} de {{ $mes }} del {{ $anio }}
</div>
<!-- Título -->

View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Constancia Cancelada - REPUVE Tabasco</title>
<style>
@page {
margin: 2cm 1.5cm;
}
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 10pt;
color: #000;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
border: 2px solid #000;
}
.header {
text-align: center;
background-color: #f0f0f0;
color: #000;
padding: 8px;
font-weight: bold;
font-size: 14pt;
margin: 0;
border-bottom: 1px solid #000;
}
.subheader {
text-align: center;
background-color: #f0f0f0;
padding: 5px;
font-weight: bold;
font-size: 11pt;
border-bottom: 1px solid #000;
}
.space-box {
height: 200px;
margin: 0;
position: relative;
font-weight: bold;
font-size: 16pt;
text-align: center;
border-bottom: 1px solid #000;
}
.space-box-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-label {
background-color: #f0f0f0;
font-weight: bold;
padding: 8px 10px;
width: 25%;
border-right: 1px solid #000;
border-bottom: 1px solid #000;
font-size: 9pt;
}
.data-value {
padding: 8px 10px;
width: 75%;
border-bottom: 1px solid #000;
font-size: 10pt;
}
.data-row:last-child .data-label,
.data-row:last-child .data-value {
border-bottom: none;
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
REPUVE TABASCO
</div>
<!-- Subheader -->
<div class="subheader">
CONSTANCIA DAÑADA
</div>
<!-- Espacio para pegar constancia -->
<div class="space-box">
<div class="space-box-text">
PEGAR CONSTANCIA
</div>
</div>
<!-- Tabla de datos -->
<table class="data-table">
<tr class="data-row">
<td class="data-label">FECHA:</td>
<td class="data-value">{{ $cancellation['fecha'] }}</td>
</tr>
<tr class="data-row">
<td class="data-label">FOLIO:</td>
<td class="data-value">{{ $cancellation['folio'] ?? '' }}</td>
</tr>
<tr class="data-row">
<td class="data-label">ID CHIP:</td>
<td class="data-value">{{ $cancellation['id_chip'] ?? '' }}</td>
</tr>
<tr class="data-row">
<td class="data-label">PLACAS:</td>
<td class="data-value">{{ $cancellation['placa'] ?? '' }}</td>
</tr>
<tr class="data-row">
<td class="data-label">VIN:</td>
<td class="data-value">{{ $cancellation['niv'] ?? '' }}</td>
</tr>
<tr class="data-row">
<td class="data-label">MOTIVO:</td>
<td class="data-value">{{ strtoupper($cancellation['motivo'] ?? '') }}</td>
</tr>
<tr class="data-row">
<td class="data-label">OPERADOR:</td>
<td class="data-value">{{ strtoupper($cancellation['operador'] ?? '') }}</td>
</tr>
<tr class="data-row">
<td class="data-label">MÓDULO:</td>
<td class="data-value">{{ $cancellation['modulo']}}</td>
</tr>
<tr class="data-row">
<td class="data-label">UBICACIÓN:</td>
<td class="data-value">{{ $cancellation['ubicacion'] }}</td>
</tr>
</table>
</div>
</body>
</html>

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Repuve\CatalogController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Repuve\MunicipalityController;
use App\Http\Controllers\Repuve\RecordController;
@ -39,6 +40,7 @@
Route::get('expediente/{id}/pdfVerificacion', [RecordController::class, 'generatePdfVerification']);
Route::get('expediente/{id}/pdfConstancia', [RecordController::class, 'generatePdfConstancia']);
Route::get('expediente/{id}/pdfImagenes', [RecordController::class, 'generatePdfImages']);
Route::get('tags/{tag}/pdfTag-cancelado', [RecordController::class, 'pdfCancelledTag']);
Route::post('expediente/pdfFormulario', [RecordController::class, 'generatePdfForm']);
Route::get('RecordErrors', [RecordController::class, 'errors']);
@ -47,7 +49,9 @@
Route::post('actualizar', [UpdateController::class, 'vehicleUpdate']);
// Rutas de cancelación de constancias
Route::resource('/razones-cancelacion', CatalogController::class);
Route::delete('cancelacion', [CancellationController::class, 'cancelarConstancia']);
Route::post('tags/cancelar', [CancellationController::class, 'cancelarTagNoAsignado']);
Route::get('excel/constancias-sustituidas', [ExcelController::class, 'constanciasSustituidas']);
//Rutas de Modulos