From 093cea3c4cc9f38f77ac4812b7d0a7da56575e31 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Sun, 8 Feb 2026 20:26:59 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20reportes=20de=20movimientos,=20inventar?= =?UTF-8?q?io=20por=20almac=C3=A9n=20y=20series?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Habilita vista de stock con filtros y selección de series en traspasos. - Implementa servicio de tickets PDF y corrige datos (ubicación/negocio). - Renombra botón a Reporte y elimina opción de almacén principal. --- src/components/POS/SerialSelector.vue | 8 +- src/pages/POS/Inventory/ImportModal.vue | 12 +- src/pages/POS/Inventory/Serials.vue | 228 +-------------- src/pages/POS/Movements/DetailModal.vue | 27 +- src/pages/POS/Movements/EntryModal.vue | 90 +++++- src/pages/POS/Movements/ExitModal.vue | 136 ++++++++- src/pages/POS/Movements/Index.vue | 2 +- src/pages/POS/Movements/TransferModal.vue | 147 +++++++++- src/pages/POS/Sales/Index.vue | 3 - src/pages/POS/Warehouses/Create.vue | 17 +- src/pages/POS/Warehouses/Edit.vue | 15 - src/pages/POS/Warehouses/Index.vue | 16 +- src/pages/POS/Warehouses/Inventory.vue | 288 +++++++++++++++++++ src/router/Index.js | 6 + src/services/TicketDetailMovement.js | 330 ++++++++++++++++++++++ src/services/serialService.js | 70 +---- src/services/ticketService.js | 4 +- 17 files changed, 1048 insertions(+), 351 deletions(-) create mode 100644 src/pages/POS/Warehouses/Inventory.vue create mode 100644 src/services/TicketDetailMovement.js diff --git a/src/components/POS/SerialSelector.vue b/src/components/POS/SerialSelector.vue index d647703..3a5fc5c 100644 --- a/src/components/POS/SerialSelector.vue +++ b/src/components/POS/SerialSelector.vue @@ -1,5 +1,5 @@ + + diff --git a/src/router/Index.js b/src/router/Index.js index 45b1fa9..0d57fb0 100644 --- a/src/router/Index.js +++ b/src/router/Index.js @@ -101,6 +101,12 @@ const router = createRouter({ beforeEnter: (to, from, next) => can(next, 'warehouses.index'), component: () => import('@Pages/POS/Warehouses/Index.vue') }, + { + path: 'warehouses/:id/inventory', + name: 'pos.warehouses.inventory', + beforeEnter: (to, from, next) => can(next, 'warehouses.index'), + component: () => import('@Pages/POS/Warehouses/Inventory.vue') + }, { path: 'movements', name: 'pos.movements.index', diff --git a/src/services/TicketDetailMovement.js b/src/services/TicketDetailMovement.js new file mode 100644 index 0000000..81b9e49 --- /dev/null +++ b/src/services/TicketDetailMovement.js @@ -0,0 +1,330 @@ +import jsPDF from 'jspdf'; +import { formatMoney } from '@/utils/formatters'; +import printService from '@Services/printService'; + +/** + * Servicio para generar tickets de movimientos de inventario en formato PDF + */ +const TicketDetailMovement = { + + /** + * Detectar ubicación del usuario (ciudad y estado) + */ + async getUserLocation() { + return { + city: import.meta.env.VITE_BUSINESS_CITY || 'Villahermosa', + state: import.meta.env.VITE_BUSINESS_STATE || 'Tabasco', + country: import.meta.env.VITE_BUSINESS_COUNTRY || 'México', + phone: import.meta.env.VITE_BUSINESS_PHONE || 'Tel: (52) 0000-0000' + }; + }, + + /** + * Generar ticket de movimiento de inventario + * @param {Object} movementData - Datos del movimiento + * @param {Object} options - Opciones de configuración + * @param {boolean} options.autoDownload - Descargar automáticamente el PDF + */ + async generateMovementTicket(movementData, options = {}) { + const { + businessName = 'HIKVISION DISTRIBUIDOR', + autoDownload = true, + } = options; + + // Detectar ubicación del usuario + const location = await this.getUserLocation(); + const businessAddress = `${location.city}, ${location.state}, ${location.country}`; + const businessPhone = location.phone; + + // Crear documento PDF - Ticket térmico 80mm de ancho + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: [80, 200] + }); + + let yPosition = 10; + const leftMargin = 5; + const rightMargin = 75; + const centerX = 40; + + // Colores + const blackColor = [0, 0, 0]; + const darkGrayColor = [80, 80, 80]; + + // Configuración por tipo + const typeBadges = { + entry: { label: 'ENTRADA', color: [34, 197, 94] }, + exit: { label: 'SALIDA', color: [239, 68, 68] }, + transfer: { label: 'TRASPASO', color: [59, 130, 246] }, + }; + const badge = typeBadges[movementData.movement_type] || { + label: movementData.movement_type?.toUpperCase(), + color: darkGrayColor + }; + + // ===== HEADER ===== + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...blackColor); + doc.text(businessName, centerX, yPosition, { align: 'center' }); + yPosition += 6; + + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...darkGrayColor); + doc.text(businessAddress, centerX, yPosition, { align: 'center' }); + yPosition += 4; + doc.text(businessPhone, centerX, yPosition, { align: 'center' }); + yPosition += 6; + + // Línea separadora + doc.setLineWidth(0.3); + doc.setDrawColor(...blackColor); + doc.line(leftMargin, yPosition, rightMargin, yPosition); + yPosition += 5; + + // ===== TÍTULO CON TIPO ===== + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...badge.color); + doc.text(badge.label, centerX, yPosition, { align: 'center' }); + yPosition += 5; + + doc.setFontSize(10); + doc.setTextColor(...blackColor); + doc.text('MOVIMIENTO DE INVENTARIO', centerX, yPosition, { align: 'center' }); + yPosition += 7; + + // ===== INFORMACIÓN DEL MOVIMIENTO ===== + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...darkGrayColor); + + // Fecha + const movementDate = new Date(movementData.created_at || Date.now()); + const formattedDate = movementDate.toLocaleDateString('es-MX', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + doc.text(`Fecha: ${formattedDate}`, leftMargin, yPosition); + yPosition += 4; + + // Hora + const formattedTime = movementDate.toLocaleTimeString('es-MX', { + hour: '2-digit', + minute: '2-digit' + }); + doc.text(`Hora: ${formattedTime}`, leftMargin, yPosition); + yPosition += 4; + + // Usuario + if (movementData.user?.name) { + doc.text(`Usuario: ${movementData.user.name}`, leftMargin, yPosition); + yPosition += 4; + } + + // Referencia de factura (si existe) + if (movementData.invoice_reference) { + doc.text(`Factura: ${movementData.invoice_reference}`, leftMargin, yPosition); + yPosition += 4; + } + + yPosition += 2; + + // ===== ALMACENES ===== + // Almacén origen (si existe) + if (movementData.warehouse_from) { + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...[239, 68, 68]); // red + doc.text('ORIGEN:', leftMargin, yPosition); + yPosition += 3; + + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...blackColor); + doc.text(`${movementData.warehouse_from.name} (${movementData.warehouse_from.code})`, leftMargin + 2, yPosition); + yPosition += 4; + } + + // Almacén destino (si existe) + if (movementData.warehouse_to) { + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...[34, 197, 94]); // green + doc.text('DESTINO:', leftMargin, yPosition); + yPosition += 3; + + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...blackColor); + doc.text(`${movementData.warehouse_to.name} (${movementData.warehouse_to.code})`, leftMargin + 2, yPosition); + yPosition += 4; + } + + yPosition += 2; + + // Línea separadora + doc.setDrawColor(...blackColor); + doc.setLineWidth(0.3); + doc.line(leftMargin, yPosition, rightMargin, yPosition); + yPosition += 5; + + // ===== PRODUCTOS ===== + const isMultiProduct = movementData.products && movementData.products.length > 0; + + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...blackColor); + doc.text('PRODUCTOS', centerX, yPosition, { align: 'center' }); + yPosition += 5; + + if (isMultiProduct) { + // Múltiples productos + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...darkGrayColor); + doc.text('DESCRIPCIÓN', leftMargin, yPosition); + doc.text('CANT', 50, yPosition, { align: 'center' }); + doc.text('COSTO', rightMargin, yPosition, { align: 'right' }); + yPosition += 4; + + // Línea bajo header + doc.setLineWidth(0.2); + doc.line(leftMargin, yPosition, rightMargin, yPosition); + yPosition += 4; + + // Iterar sobre los productos + movementData.products.forEach((product) => { + // Nombre del producto + const productName = product.inventory?.name || 'Producto'; + const nameLines = doc.splitTextToSize(productName, 40); + + doc.setFontSize(8); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...blackColor); + doc.text(nameLines, leftMargin, yPosition); + yPosition += nameLines.length * 3.5; + + // SKU + if (product.inventory?.sku) { + doc.setFontSize(7); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...darkGrayColor); + doc.text(`SKU: ${product.inventory.sku}`, leftMargin, yPosition); + yPosition += 3; + } + + // Cantidad y Costo + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...blackColor); + + doc.text(String(product.quantity), 50, yPosition, { align: 'center' }); + doc.text(`$${formatMoney(product.unit_cost || 0)}`, rightMargin, yPosition, { align: 'right' }); + yPosition += 5; + }); + + // Total + const totalQuantity = movementData.products.reduce((sum, p) => sum + Number(p.quantity), 0); + const totalCost = movementData.products.reduce((sum, p) => sum + (Number(p.quantity) * Number(p.unit_cost || 0)), 0); + + yPosition += 2; + doc.setLineWidth(0.3); + doc.line(leftMargin, yPosition, rightMargin, yPosition); + yPosition += 5; + + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.text('TOTAL:', leftMargin, yPosition); + doc.text(String(totalQuantity), 50, yPosition, { align: 'center' }); + doc.text(`$${formatMoney(totalCost)}`, rightMargin, yPosition, { align: 'right' }); + yPosition += 6; + + } else { + // Producto individual + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...blackColor); + + const productName = movementData.inventory?.name || 'Producto'; + const nameLines = doc.splitTextToSize(productName, 65); + doc.text(nameLines, leftMargin, yPosition); + yPosition += nameLines.length * 4; + + if (movementData.inventory?.sku) { + doc.setFontSize(7); + doc.setTextColor(...darkGrayColor); + doc.text(`SKU: ${movementData.inventory.sku}`, leftMargin, yPosition); + yPosition += 4; + } + + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...blackColor); + doc.text(`Cantidad: ${movementData.quantity}`, leftMargin, yPosition); + yPosition += 5; + + if (movementData.unit_cost) { + doc.text(`Costo unitario: $${formatMoney(movementData.unit_cost)}`, leftMargin, yPosition); + yPosition += 5; + + const total = Number(movementData.quantity) * Number(movementData.unit_cost); + doc.text(`Total: $${formatMoney(total)}`, leftMargin, yPosition); + yPosition += 6; + } else { + yPosition += 1; + } + } + + // Notas (si existen) + if (movementData.notes) { + doc.setLineWidth(0.2); + doc.setDrawColor(...darkGrayColor); + doc.line(leftMargin, yPosition, rightMargin, yPosition); + yPosition += 5; + + doc.setFontSize(7); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...darkGrayColor); + doc.text('NOTAS:', leftMargin, yPosition); + yPosition += 3; + + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...blackColor); + const notesLines = doc.splitTextToSize(movementData.notes, 65); + doc.text(notesLines, leftMargin, yPosition); + yPosition += notesLines.length * 3.5 + 3; + } + + // Línea decorativa doble + doc.setLineWidth(0.3); + doc.setDrawColor(...darkGrayColor); + doc.line(leftMargin, yPosition, rightMargin, yPosition); + yPosition += 0.5; + doc.setLineWidth(0.2); + doc.line(leftMargin, yPosition, rightMargin, yPosition); + yPosition += 6; + + // Información de impresión + doc.setFontSize(7); + const currentDate = new Date().toLocaleString('es-MX', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + doc.text(`Impreso: ${currentDate}`, centerX, yPosition, { align: 'center' }); + + // Guardar o retornar el PDF + if (autoDownload) { + const fileName = `movimiento-${badge.label.toLowerCase()}-${movementData.id}.pdf`; + doc.save(fileName); + } + + return doc; + } +}; + +export default TicketDetailMovement; diff --git a/src/services/serialService.js b/src/services/serialService.js index dbff0a4..685d062 100644 --- a/src/services/serialService.js +++ b/src/services/serialService.js @@ -27,10 +27,15 @@ const serialService = { /** * Obtener solo seriales disponibles de un producto * @param {Number} inventoryId - ID del inventario + * @param {Number} warehouseId - ID del almacén (opcional) * @returns {Promise} */ - async getAvailableSerials(inventoryId) { - return this.getSerials(inventoryId, { status: 'disponible' }); + async getAvailableSerials(inventoryId, warehouseId = null) { + const filters = { status: 'disponible' }; + if (warehouseId) { + filters.warehouse_id = warehouseId; + } + return this.getSerials(inventoryId, filters); }, /** @@ -52,67 +57,6 @@ const serialService = { }); }, - /** - * Crear un nuevo serial - * @param {Number} inventoryId - ID del inventario - * @param {Object} data - Datos del serial (serial_number, notes) - * @returns {Promise} - */ - async createSerial(inventoryId, data) { - return new Promise((resolve, reject) => { - api.post(apiURL(`inventario/${inventoryId}/serials`), { - data: data, - onSuccess: (response) => { - resolve(response); - }, - onError: (error) => { - reject(error); - } - }); - }); - }, - - /** - * Importar múltiples seriales - * @param {Number} inventoryId - ID del inventario - * @param {Array} serialNumbers - Array de números de serie - * @returns {Promise} - */ - async bulkImport(inventoryId, serialNumbers) { - return new Promise((resolve, reject) => { - api.post(apiURL(`inventario/${inventoryId}/serials/bulk`), { - data: { serial_numbers: serialNumbers }, - onSuccess: (response) => { - resolve(response); - }, - onError: (error) => { - reject(error); - } - }); - }); - }, - - /** - * Actualizar un serial - * @param {Number} inventoryId - ID del inventario - * @param {Number} serialId - ID del serial - * @param {Object} data - Datos a actualizar (serial_number, status, notes) - * @returns {Promise} - */ - async updateSerial(inventoryId, serialId, data) { - return new Promise((resolve, reject) => { - api.put(apiURL(`inventario/${inventoryId}/serials/${serialId}`), { - data: data, - onSuccess: (response) => { - resolve(response); - }, - onError: (error) => { - reject(error); - } - }); - }); - }, - /** * Eliminar un serial * @param {Number} inventoryId - ID del inventario diff --git a/src/services/ticketService.js b/src/services/ticketService.js index 80db1e6..a4885cf 100644 --- a/src/services/ticketService.js +++ b/src/services/ticketService.js @@ -13,8 +13,8 @@ const ticketService = { */ async getUserLocation() { return { - city: import.meta.env.VITE_BUSINESS_CITY || 'Ciudad', - state: import.meta.env.VITE_BUSINESS_STATE || 'Estado', + city: import.meta.env.VITE_BUSINESS_CITY || 'Villahermosa', + state: import.meta.env.VITE_BUSINESS_STATE || 'Tabasco', country: import.meta.env.VITE_BUSINESS_COUNTRY || 'México', phone: import.meta.env.VITE_BUSINESS_PHONE || 'Tel: (52) 0000-0000' };