feat: agregar manejo de números de serie en movimientos y actualización de PDF
This commit is contained in:
parent
898643cdab
commit
156c915403
@ -78,7 +78,25 @@ const handleClose = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = () => {
|
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) => {
|
const handleEditProduct = (product) => {
|
||||||
|
|||||||
@ -32,9 +32,13 @@ const form = useForm({
|
|||||||
origin_warehouse_id: '',
|
origin_warehouse_id: '',
|
||||||
destination_warehouse_id: '',
|
destination_warehouse_id: '',
|
||||||
invoice_reference: '',
|
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 */
|
/** Computed */
|
||||||
const movementTypeInfo = computed(() => {
|
const movementTypeInfo = computed(() => {
|
||||||
const types = {
|
const types = {
|
||||||
@ -71,6 +75,43 @@ const allowsDecimals = computed(() => {
|
|||||||
return props.movement?.inventory?.unit_of_measure?.allows_decimals || false;
|
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 */
|
/** Métodos */
|
||||||
const loadWarehouses = () => {
|
const loadWarehouses = () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -91,12 +132,28 @@ const loadWarehouses = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateMovement = () => {
|
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
|
// Preparar datos según el tipo de movimiento
|
||||||
const data = {
|
const data = {
|
||||||
quantity: Number(form.quantity), // Común para todos los tipos
|
quantity: Number(form.quantity), // Común para todos los tipos
|
||||||
notes: form.notes || null
|
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
|
// Campos específicos por tipo
|
||||||
if (props.movement.movement_type === 'entry') {
|
if (props.movement.movement_type === 'entry') {
|
||||||
data.unit_cost = Number(form.unit_cost);
|
data.unit_cost = Number(form.unit_cost);
|
||||||
@ -110,9 +167,19 @@ const updateMovement = () => {
|
|||||||
data.warehouse_to_id = form.destination_warehouse_id;
|
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}`), {
|
api.put(apiURL(`movimientos/${props.movement.id}`), {
|
||||||
data,
|
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');
|
window.Notify.success('Movimiento actualizado correctamente');
|
||||||
emit('updated');
|
emit('updated');
|
||||||
closeModal();
|
closeModal();
|
||||||
@ -144,6 +211,21 @@ watch(() => props.show, (isShown) => {
|
|||||||
form.invoice_reference = props.movement.invoice_reference || '';
|
form.invoice_reference = props.movement.invoice_reference || '';
|
||||||
form.notes = props.movement.notes || '';
|
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
|
// Almacenes según tipo
|
||||||
if (props.movement.movement_type === 'entry') {
|
if (props.movement.movement_type === 'entry') {
|
||||||
form.destination_warehouse_id = props.movement.warehouse_id || '';
|
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.origin_warehouse_id = props.movement.origin_warehouse_id || '';
|
||||||
form.destination_warehouse_id = props.movement.destination_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 });
|
}, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
@ -194,6 +288,13 @@ watch(() => props.show, (isShown) => {
|
|||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<GoogleIcon name="inventory_2" class="text-gray-500 dark:text-gray-400 text-sm" />
|
<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>
|
<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>
|
</div>
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ movement?.inventory?.name || 'N/A' }}
|
{{ movement?.inventory?.name || 'N/A' }}
|
||||||
@ -222,6 +323,48 @@ watch(() => props.show, (isShown) => {
|
|||||||
<FormError :message="form.errors?.quantity" />
|
<FormError :message="form.errors?.quantity" />
|
||||||
</div>
|
</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 SN002 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) -->
|
<!-- Costo unitario (solo para entradas) -->
|
||||||
<div v-if="movement?.movement_type === 'entry'">
|
<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">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
|||||||
@ -275,6 +275,35 @@ const TicketDetailMovement = {
|
|||||||
} else {
|
} else {
|
||||||
yPosition += 1;
|
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)
|
// Notas (si existen)
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { apiURL, token } from '@Services/Api';
|
import { apiURL, token } from '@Services/Api';
|
||||||
import { page } from '@Services/Page';
|
|
||||||
import axios from 'axios';
|
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;
|
export default whatsappService;
|
||||||
Loading…
x
Reference in New Issue
Block a user