feat: agregar manejo de números de serie en movimientos y actualización de PDF

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-12 15:47:48 -06:00
parent 898643cdab
commit 156c915403
4 changed files with 193 additions and 32 deletions

View File

@ -78,7 +78,25 @@ const handleClose = () => {
};
const handleEdit = () => {
emit('edit', movement.value);
// Si es un movimiento múltiple, usar el valor actual
if (isMultiProduct.value) {
emit('edit', movement.value);
return;
}
// Para movimientos individuales, hacer fetch para obtener datos completos (incluidos seriales)
loading.value = true;
api.get(apiURL(`movimientos/${movement.value.id}`), {
onSuccess: (data) => {
emit('edit', data.movement || data);
},
onError: () => {
window.Notify.error('Error al cargar el movimiento');
},
onFinish: () => {
loading.value = false;
}
});
};
const handleEditProduct = (product) => {

View File

@ -32,9 +32,13 @@ const form = useForm({
origin_warehouse_id: '',
destination_warehouse_id: '',
invoice_reference: '',
notes: ''
notes: '',
serial_numbers: [] // Array de números de serie
});
/** Estado para manejo de seriales */
const serialsText = ref(''); // Texto editable (uno por línea)
/** Computed */
const movementTypeInfo = computed(() => {
const types = {
@ -71,6 +75,43 @@ const allowsDecimals = computed(() => {
return props.movement?.inventory?.unit_of_measure?.allows_decimals || false;
});
const hasSerials = computed(() => {
return props.movement?.inventory?.track_serials || false;
});
const serialsArray = computed(() => {
return serialsText.value
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0);
});
const serialsValidation = computed(() => {
if (!hasSerials.value) return { valid: true, message: '' };
const quantity = Number(form.quantity);
const serialCount = serialsArray.value.length;
if (quantity > 0 && serialCount === 0) {
return { valid: false, message: 'Debe ingresar números de serie' };
}
if (serialCount !== quantity) {
return {
valid: false,
message: `Debe ingresar ${quantity} número(s) de serie (actual: ${serialCount})`
};
}
// Verificar duplicados
const uniqueSerials = new Set(serialsArray.value);
if (uniqueSerials.size !== serialCount) {
return { valid: false, message: 'Hay números de serie duplicados' };
}
return { valid: true, message: '' };
});
/** Métodos */
const loadWarehouses = () => {
loading.value = true;
@ -91,12 +132,28 @@ const loadWarehouses = () => {
};
const updateMovement = () => {
// Validar seriales si el producto los requiere
if (hasSerials.value && !serialsValidation.value.valid) {
window.Notify.warning(serialsValidation.value.message);
return;
}
// Preparar datos según el tipo de movimiento
const data = {
quantity: Number(form.quantity), // Común para todos los tipos
notes: form.notes || null
};
// Siempre enviar serial_numbers si el producto requiere seriales
if (hasSerials.value) {
// Validar que haya seriales
if (serialsArray.value.length === 0) {
window.Notify.warning('Debe ingresar números de serie para este producto');
return;
}
data.serial_numbers = serialsArray.value;
}
// Campos específicos por tipo
if (props.movement.movement_type === 'entry') {
data.unit_cost = Number(form.unit_cost);
@ -110,9 +167,19 @@ const updateMovement = () => {
data.warehouse_to_id = form.destination_warehouse_id;
}
console.log('📤 Datos a enviar al backend:', {
movement_id: props.movement.id,
data: data,
serial_numbers_count: data.serial_numbers?.length || 0,
hasSerials: hasSerials.value
});
api.put(apiURL(`movimientos/${props.movement.id}`), {
data,
onSuccess: () => {
onSuccess: (response) => {
console.log('✅ Respuesta del backend (completa):', JSON.stringify(response, null, 2));
console.log('✅ Tipo de respuesta:', typeof response);
console.log('✅ Keys:', Object.keys(response || {}));
window.Notify.success('Movimiento actualizado correctamente');
emit('updated');
closeModal();
@ -144,6 +211,21 @@ watch(() => props.show, (isShown) => {
form.invoice_reference = props.movement.invoice_reference || '';
form.notes = props.movement.notes || '';
// Cargar números de serie si existen
let serialNumbers = [];
if (props.movement.serial_numbers && props.movement.serial_numbers.length > 0) {
serialNumbers = props.movement.serial_numbers;
} else if (props.movement.serials && props.movement.serials.length > 0) {
serialNumbers = props.movement.serials.map(s => s.serial_number);
}
if (serialNumbers.length > 0) {
serialsText.value = serialNumbers.join('\n');
} else {
serialsText.value = '';
}
// Almacenes según tipo
if (props.movement.movement_type === 'entry') {
form.destination_warehouse_id = props.movement.warehouse_id || '';
@ -153,7 +235,19 @@ watch(() => props.show, (isShown) => {
form.origin_warehouse_id = props.movement.origin_warehouse_id || '';
form.destination_warehouse_id = props.movement.destination_warehouse_id || '';
}
console.log('🔍 Debug Movement:', {
movement: props.movement,
has_inventory: !!props.movement.inventory,
track_serials: props.movement?.inventory?.track_serials,
serials_relation: props.movement.serials,
hasSerials_computed: hasSerials.value,
serialNumbers_loaded: serialNumbers,
serialsText_final: serialsText.value
});
}
} else {
// Limpiar seriales al cerrar
serialsText.value = '';
}
}, { immediate: true });
</script>
@ -194,6 +288,13 @@ watch(() => props.show, (isShown) => {
<div class="flex items-center gap-2 mb-1">
<GoogleIcon name="inventory_2" class="text-gray-500 dark:text-gray-400 text-sm" />
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Producto</p>
<span
v-if="hasSerials"
class="ml-auto px-2 py-0.5 text-xs font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full flex items-center gap-1"
>
<GoogleIcon name="qr_code_scanner" class="text-xs" />
Con seriales
</span>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ movement?.inventory?.name || 'N/A' }}
@ -222,6 +323,48 @@ watch(() => props.show, (isShown) => {
<FormError :message="form.errors?.quantity" />
</div>
<!-- Números de Serie (solo si el producto los tiene) -->
<div v-if="hasSerials" class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="qr_code_scanner" class="text-amber-600 dark:text-amber-400" />
<label class="text-xs font-semibold text-amber-700 dark:text-amber-300 uppercase">
NÚMEROS DE SERIE
</label>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400 mb-2">
Ingresa un número de serie por línea. Debe coincidir con la cantidad ({{ form.quantity }}).
</p>
<textarea
v-model="serialsText"
rows="5"
placeholder="SN001&#10;SN002&#10;SN003"
class="w-full px-3 py-2 border border-amber-300 dark:border-amber-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm font-mono focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none"
:class="{
'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500': !serialsValidation.valid && serialsText.length > 0
}"
></textarea>
<!-- Contador y validación -->
<div class="mt-2 flex items-center justify-between">
<p class="text-xs text-amber-600 dark:text-amber-400">
<span class="font-semibold">{{ serialsArray.length }}</span> de {{ form.quantity }} seriales
</p>
<p
v-if="!serialsValidation.valid && serialsText.length > 0"
class="text-xs text-red-600 dark:text-red-400 font-medium"
>
{{ serialsValidation.message }}
</p>
<p
v-else-if="serialsValidation.valid && serialsArray.length > 0"
class="text-xs text-green-600 dark:text-green-400 font-medium flex items-center gap-1"
>
<GoogleIcon name="check_circle" class="text-sm" />
Válido
</p>
</div>
</div>
<!-- Costo unitario (solo para entradas) -->
<div v-if="movement?.movement_type === 'entry'">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">

View File

@ -275,6 +275,35 @@ const TicketDetailMovement = {
} else {
yPosition += 1;
}
// Números de serie (si existen)
const serialNumbers = movementData.serial_numbers ||
(movementData.serials ? movementData.serials.map(s => s.serial_number) : []);
if (serialNumbers && serialNumbers.length > 0) {
yPosition += 2;
doc.setLineWidth(0.2);
doc.setDrawColor(...darkGrayColor);
doc.line(leftMargin, yPosition, rightMargin, yPosition);
yPosition += 5;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...[0, 0, 0]); // amber
doc.text('NÚMEROS DE SERIE:', leftMargin, yPosition);
yPosition += 4;
doc.setFontSize(8);
doc.setFont('courier', 'normal');
doc.setTextColor(...blackColor);
serialNumbers.forEach((serial, index) => {
doc.text(`${index + 1}. ${serial}`, leftMargin + 2, yPosition);
yPosition += 3.5;
});
yPosition += 2;
}
}
// Notas (si existen)

View File

@ -1,5 +1,4 @@
import { apiURL, token } from '@Services/Api';
import { page } from '@Services/Page';
import axios from 'axios';
/**
@ -38,34 +37,6 @@ const whatsappService = {
}
},
/**
* Enviar mensaje genérico por WhatsApp
* @param {Object} data - Datos del mensaje
* @returns {Promise}
*/
async sendMessage({ phone_number, message }) {
try {
const { data } = await axios.post(
apiURL('whatsapp/send-message'),
{
phone_number,
message
},
{
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${token.value}`
}
}
);
return data;
} catch (error) {
const errorData = error.response?.data?.data || error.response?.data;
console.error('WhatsApp Error Detail:', errorData);
throw errorData;
}
}
};
export default whatsappService;