mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
Compras: requisiciones de materiales y pago a proveedores
- Requisiciones (ROL_REQUISICION): el trabajador pide materiales sin precios para un proyecto activo con contrato firmado, adjunta la cotización del proveedor (PDF/JPG/PNG/HEIC, 10 MB) y solo ve las suyas (el servidor filtra por el correo del token), sin montos, cliente, OC ni pagos. - SOLO_REQUISICION: cuentas que solo ven la pestaña Requisiciones; doPost les niega lo demás. - Estados: Solicitada → Autorizada (genera la OC) → Pagada → Cerrada; Rechazada, Devuelta (corregir y reenviar con el mismo folio) y Cancelada. Historial visible para el solicitante. - Toda OC sale de una requisición autorizada: se quitan la emisión directa y el botón "Recibida". La OC guarda req_folio y su PDF lleva "Requisición: REQ-…". Cancelar la OC regresa la requisición a Solicitada. - Pago a proveedor (PGP-AAAA-NNNN, hoja PagosProveedores en el libro de Compras): comprobante obligatorio, monto = total de la OC ±1.00, tipo de cambio si es otra moneda. OC y requisición a Pagada; cancelar el pago las regresa (no si ya está cerrada). Una OC pagada no se cancela. - Cierre: "Recibí el material" (solicitante) o compras con motivo; la OC queda Recibida. - Pruebas: tests/requisiciones.test.js nueva; compras, interfaz, cobranza y sesión actualizadas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf140d93c8
commit
fdb273793f
849
Code.js
849
Code.js
@ -105,12 +105,24 @@ const API_FUNCIONES = {
|
|||||||
desbloquearContrato: desbloquearContrato,
|
desbloquearContrato: desbloquearContrato,
|
||||||
registrarPago: registrarPago,
|
registrarPago: registrarPago,
|
||||||
cancelarPago: cancelarPago,
|
cancelarPago: cancelarPago,
|
||||||
// Compras (solo ROL_COMPRAS; cada función lo revisa)
|
// Compras (solo ROL_COMPRAS; cada función lo revisa). Las órdenes salen de autorizarRequisicion.
|
||||||
listarProyectosCompras: listarProyectosCompras,
|
listarProyectosCompras: listarProyectosCompras,
|
||||||
getComprasProyecto: getComprasProyecto,
|
getComprasProyecto: getComprasProyecto,
|
||||||
guardarOrdenCompra: guardarOrdenCompra,
|
|
||||||
marcarOcRecibida: marcarOcRecibida,
|
|
||||||
cancelarOrdenCompra: cancelarOrdenCompra,
|
cancelarOrdenCompra: cancelarOrdenCompra,
|
||||||
|
listarRequisiciones: listarRequisiciones,
|
||||||
|
getRequisicion: getRequisicion,
|
||||||
|
autorizarRequisicion: autorizarRequisicion,
|
||||||
|
rechazarRequisicion: rechazarRequisicion,
|
||||||
|
devolverRequisicion: devolverRequisicion,
|
||||||
|
registrarPagoProveedor: registrarPagoProveedor,
|
||||||
|
cancelarPagoProveedor: cancelarPagoProveedor,
|
||||||
|
cerrarRequisicionCompras: cerrarRequisicionCompras,
|
||||||
|
// Requisiciones del solicitante (ROL_REQUISICION o SOLO_REQUISICION; solo las suyas)
|
||||||
|
listarProyectosRequisicion: listarProyectosRequisicion,
|
||||||
|
misRequisiciones: misRequisiciones,
|
||||||
|
guardarRequisicion: guardarRequisicion,
|
||||||
|
cancelarRequisicion: cancelarRequisicion,
|
||||||
|
cerrarRequisicion: cerrarRequisicion,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -135,6 +147,10 @@ function doPost(e) {
|
|||||||
if (!Object.prototype.hasOwnProperty.call(API_FUNCIONES, nombre)) {
|
if (!Object.prototype.hasOwnProperty.call(API_FUNCIONES, nombre)) {
|
||||||
throw new Error('Función no permitida: ' + (nombre || '(vacía)') + '.');
|
throw new Error('Función no permitida: ' + (nombre || '(vacía)') + '.');
|
||||||
}
|
}
|
||||||
|
// Cuentas de SOLO_REQUISICION: solo su pestaña (nada de cotizaciones, historial, catálogo, clientes ni proyectos)
|
||||||
|
if (FUNCIONES_SOLO_REQUISICION.indexOf(nombre) === -1 && esSoloRequisicion_()) {
|
||||||
|
throw errorPermiso_('Tu cuenta solo tiene acceso a Requisiciones.');
|
||||||
|
}
|
||||||
const args = Array.isArray(cuerpo.args) ? cuerpo.args : [];
|
const args = Array.isArray(cuerpo.args) ? cuerpo.args : [];
|
||||||
respuesta = { ok: true, datos: API_FUNCIONES[nombre].apply(null, args) };
|
respuesta = { ok: true, datos: API_FUNCIONES[nombre].apply(null, args) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -642,6 +658,15 @@ function guardarProducto(p) {
|
|||||||
function getDatosIniciales() {
|
function getDatosIniciales() {
|
||||||
const cfg = getConfig_();
|
const cfg = getConfig_();
|
||||||
const hojaCat = hojaCatalogo_();
|
const hojaCat = hojaCatalogo_();
|
||||||
|
if (esSoloRequisicion_()) {
|
||||||
|
// Solo la pestaña Requisiciones: catálogo sin precios (para sugerir descripción y unidad), sin clientes ni proyectos
|
||||||
|
return {
|
||||||
|
soloRequisicion: true, ivaTasa: cfg.IVA_TASA, marcas: MARCAS,
|
||||||
|
catalogo: getCatalogo_(hojaCat).map(p => ({ clave: p.clave, descripcion: p.descripcion, unidad: p.unidad })),
|
||||||
|
tipoCambio: null, clientes: [], proyectos: [], usuario: usuarioActual_(), vendedor: VENDEDOR_,
|
||||||
|
permisos: { cobranza: false, desbloqueo: false, compras: false, requisicion: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ivaTasa: cfg.IVA_TASA,
|
ivaTasa: cfg.IVA_TASA,
|
||||||
vigenciaDias: cfg.VIGENCIA_DIAS,
|
vigenciaDias: cfg.VIGENCIA_DIAS,
|
||||||
@ -653,7 +678,7 @@ function getDatosIniciales() {
|
|||||||
usuario: usuarioActual_(),
|
usuario: usuarioActual_(),
|
||||||
vendedor: VENDEDOR_,
|
vendedor: VENDEDOR_,
|
||||||
// Solo para mostrar u ocultar las pestañas Cobranza y Compras; cada función lo vuelve a revisar
|
// Solo para mostrar u ocultar las pestañas Cobranza y Compras; cada función lo vuelve a revisar
|
||||||
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_(), compras: esCompras_() },
|
permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_(), compras: esCompras_(), requisicion: esRequisicion_() },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1639,13 +1664,14 @@ function prepararCobranza() {
|
|||||||
|
|
||||||
// ====== 4.3 COMPRAS (Fase 3): órdenes de compra por proyecto ======
|
// ====== 4.3 COMPRAS (Fase 3): órdenes de compra por proyecto ======
|
||||||
// Libro aparte "Cotizador Helgen — Compras (datos)" dentro de la carpeta "Cotizador Helgen — Compras" (fuera de la carpeta
|
// Libro aparte "Cotizador Helgen — Compras (datos)" dentro de la carpeta "Cotizador Helgen — Compras" (fuera de la carpeta
|
||||||
// maestra y de la de cobranza). Solo el rol de compras (ROL_COMPRAS) crea, ve, recibe y cancela órdenes; el servidor lo
|
// maestra y de la de cobranza). Solo el rol de compras (ROL_COMPRAS) ve, paga y cancela órdenes; el servidor lo revisa en
|
||||||
// revisa en cada llamada. PDF en Compras/AAAA/<folio del proyecto>/. El margen (contratado − órdenes) solo lo ve cobranza.
|
// cada llamada. Toda orden sale de una requisición autorizada (4.4). PDF en Compras/AAAA/<folio del proyecto>/.
|
||||||
|
// El margen (contratado − órdenes) solo lo ve cobranza.
|
||||||
|
|
||||||
const NOMBRE_CARPETA_COMPRAS = 'Cotizador Helgen — Compras';
|
const NOMBRE_CARPETA_COMPRAS = 'Cotizador Helgen — Compras';
|
||||||
const NOMBRE_LIBRO_COMPRAS = 'Cotizador Helgen — Compras (datos)';
|
const NOMBRE_LIBRO_COMPRAS = 'Cotizador Helgen — Compras (datos)';
|
||||||
const PREFIJO_OC = 'OC';
|
const PREFIJO_OC = 'OC';
|
||||||
const OC_EMITIDA = 'Emitida', OC_RECIBIDA = 'Recibida', OC_CANCELADA = 'Cancelada';
|
const OC_EMITIDA = 'Emitida', OC_PAGADA = 'Pagada', OC_RECIBIDA = 'Recibida', OC_CANCELADA = 'Cancelada';
|
||||||
|
|
||||||
/** Estructura del libro de compras (mismo formato que HOJAS_COBRANZA). */
|
/** Estructura del libro de compras (mismo formato que HOJAS_COBRANZA). */
|
||||||
const HOJAS_COMPRAS = {
|
const HOJAS_COMPRAS = {
|
||||||
@ -1653,10 +1679,26 @@ const HOJAS_COMPRAS = {
|
|||||||
enc: ['folio', 'fecha', 'proyecto_folio', 'marca', 'proveedor_id', 'proveedor', 'rfc', 'contacto', 'correo', 'telefono',
|
enc: ['folio', 'fecha', 'proyecto_folio', 'marca', 'proveedor_id', 'proveedor', 'rfc', 'contacto', 'correo', 'telefono',
|
||||||
'fecha_entrega', 'lugar_entrega', 'condiciones_pago', 'moneda', 'iva_tasa', 'subtotal', 'iva', 'total', 'moneda_proyecto',
|
'fecha_entrega', 'lugar_entrega', 'condiciones_pago', 'moneda', 'iva_tasa', 'subtotal', 'iva', 'total', 'moneda_proyecto',
|
||||||
'tipo_cambio', 'subtotal_proyecto', 'renglones (JSON)', 'notas', 'pdf_id', 'pdf_url', 'elaboro_email', 'elaboro_nombre',
|
'tipo_cambio', 'subtotal_proyecto', 'renglones (JSON)', 'notas', 'pdf_id', 'pdf_url', 'elaboro_email', 'elaboro_nombre',
|
||||||
'fecha_registro', 'estado', 'fecha_recepcion', 'recibio', 'cancelado_por', 'fecha_cancelacion', 'motivo_cancelacion'],
|
'fecha_registro', 'estado', 'fecha_recepcion', 'recibio', 'cancelado_por', 'fecha_cancelacion', 'motivo_cancelacion',
|
||||||
|
'req_folio'],
|
||||||
numeros: [15, 16, 17, 18, 20, 21],
|
numeros: [15, 16, 17, 18, 20, 21],
|
||||||
},
|
},
|
||||||
Proveedores: { enc: ['id', 'razon_social', 'rfc', 'contacto', 'correo', 'telefono', 'actualizado'], numeros: [] },
|
Proveedores: { enc: ['id', 'razon_social', 'rfc', 'contacto', 'correo', 'telefono', 'actualizado'], numeros: [] },
|
||||||
|
// Requisiciones (4.4): sin costos; los renglones se versionan (al reenviar una devuelta se agrega la versión nueva)
|
||||||
|
Requisiciones: {
|
||||||
|
enc: ['folio', 'fecha', 'proyecto_folio', 'solicitante_email', 'solicitante_nombre', 'estado', 'motivo',
|
||||||
|
'cotizacion_pdf_id', 'cotizacion_pdf_url', 'oc_folio', 'reviso_email', 'fecha_revision', 'actualizado', 'notas_recepcion'],
|
||||||
|
numeros: [],
|
||||||
|
},
|
||||||
|
RequisicionesRenglones: { enc: ['req_folio', 'version', 'renglon', 'descripcion', 'cantidad', 'unidad', 'notas'], numeros: [2, 3, 5] },
|
||||||
|
// Historial que ve el solicitante (estado, quién y cuándo); sin montos
|
||||||
|
RequisicionesEventos: { enc: ['req_folio', 'fecha', 'estado', 'correo', 'nombre', 'nota'], numeros: [] },
|
||||||
|
PagosProveedores: {
|
||||||
|
enc: ['folio', 'fecha_pago', 'fecha_registro', 'oc_folio', 'req_folio', 'proyecto_folio', 'monto', 'moneda_pago',
|
||||||
|
'tipo_cambio', 'monto_aplicado', 'moneda_oc', 'metodo', 'referencia', 'comprobante_id', 'comprobante_url',
|
||||||
|
'registro_email', 'registro_nombre', 'estado', 'cancelado_por', 'fecha_cancelacion', 'motivo_cancelacion'],
|
||||||
|
numeros: [7, 9, 10],
|
||||||
|
},
|
||||||
Bitacora: { enc: ['fecha', 'correo', 'accion', 'proyecto_folio', 'detalle', 'motivo'], numeros: [] },
|
Bitacora: { enc: ['fecha', 'correo', 'accion', 'proyecto_folio', 'detalle', 'motivo'], numeros: [] },
|
||||||
};
|
};
|
||||||
const COL_ESTADO_OC = 29; // AC: estado, recepción (AD:AE) y cancelación (AF:AH)
|
const COL_ESTADO_OC = 29; // AC: estado, recepción (AD:AE) y cancelación (AF:AH)
|
||||||
@ -1692,7 +1734,7 @@ function ordenDesdeFila_(r, fila) {
|
|||||||
tipoCambio: r[19] === '' ? '' : Number(r[19]), subtotalProyecto: Number(r[20]) || 0, renglones: renglones,
|
tipoCambio: r[19] === '' ? '' : Number(r[19]), subtotalProyecto: Number(r[20]) || 0, renglones: renglones,
|
||||||
notas: String(r[22] || ''), pdfUrl: String(r[24] || ''), elaboro: String(r[26] || r[25] || ''),
|
notas: String(r[22] || ''), pdfUrl: String(r[24] || ''), elaboro: String(r[26] || r[25] || ''),
|
||||||
estado: String(r[28] || OC_EMITIDA), fechaRecepcion: fechaIso_(r[29]), recibio: String(r[30] || ''),
|
estado: String(r[28] || OC_EMITIDA), fechaRecepcion: fechaIso_(r[29]), recibio: String(r[30] || ''),
|
||||||
canceladoPor: String(r[31] || ''), motivoCancelacion: String(r[33] || ''), fila: fila,
|
canceladoPor: String(r[31] || ''), motivoCancelacion: String(r[33] || ''), reqFolio: String(r[34] || ''), fila: fila,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1738,7 +1780,7 @@ function proyectoParaCompras_(folio) {
|
|||||||
return proyecto;
|
return proyecto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Respuesta para la pestaña Compras: proyecto, moneda, órdenes y proveedores. Sin montos del contrato ni margen. */
|
/** Respuesta para "Órdenes por proyecto" en la pestaña Compras: proyecto, moneda y órdenes. Sin montos del contrato ni margen. */
|
||||||
function vistaCompras_(libro, proyecto) {
|
function vistaCompras_(libro, proyecto) {
|
||||||
const c = contratosFirmados_()[proyecto.folio.toUpperCase()] || null;
|
const c = contratosFirmados_()[proyecto.folio.toUpperCase()] || null;
|
||||||
return {
|
return {
|
||||||
@ -1746,15 +1788,12 @@ function vistaCompras_(libro, proyecto) {
|
|||||||
ubicacion: proyecto.ubicacion },
|
ubicacion: proyecto.ubicacion },
|
||||||
moneda: c ? c.moneda : '',
|
moneda: c ? c.moneda : '',
|
||||||
marca: c ? c.marca : '',
|
marca: c ? c.marca : '',
|
||||||
puedeEmitir: !!c && proyecto.estado === 'Activo',
|
|
||||||
ordenes: ordenesDeProyecto_(libro, proyecto.folio).map(o => { const x = Object.assign({}, o); delete x.fila; return x; }).reverse(),
|
ordenes: ordenesDeProyecto_(libro, proyecto.folio).map(o => { const x = Object.assign({}, o); delete x.fila; return x; }).reverse(),
|
||||||
proveedores: proveedoresDelLibro_(libro).map(p => { const x = Object.assign({}, p); delete x.fila; return x; }),
|
|
||||||
ivaTasas: IVA_OC,
|
|
||||||
hoy: hoyIso_(),
|
hoy: hoyIso_(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Proyectos donde se pueden emitir órdenes: Activos y con contrato firmado (con su moneda; sin montos). */
|
/** Proyectos Activos con contrato firmado (con su moneda; sin montos), para ver sus órdenes. */
|
||||||
function listarProyectosCompras(busqueda) {
|
function listarProyectosCompras(busqueda) {
|
||||||
exigirCompras_();
|
exigirCompras_();
|
||||||
const firmados = contratosFirmados_();
|
const firmados = contratosFirmados_();
|
||||||
@ -1775,69 +1814,55 @@ function getComprasProyecto(folio) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emite una orden de compra con su PDF. p = {proyectoFolio, proveedor: {razonSocial, rfc, contacto, correo, telefono},
|
* Emite una orden de compra con su PDF (solo desde autorizarRequisicion, dentro del candado). d0 = {proveedor:
|
||||||
* fechaEntrega, lugarEntrega, condicionesPago, moneda, tipoCambio, ivaTasa, renglones: [{clave, descripcion, unidad,
|
* {razonSocial, rfc, contacto, correo, telefono}, fechaEntrega, lugarEntrega, condicionesPago, moneda, tipoCambio, ivaTasa,
|
||||||
* cantidad, costo}], notas, idSolicitud}. Solo en proyectos Activos con contrato firmado. La línea de negocio sale del
|
* renglones: [{clave, descripcion, unidad, cantidad, costo}], notas}. Solo en proyectos Activos con contrato firmado. La línea
|
||||||
* contrato (nunca del navegador); el costo es el capturado (nunca el precio de venta del catálogo).
|
* de negocio sale del contrato (nunca del navegador); el costo es el capturado (nunca el precio de venta del catálogo).
|
||||||
*/
|
*/
|
||||||
function guardarOrdenCompra(p) {
|
function emitirOrdenCompra_(libro, d0, proyecto, reqFolio) {
|
||||||
exigirCompras_();
|
|
||||||
const d0 = p || {};
|
|
||||||
const cfg = getConfig_();
|
const cfg = getConfig_();
|
||||||
const libro = libroCompras_();
|
if (proyecto.estado !== 'Activo') {
|
||||||
|
throw new Error('El proyecto ' + proyecto.folio + ' está ' + proyecto.estado.toLowerCase() + ': no admite órdenes de compra.');
|
||||||
|
}
|
||||||
|
const c = contratosFirmados_()[proyecto.folio.toUpperCase()];
|
||||||
|
if (!c) throw new Error('Solo se emiten órdenes de compra en proyectos con contrato firmado.');
|
||||||
|
const oc = normalizarOrdenCompra(d0, { moneda: c.moneda, ubicacion: proyecto.ubicacion, hoy: hoyIso_(), ivaTasa: cfg.IVA_TASA });
|
||||||
|
|
||||||
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
|
const hoja = hojaCompras_(libro, 'OrdenesCompra');
|
||||||
const cache = CacheService.getScriptCache();
|
const folio = siguienteFolio_(PREFIJO_OC, hoja, 'CONSECUTIVO_OC_');
|
||||||
const claveCache = idSolicitud ? 'OC_' + idSolicitud : '';
|
const proveedor = guardarProveedor_(libro, oc.proveedor);
|
||||||
|
const ahora = new Date();
|
||||||
|
const elaboro = [VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ') || VENDEDOR_.email;
|
||||||
|
|
||||||
return conCandado_(() => {
|
// PDF (razón social y datos fiscales de Helgen, iguales en Helgen y Helgen Energy)
|
||||||
if (claveCache) {
|
const html = construirHtmlOrdenCompra(Object.assign({}, oc, {
|
||||||
const previo = cache.get(claveCache);
|
folio: folio, fecha: Utilities.formatDate(ahora, 'America/Mexico_City', 'dd/MM/yyyy'), proveedor: proveedor,
|
||||||
if (previo) return JSON.parse(previo);
|
proyectoFolio: proyecto.folio, reqFolio: reqFolio,
|
||||||
}
|
elaboro: elaboro + (elaboro === VENDEDOR_.email ? '' : ' (' + VENDEDOR_.email + ')'),
|
||||||
const proyecto = proyectoParaCompras_(d0.proyectoFolio);
|
}), cfg, logoDataUrl_(cfg.LOGO_FILE_ID));
|
||||||
if (proyecto.estado !== 'Activo') {
|
const pdf = Utilities.newBlob(html, 'text/html', folio + '.html').getAs('application/pdf')
|
||||||
throw new Error('El proyecto ' + proyecto.folio + ' está ' + proyecto.estado.toLowerCase() + ': no admite órdenes de compra.');
|
.setName(nombreArchivoOc(folio, proveedor.razonSocial));
|
||||||
}
|
const archivo = privado_(carpetaProyectoCompras_(proyecto.folio).createFile(pdf));
|
||||||
const c = contratosFirmados_()[proyecto.folio.toUpperCase()];
|
|
||||||
if (!c) throw new Error('Solo se emiten órdenes de compra en proyectos con contrato firmado.');
|
|
||||||
const oc = normalizarOrdenCompra(d0, { moneda: c.moneda, ubicacion: proyecto.ubicacion, hoy: hoyIso_(), ivaTasa: cfg.IVA_TASA });
|
|
||||||
|
|
||||||
const hoja = hojaCompras_(libro, 'OrdenesCompra');
|
const t = textoCelda;
|
||||||
const folio = siguienteFolio_(PREFIJO_OC, hoja, 'CONSECUTIVO_OC_');
|
hoja.appendRow([
|
||||||
const proveedor = guardarProveedor_(libro, oc.proveedor);
|
folio, textoFecha_(hoyIso_()), proyecto.folio, MARCAS[c.marca], t(proveedor.id), t(proveedor.razonSocial), t(proveedor.rfc),
|
||||||
const ahora = new Date();
|
t(proveedor.contacto), t(proveedor.correo), t(proveedor.telefono), textoFecha_(oc.fechaEntrega), t(oc.lugarEntrega),
|
||||||
const elaboro = [VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ') || VENDEDOR_.email;
|
t(oc.condicionesPago), oc.moneda, oc.ivaTasa, oc.subtotal, oc.iva, oc.total, oc.monedaProyecto, oc.tipoCambio,
|
||||||
|
oc.subtotalProyecto, JSON.stringify(oc.renglones), t(oc.notas), archivo.getId(), archivo.getUrl(), t(VENDEDOR_.email),
|
||||||
|
t(elaboro), Utilities.formatDate(ahora, 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), OC_EMITIDA, '', '', '', '', '',
|
||||||
|
reqFolio || '',
|
||||||
|
]);
|
||||||
|
bitacora_(libro, 'Emitir orden de compra', proyecto.folio, folio + (reqFolio ? ' · ' + reqFolio : '') + ' · ' +
|
||||||
|
proveedor.razonSocial + ' · ' + formatoMoneda(oc.total) + ' ' + oc.moneda + (oc.tipoCambio ? ' · TC ' + oc.tipoCambio + ' = ' +
|
||||||
|
formatoMoneda(oc.subtotalProyecto) + ' ' + oc.monedaProyecto + ' sin IVA' : '') + ' · ' + MARCAS[c.marca], '', HOJAS_COMPRAS);
|
||||||
|
return { folio: folio, url: archivo.getUrl(), total: oc.total, moneda: oc.moneda };
|
||||||
|
}
|
||||||
|
|
||||||
// PDF (razón social y datos fiscales de Helgen, iguales en Helgen y Helgen Energy)
|
/** Compras/AAAA/<folio del proyecto>/ (restringidas de arriba abajo: un permiso heredado no se puede quitar en el hijo). */
|
||||||
const html = construirHtmlOrdenCompra(Object.assign({}, oc, {
|
function carpetaProyectoCompras_(proyectoFolio) {
|
||||||
folio: folio, fecha: Utilities.formatDate(ahora, 'America/Mexico_City', 'dd/MM/yyyy'), proveedor: proveedor,
|
const anio = String(proyectoFolio).split('-')[1] || hoyIso_().slice(0, 4);
|
||||||
proyectoFolio: proyecto.folio, elaboro: elaboro + (elaboro === VENDEDOR_.email ? '' : ' (' + VENDEDOR_.email + ')'),
|
return privado_(subcarpeta_(privado_(subcarpeta_(carpetaCompras_(), anio)), proyectoFolio));
|
||||||
}), cfg, logoDataUrl_(cfg.LOGO_FILE_ID));
|
|
||||||
const pdf = Utilities.newBlob(html, 'text/html', folio + '.html').getAs('application/pdf')
|
|
||||||
.setName(nombreArchivoOc(folio, proveedor.razonSocial));
|
|
||||||
const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4);
|
|
||||||
// De arriba abajo: un permiso heredado de la carpeta no se puede quitar en el hijo
|
|
||||||
const carpeta = privado_(subcarpeta_(privado_(subcarpeta_(carpetaCompras_(), anio)), proyecto.folio));
|
|
||||||
const archivo = privado_(carpeta.createFile(pdf));
|
|
||||||
|
|
||||||
const t = textoCelda;
|
|
||||||
hoja.appendRow([
|
|
||||||
folio, textoFecha_(hoyIso_()), proyecto.folio, MARCAS[c.marca], t(proveedor.id), t(proveedor.razonSocial), t(proveedor.rfc),
|
|
||||||
t(proveedor.contacto), t(proveedor.correo), t(proveedor.telefono), textoFecha_(oc.fechaEntrega), t(oc.lugarEntrega),
|
|
||||||
t(oc.condicionesPago), oc.moneda, oc.ivaTasa, oc.subtotal, oc.iva, oc.total, oc.monedaProyecto, oc.tipoCambio,
|
|
||||||
oc.subtotalProyecto, JSON.stringify(oc.renglones), t(oc.notas), archivo.getId(), archivo.getUrl(), t(VENDEDOR_.email),
|
|
||||||
t(elaboro), Utilities.formatDate(ahora, 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), OC_EMITIDA, '', '', '', '', '',
|
|
||||||
]);
|
|
||||||
bitacora_(libro, 'Emitir orden de compra', proyecto.folio, folio + ' · ' + proveedor.razonSocial + ' · ' +
|
|
||||||
formatoMoneda(oc.total) + ' ' + oc.moneda + (oc.tipoCambio ? ' · TC ' + oc.tipoCambio + ' = ' +
|
|
||||||
formatoMoneda(oc.subtotalProyecto) + ' ' + oc.monedaProyecto + ' sin IVA' : '') + ' · ' + MARCAS[c.marca], '', HOJAS_COMPRAS);
|
|
||||||
SpreadsheetApp.flush();
|
|
||||||
|
|
||||||
const resultado = { folio: folio, url: archivo.getUrl(), total: oc.total, moneda: oc.moneda, vista: vistaCompras_(libro, proyecto) };
|
|
||||||
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
|
||||||
return resultado;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Busca una orden por folio en el libro de compras (o error). */
|
/** Busca una orden por folio en el libro de compras (o error). */
|
||||||
@ -1852,55 +1877,589 @@ function buscarOrden_(libro, folioOc) {
|
|||||||
return { hoja: hoja, orden: orden };
|
return { hoja: hoja, orden: orden };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marca una orden Emitida como Recibida (completa), con la fecha de recepción (AAAA-MM-DD, no futura). */
|
/**
|
||||||
function marcarOcRecibida(folioOc, fecha) {
|
* Cancela una orden Emitida (no se borra), con motivo; queda en la Bitacora y deja de contar en el margen. Una pagada o
|
||||||
exigirCompras_();
|
* recibida ya no se cancela. Su requisición regresa a Solicitada (sin orden) para autorizarla de nuevo.
|
||||||
const libro = libroCompras_();
|
*/
|
||||||
return conCandado_(() => {
|
|
||||||
const { hoja, orden } = buscarOrden_(libro, folioOc);
|
|
||||||
if (orden.estado !== OC_EMITIDA) throw new Error('La orden ' + orden.folio + ' ya está ' + orden.estado.toLowerCase() + '.');
|
|
||||||
const recepcion = validarFechaIso(fecha, hoyIso_(), 'La fecha de recepción');
|
|
||||||
if (orden.fecha && recepcion < orden.fecha) throw new Error('La fecha de recepción no puede ser anterior a la orden (' + orden.fecha + ').');
|
|
||||||
hoja.getRange(orden.fila, COL_ESTADO_OC, 1, 3).setValues([[OC_RECIBIDA, textoFecha_(recepcion), textoCelda(VENDEDOR_.email)]]);
|
|
||||||
bitacora_(libro, 'Recibir orden de compra', orden.proyectoFolio, orden.folio + ' · recibida el ' + recepcion, '', HOJAS_COMPRAS);
|
|
||||||
SpreadsheetApp.flush();
|
|
||||||
return vistaCompras_(libro, proyectoParaCompras_(orden.proyectoFolio));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancela una orden Emitida (no se borra), con motivo; queda en la Bitacora y deja de contar en el margen. */
|
|
||||||
function cancelarOrdenCompra(folioOc, motivo) {
|
function cancelarOrdenCompra(folioOc, motivo) {
|
||||||
exigirCompras_();
|
exigirCompras_();
|
||||||
const m = String(motivo || '').trim();
|
const m = motivoObligatorio_(motivo, 'Escribe el motivo de la cancelación.');
|
||||||
if (m.length < 5) throw new Error('Escribe el motivo de la cancelación.');
|
|
||||||
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
|
|
||||||
const libro = libroCompras_();
|
const libro = libroCompras_();
|
||||||
return conCandado_(() => {
|
return conCandado_(() => {
|
||||||
const { hoja, orden } = buscarOrden_(libro, folioOc);
|
const { hoja, orden } = buscarOrden_(libro, folioOc);
|
||||||
if (orden.estado === OC_CANCELADA) throw new Error('La orden ' + orden.folio + ' ya está cancelada.');
|
if (orden.estado === OC_CANCELADA) throw new Error('La orden ' + orden.folio + ' ya está cancelada.');
|
||||||
|
if (orden.estado === OC_PAGADA) throw new Error('La orden ' + orden.folio + ' ya está pagada: no se puede cancelar.');
|
||||||
if (orden.estado !== OC_EMITIDA) throw new Error('La orden ' + orden.folio + ' ya se recibió: no se puede cancelar.');
|
if (orden.estado !== OC_EMITIDA) throw new Error('La orden ' + orden.folio + ' ya se recibió: no se puede cancelar.');
|
||||||
hoja.getRange(orden.fila, COL_ESTADO_OC).setValue(OC_CANCELADA);
|
hoja.getRange(orden.fila, COL_ESTADO_OC).setValue(OC_CANCELADA);
|
||||||
hoja.getRange(orden.fila, COL_ESTADO_OC + 3, 1, 3).setValues([[textoCelda(VENDEDOR_.email),
|
hoja.getRange(orden.fila, COL_ESTADO_OC + 3, 1, 3).setValues([[textoCelda(VENDEDOR_.email),
|
||||||
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), textoCelda(m)]]);
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), textoCelda(m)]]);
|
||||||
bitacora_(libro, 'Cancelar orden de compra', orden.proyectoFolio, orden.folio + ' · ' + orden.proveedor + ' · ' +
|
bitacora_(libro, 'Cancelar orden de compra', orden.proyectoFolio, orden.folio + ' · ' + orden.proveedor + ' · ' +
|
||||||
formatoMoneda(orden.total) + ' ' + orden.moneda, m, HOJAS_COMPRAS);
|
formatoMoneda(orden.total) + ' ' + orden.moneda, m, HOJAS_COMPRAS);
|
||||||
|
if (orden.reqFolio) {
|
||||||
|
const { hoja: hR, req } = buscarReq_(libro, orden.reqFolio);
|
||||||
|
if (req.estado === REQ_AUTORIZADA && req.ocFolio.toUpperCase() === orden.folio.toUpperCase()) {
|
||||||
|
cambiarReq_(hR, req, transicionReq(req.estado, 'cancelarOc'), { ocFolio: '' });
|
||||||
|
eventoReq_(libro, req.folio, REQ_SOLICITADA, 'Se canceló la orden de compra; queda pendiente de autorizar de nuevo.');
|
||||||
|
bitacora_(libro, 'Requisición a Solicitada', req.proyectoFolio, req.folio + ' · se canceló ' + orden.folio, m, HOJAS_COMPRAS);
|
||||||
|
}
|
||||||
|
}
|
||||||
SpreadsheetApp.flush();
|
SpreadsheetApp.flush();
|
||||||
return vistaCompras_(libro, proyectoParaCompras_(orden.proyectoFolio));
|
return vistaCompras_(libro, proyectoParaCompras_(orden.proyectoFolio));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Motivo obligatorio (5 a 500 caracteres). */
|
||||||
|
function motivoObligatorio_(motivo, mensaje) {
|
||||||
|
const m = String(motivo || '').trim();
|
||||||
|
if (m.length < 5) throw new Error(mensaje || 'Escribe el motivo.');
|
||||||
|
if (m.length > 500) throw new Error('El motivo debe tener máximo 500 caracteres.');
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 4.4 REQUISICIONES DE MATERIALES Y PAGO A PROVEEDORES ======
|
||||||
|
// Un trabajador (ROL_REQUISICION) pide materiales para un proyecto, sin precios, con la cotización del proveedor adjunta.
|
||||||
|
// Compras (ROL_COMPRAS) la autoriza (genera la orden de compra), la rechaza o la devuelve; paga la orden (PGP) y el
|
||||||
|
// solicitante confirma que recibió el material (cierra la requisición y la orden queda Recibida). Todo en el libro de
|
||||||
|
// compras; archivos en Compras/AAAA/<folio del proyecto>/Requisiciones/. El solicitante solo ve SUS requisiciones (el
|
||||||
|
// servidor filtra por el correo del token) y nunca montos, cliente, margen, pagos ni órdenes.
|
||||||
|
// SOLO_REQUISICION: cuentas que solo usan esta pestaña (el servidor les niega cotizar, historial, catálogo y proyectos).
|
||||||
|
|
||||||
|
const PREFIJO_REQ = 'REQ', PREFIJO_PGP = 'PGP';
|
||||||
|
const SUBCARPETA_REQUISICIONES = 'Requisiciones';
|
||||||
|
|
||||||
|
/** Funciones que puede llamar una cuenta de SOLO_REQUISICION (todo lo demás se le niega en doPost). */
|
||||||
|
const FUNCIONES_SOLO_REQUISICION = ['getDatosIniciales', 'listarProyectosRequisicion', 'misRequisiciones',
|
||||||
|
'guardarRequisicion', 'cancelarRequisicion', 'cerrarRequisicion'];
|
||||||
|
|
||||||
|
/** ¿Solo puede usar requisiciones? (SOLO_REQUISICION; manda sobre cualquier otro rol). */
|
||||||
|
function esSoloRequisicion_() {
|
||||||
|
return enRol_('SOLO_REQUISICION');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ¿Puede pedir materiales? (ROL_REQUISICION, o SOLO_REQUISICION, que lo incluye). */
|
||||||
|
function esRequisicion_() {
|
||||||
|
return enRol_('ROL_REQUISICION') || esSoloRequisicion_();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exigirRequisicion_() {
|
||||||
|
if (!VENDEDOR_) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.');
|
||||||
|
if (!esRequisicion_()) throw errorPermiso_('No tienes permiso para hacer requisiciones.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hojaReq_(libro) { return hojaCompras_(libro, 'Requisiciones'); }
|
||||||
|
|
||||||
|
function reqDesdeFila_(r, fila) {
|
||||||
|
return {
|
||||||
|
folio: String(r[0]), fecha: fechaIso_(r[1]), proyectoFolio: String(r[2]), solicitanteEmail: String(r[3] || '').toLowerCase(),
|
||||||
|
solicitante: String(r[4] || r[3] || ''), estado: String(r[5] || REQ_SOLICITADA), motivo: String(r[6] || ''),
|
||||||
|
cotizacionId: String(r[7] || ''), cotizacionUrl: String(r[8] || ''), ocFolio: String(r[9] || ''),
|
||||||
|
reviso: String(r[10] || ''), fechaRevision: fechaTexto_(r[11]), actualizado: fechaTexto_(r[12]),
|
||||||
|
notasRecepcion: String(r[13] || ''), fila: fila,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requisición por folio (o error). */
|
||||||
|
function buscarReq_(libro, folioReq) {
|
||||||
|
const folio = String(folioReq || '').trim().toUpperCase();
|
||||||
|
const hoja = hojaReq_(libro);
|
||||||
|
let req = null;
|
||||||
|
filasDeArea_(hoja, HOJAS_COMPRAS, 'Requisiciones').forEach((r, i) => {
|
||||||
|
if (String(r[0]).toUpperCase() === folio) req = reqDesdeFila_(r, i + 2);
|
||||||
|
});
|
||||||
|
if (!req) throw new Error('No existe la requisición ' + folio + '.');
|
||||||
|
return { hoja: hoja, req: req };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cambia estado (y otros campos) de una requisición en su fila. extra = {motivo, ocFolio, revisar (bool), notasRecepcion}. */
|
||||||
|
function cambiarReq_(hoja, req, estado, extra) {
|
||||||
|
const x = extra || {};
|
||||||
|
const ahora = Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss');
|
||||||
|
const t = textoCelda;
|
||||||
|
hoja.getRange(req.fila, 6, 1, 2).setValues([[estado, t('motivo' in x ? x.motivo : req.motivo)]]);
|
||||||
|
if ('ocFolio' in x) hoja.getRange(req.fila, 10).setValue(x.ocFolio);
|
||||||
|
if (x.revisar) hoja.getRange(req.fila, 11, 1, 2).setValues([[t(VENDEDOR_.email), ahora]]);
|
||||||
|
hoja.getRange(req.fila, 13).setValue(ahora);
|
||||||
|
if ('notasRecepcion' in x) hoja.getRange(req.fila, 14).setValue(t(x.notasRecepcion));
|
||||||
|
req.estado = estado;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renglón del historial que ve el solicitante (sin montos). */
|
||||||
|
function eventoReq_(libro, folio, estado, nota) {
|
||||||
|
const t = textoCelda;
|
||||||
|
hojaCompras_(libro, 'RequisicionesEventos').appendRow([folio,
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), estado, t(VENDEDOR_.email),
|
||||||
|
t([VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ') || VENDEDOR_.email), t(nota || '')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {folio: {version, renglones}} con la versión vigente (la mayor) de los renglones de cada requisición. */
|
||||||
|
function renglonesDeReqs_(libro) {
|
||||||
|
const mapa = {};
|
||||||
|
filasDeArea_(hojaCompras_(libro, 'RequisicionesRenglones'), HOJAS_COMPRAS, 'RequisicionesRenglones').forEach(r => {
|
||||||
|
const f = String(r[0]).toUpperCase();
|
||||||
|
if (!f) return;
|
||||||
|
const v = Number(r[1]) || 1;
|
||||||
|
const x = mapa[f] || (mapa[f] = { version: 0, renglones: [] });
|
||||||
|
if (v > x.version) { x.version = v; x.renglones = []; }
|
||||||
|
if (v === x.version) {
|
||||||
|
x.renglones.push({ renglon: Number(r[2]) || x.renglones.length + 1, descripcion: String(r[3] || ''), cantidad: Number(r[4]) || 0,
|
||||||
|
unidad: String(r[5] || ''), notas: String(r[6] || '') });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(mapa).forEach(f => mapa[f].renglones.sort((a, b) => a.renglon - b.renglon));
|
||||||
|
return mapa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {folio: [eventos en orden]} del historial de requisiciones. */
|
||||||
|
function eventosDeReqs_(libro) {
|
||||||
|
const mapa = {};
|
||||||
|
filasDeArea_(hojaCompras_(libro, 'RequisicionesEventos'), HOJAS_COMPRAS, 'RequisicionesEventos').forEach(r => {
|
||||||
|
const f = String(r[0]).toUpperCase();
|
||||||
|
if (!f) return;
|
||||||
|
(mapa[f] = mapa[f] || []).push({ fecha: fechaTexto_(r[1]), estado: String(r[2] || ''), correo: String(r[3] || ''),
|
||||||
|
nombre: String(r[4] || r[3] || ''), nota: String(r[5] || '') });
|
||||||
|
});
|
||||||
|
return mapa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escribe los renglones de una versión (las versiones anteriores se quedan: nada se borra). */
|
||||||
|
function escribirRenglonesReq_(libro, folio, version, renglones) {
|
||||||
|
const hoja = hojaCompras_(libro, 'RequisicionesRenglones');
|
||||||
|
const t = textoCelda;
|
||||||
|
renglones.forEach((r, i) => hoja.appendRow([folio, version, i + 1, t(r.descripcion), r.cantidad, t(r.unidad), t(r.notas)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {folio: nombre} de los proyectos (para mostrar el nombre sin el cliente). */
|
||||||
|
function nombresDeProyectos_() {
|
||||||
|
const hoja = hojaProyectos_();
|
||||||
|
const filas = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : [];
|
||||||
|
const mapa = {};
|
||||||
|
proyectosDesdeFilas(filas).forEach(p => { mapa[p.folio.toUpperCase()] = p; });
|
||||||
|
return mapa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes de un archivo adjunto ({nombre, datos base64}); null si no viene. */
|
||||||
|
function bytesDeAdjunto_(adjunto, mensaje) {
|
||||||
|
try {
|
||||||
|
return adjunto && adjunto.datos ? Utilities.base64Decode(String(adjunto.datos)) : null;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(mensaje || 'No se pudo leer el archivo. Vuelve a adjuntarlo.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compras/AAAA/<folio del proyecto>/Requisiciones/ (restringida). */
|
||||||
|
function carpetaRequisiciones_(proyectoFolio) {
|
||||||
|
return privado_(subcarpeta_(carpetaProyectoCompras_(proyectoFolio), SUBCARPETA_REQUISICIONES));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lo que ve el solicitante de una requisición: estado, renglones e historial. Sin montos, cliente, orden ni pagos. */
|
||||||
|
function reqParaSolicitante_(req, ren, eventos, proyectos) {
|
||||||
|
const p = proyectos[req.proyectoFolio.toUpperCase()];
|
||||||
|
return {
|
||||||
|
folio: req.folio, fecha: req.fecha, proyectoFolio: req.proyectoFolio, proyectoNombre: p ? p.nombre : '',
|
||||||
|
estado: req.estado, motivo: req.motivo, notasRecepcion: req.notasRecepcion,
|
||||||
|
renglones: (ren && ren.renglones) || [], eventos: eventos || [],
|
||||||
|
puedeEditar: req.estado === REQ_DEVUELTA, puedeCancelar: req.estado === REQ_SOLICITADA || req.estado === REQ_DEVUELTA,
|
||||||
|
puedeCerrar: req.estado === REQ_PAGADA,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proyectos donde se pueden pedir materiales: Activos con contrato firmado. Solo folio y nombre (sin cliente ni montos). */
|
||||||
|
function listarProyectosRequisicion(busqueda) {
|
||||||
|
exigirRequisicion_();
|
||||||
|
const firmados = contratosFirmados_();
|
||||||
|
const q = textoNormal(busqueda);
|
||||||
|
const proyectos = nombresDeProyectos_();
|
||||||
|
return Object.keys(proyectos).map(f => proyectos[f]).reverse()
|
||||||
|
.filter(p => p.estado === 'Activo' && firmados[p.folio.toUpperCase()])
|
||||||
|
.filter(p => !q || textoNormal(p.folio + ' ' + p.nombre).includes(q))
|
||||||
|
.slice(0, 50)
|
||||||
|
.map(p => ({ folio: p.folio, nombre: p.nombre }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requisiciones del usuario del token (nunca las de otros), más recientes primero. */
|
||||||
|
function misRequisiciones() {
|
||||||
|
exigirRequisicion_();
|
||||||
|
const libro = libroCompras_();
|
||||||
|
const yo = String(VENDEDOR_.email).toLowerCase();
|
||||||
|
const ren = renglonesDeReqs_(libro), ev = eventosDeReqs_(libro), proyectos = nombresDeProyectos_();
|
||||||
|
const lista = [];
|
||||||
|
filasDeArea_(hojaReq_(libro), HOJAS_COMPRAS, 'Requisiciones').forEach((r, i) => {
|
||||||
|
const req = reqDesdeFila_(r, i + 2);
|
||||||
|
if (req.folio && req.solicitanteEmail === yo) {
|
||||||
|
const f = req.folio.toUpperCase();
|
||||||
|
lista.push(reqParaSolicitante_(req, ren[f], ev[f], proyectos));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lista.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Correr desde el editor: crea la carpeta "Cotizador Helgen — Compras" (en Mi unidad) y su libro, y deja los dos en
|
* Nueva requisición o reenvío de una devuelta. p = {folio? (reenvío), proyectoFolio, renglones: [{descripcion, cantidad,
|
||||||
* "Restringido" (también si ya existían). No comparte nada: a quien deba abrir los PDF se le comparte la carpeta en Drive.
|
* unidad, notas}], cotizacion: {nombre, datos (base64)}, idSolicitud}. Nueva: cotización obligatoria; proyecto Activo con
|
||||||
|
* contrato firmado. Reenvío: solo el solicitante y solo si está Devuelta; mismo folio y proyecto; la cotización es opcional
|
||||||
|
* (si viene, se agrega otro archivo: el anterior no se borra). Queda en Solicitada.
|
||||||
|
*/
|
||||||
|
function guardarRequisicion(p) {
|
||||||
|
exigirRequisicion_();
|
||||||
|
const d0 = p || {};
|
||||||
|
const renglones = normalizarRequisicion(d0);
|
||||||
|
const bytes = bytesDeAdjunto_(d0.cotizacion, 'No se pudo leer la cotización. Vuelve a adjuntarla.');
|
||||||
|
const reenvio = !!String(d0.folio || '').trim();
|
||||||
|
if (!reenvio && !bytes) throw new Error('Adjunta la cotización del proveedor (PDF o imagen).');
|
||||||
|
const tipo = bytes ? validarArchivo(bytes, 'la cotización del proveedor', 'La cotización') : null;
|
||||||
|
const libro = libroCompras_();
|
||||||
|
|
||||||
|
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
|
||||||
|
const cache = CacheService.getScriptCache();
|
||||||
|
const claveCache = idSolicitud ? 'REQ_' + idSolicitud : '';
|
||||||
|
|
||||||
|
return conCandado_(() => {
|
||||||
|
if (claveCache) {
|
||||||
|
const previo = cache.get(claveCache);
|
||||||
|
if (previo) return JSON.parse(previo);
|
||||||
|
}
|
||||||
|
const yo = String(VENDEDOR_.email).toLowerCase();
|
||||||
|
const t = textoCelda;
|
||||||
|
let folio, version;
|
||||||
|
if (reenvio) {
|
||||||
|
const { hoja, req } = buscarReq_(libro, d0.folio);
|
||||||
|
if (req.solicitanteEmail !== yo) throw errorPermiso_('Solo quien hizo la requisición ' + req.folio + ' puede corregirla.');
|
||||||
|
const nuevo = transicionReq(req.estado, 'reenviar');
|
||||||
|
folio = req.folio;
|
||||||
|
version = ((renglonesDeReqs_(libro)[folio.toUpperCase()] || {}).version || 1) + 1;
|
||||||
|
if (bytes) {
|
||||||
|
const archivo = privado_(carpetaRequisiciones_(req.proyectoFolio).createFile(Utilities.newBlob(bytes, tipo.mime,
|
||||||
|
nombreArchivoReq(folio, 'Cotización', tipo.ext, version))));
|
||||||
|
hoja.getRange(req.fila, 8, 1, 2).setValues([[archivo.getId(), archivo.getUrl()]]);
|
||||||
|
}
|
||||||
|
escribirRenglonesReq_(libro, folio, version, renglones);
|
||||||
|
cambiarReq_(hoja, req, nuevo, { motivo: '' });
|
||||||
|
eventoReq_(libro, folio, nuevo, 'Corregida y reenviada' + (bytes ? ' con otra cotización.' : '.'));
|
||||||
|
bitacora_(libro, 'Reenviar requisición', req.proyectoFolio, folio + ' · versión ' + version + ' · ' + renglones.length +
|
||||||
|
' renglón(es)' + (bytes ? ' · cotización nueva' : ''), '', HOJAS_COMPRAS);
|
||||||
|
} else {
|
||||||
|
const proyecto = proyectoParaCompras_(d0.proyectoFolio);
|
||||||
|
if (proyecto.estado !== 'Activo' || !contratosFirmados_()[proyecto.folio.toUpperCase()]) {
|
||||||
|
throw new Error('Solo se piden materiales para proyectos activos con contrato firmado.');
|
||||||
|
}
|
||||||
|
const hoja = hojaReq_(libro);
|
||||||
|
folio = siguienteFolio_(PREFIJO_REQ, hoja, 'CONSECUTIVO_REQ_');
|
||||||
|
version = 1;
|
||||||
|
const archivo = privado_(carpetaRequisiciones_(proyecto.folio).createFile(Utilities.newBlob(bytes, tipo.mime,
|
||||||
|
nombreArchivoReq(folio, 'Cotización', tipo.ext, 1))));
|
||||||
|
const ahora = Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss');
|
||||||
|
hoja.appendRow([folio, textoFecha_(hoyIso_()), proyecto.folio, t(yo),
|
||||||
|
t([VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ') || yo), REQ_SOLICITADA, '', archivo.getId(),
|
||||||
|
archivo.getUrl(), '', '', '', ahora, '']);
|
||||||
|
escribirRenglonesReq_(libro, folio, 1, renglones);
|
||||||
|
eventoReq_(libro, folio, REQ_SOLICITADA, '');
|
||||||
|
bitacora_(libro, 'Solicitar requisición', proyecto.folio, folio + ' · ' + renglones.length + ' renglón(es)', '', HOJAS_COMPRAS);
|
||||||
|
}
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
const resultado = { folio: folio, requisiciones: misRequisiciones() };
|
||||||
|
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||||
|
return resultado;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** El solicitante cancela su requisición (Solicitada o Devuelta), con motivo. No se borra. */
|
||||||
|
function cancelarRequisicion(folioReq, motivo) {
|
||||||
|
exigirRequisicion_();
|
||||||
|
const m = motivoObligatorio_(motivo, 'Escribe el motivo de la cancelación.');
|
||||||
|
const libro = libroCompras_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const { hoja, req } = buscarReq_(libro, folioReq);
|
||||||
|
if (req.solicitanteEmail !== String(VENDEDOR_.email).toLowerCase()) {
|
||||||
|
throw errorPermiso_('Solo quien hizo la requisición ' + req.folio + ' puede cancelarla.');
|
||||||
|
}
|
||||||
|
cambiarReq_(hoja, req, transicionReq(req.estado, 'cancelar'), { motivo: m });
|
||||||
|
eventoReq_(libro, req.folio, REQ_CANCELADA, m);
|
||||||
|
bitacora_(libro, 'Cancelar requisición', req.proyectoFolio, req.folio, m, HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return misRequisiciones();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cierre: la requisición pasa a Cerrada y su orden a Recibida (hoy, con el correo de quien cierra). */
|
||||||
|
function cerrarReq_(libro, hoja, req, nota, motivo) {
|
||||||
|
const nuevo = transicionReq(req.estado, 'cerrar');
|
||||||
|
const { hoja: hO, orden } = buscarOrden_(libro, req.ocFolio);
|
||||||
|
if (orden.estado !== OC_PAGADA) throw new Error('La orden ' + orden.folio + ' no está pagada.');
|
||||||
|
cambiarReq_(hoja, req, nuevo, { notasRecepcion: nota, motivo: motivo || req.motivo });
|
||||||
|
hO.getRange(orden.fila, COL_ESTADO_OC, 1, 3).setValues([[OC_RECIBIDA, textoFecha_(hoyIso_()), textoCelda(VENDEDOR_.email)]]);
|
||||||
|
eventoReq_(libro, req.folio, REQ_CERRADA, [motivo ? 'Cerrada por compras: ' + motivo : 'Material recibido', nota].filter(Boolean).join(' · '));
|
||||||
|
bitacora_(libro, 'Cerrar requisición', req.proyectoFolio, req.folio + ' · ' + orden.folio + ' recibida el ' + hoyIso_() +
|
||||||
|
(nota ? ' · ' + nota : ''), motivo || '', HOJAS_COMPRAS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Recibí el material": solo el solicitante y solo si está Pagada. notas opcionales (p. ej. "faltó 1 pieza"). */
|
||||||
|
function cerrarRequisicion(folioReq, notas) {
|
||||||
|
exigirRequisicion_();
|
||||||
|
const nota = notaOpcional_(notas);
|
||||||
|
const libro = libroCompras_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const { hoja, req } = buscarReq_(libro, folioReq);
|
||||||
|
if (req.solicitanteEmail !== String(VENDEDOR_.email).toLowerCase()) {
|
||||||
|
throw errorPermiso_('Solo quien hizo la requisición ' + req.folio + ' puede confirmar que recibió el material.');
|
||||||
|
}
|
||||||
|
cerrarReq_(libro, hoja, req, nota, '');
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return misRequisiciones();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notaOpcional_(notas) {
|
||||||
|
const n = String(notas || '').trim();
|
||||||
|
if (n.length > 500) throw new Error('Las notas deben tener máximo 500 caracteres.');
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Requisiciones: lado de compras ----
|
||||||
|
|
||||||
|
/** Todas las requisiciones (compras), más recientes primero. Filtro opcional por estado y por folio/proyecto/solicitante. */
|
||||||
|
function listarRequisiciones(busqueda, estado) {
|
||||||
|
exigirCompras_();
|
||||||
|
const libro = libroCompras_();
|
||||||
|
const ren = renglonesDeReqs_(libro), proyectos = nombresDeProyectos_();
|
||||||
|
const q = textoNormal(busqueda), e = String(estado || '').trim();
|
||||||
|
const lista = [];
|
||||||
|
filasDeArea_(hojaReq_(libro), HOJAS_COMPRAS, 'Requisiciones').forEach((r, i) => {
|
||||||
|
const req = reqDesdeFila_(r, i + 2);
|
||||||
|
if (!req.folio) return;
|
||||||
|
const p = proyectos[req.proyectoFolio.toUpperCase()];
|
||||||
|
const x = { folio: req.folio, fecha: req.fecha, proyectoFolio: req.proyectoFolio, proyectoNombre: p ? p.nombre : '',
|
||||||
|
solicitante: req.solicitante, solicitanteEmail: req.solicitanteEmail, estado: req.estado, ocFolio: req.ocFolio,
|
||||||
|
actualizado: req.actualizado, renglones: ((ren[req.folio.toUpperCase()] || {}).renglones || []).length };
|
||||||
|
if (e && x.estado !== e) return;
|
||||||
|
if (q && !textoNormal([x.folio, x.proyectoFolio, x.proyectoNombre, x.solicitante, x.solicitanteEmail, x.ocFolio].join(' ')).includes(q)) return;
|
||||||
|
lista.push(x);
|
||||||
|
});
|
||||||
|
return lista.reverse().slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagoProvDesdeFila_(r, fila) {
|
||||||
|
return {
|
||||||
|
folio: String(r[0]), fecha: fechaIso_(r[1]), fechaRegistro: fechaTexto_(r[2]), ocFolio: String(r[3]), reqFolio: String(r[4] || ''),
|
||||||
|
proyectoFolio: String(r[5] || ''), monto: Number(r[6]) || 0, monedaPago: String(r[7] || ''),
|
||||||
|
tipoCambio: r[8] === '' ? '' : Number(r[8]), montoAplicado: Number(r[9]) || 0, monedaOc: String(r[10] || ''),
|
||||||
|
metodo: String(r[11] || ''), referencia: String(r[12] || ''), comprobanteId: String(r[13] || ''),
|
||||||
|
comprobanteUrl: String(r[14] || ''), registro: String(r[16] || r[15] || ''), estado: String(r[17] || 'Vigente'),
|
||||||
|
canceladoPor: String(r[18] || ''), fechaCancelacion: fechaTexto_(r[19]), motivoCancelacion: String(r[20] || ''), fila: fila,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pagos de una orden (en orden de registro). */
|
||||||
|
function pagosDeOrden_(libro, folioOc) {
|
||||||
|
const f = String(folioOc || '').toUpperCase();
|
||||||
|
const lista = [];
|
||||||
|
if (!f) return lista;
|
||||||
|
filasDeArea_(hojaCompras_(libro, 'PagosProveedores'), HOJAS_COMPRAS, 'PagosProveedores').forEach((r, i) => {
|
||||||
|
if (String(r[3]).toUpperCase() === f) lista.push(pagoProvDesdeFila_(r, i + 2));
|
||||||
|
});
|
||||||
|
return lista;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vista de una requisición para compras: renglones, cotización, historial, proyecto (moneda del contrato), orden y pagos. */
|
||||||
|
function vistaRequisicion_(libro, folioReq) {
|
||||||
|
const { req } = buscarReq_(libro, folioReq);
|
||||||
|
const f = req.folio.toUpperCase();
|
||||||
|
const proyecto = buscarProyecto_(hojaProyectos_(), req.proyectoFolio);
|
||||||
|
const c = contratosFirmados_()[req.proyectoFolio.toUpperCase()] || null;
|
||||||
|
let orden = null;
|
||||||
|
if (req.ocFolio) { orden = buscarOrden_(libro, req.ocFolio).orden; delete orden.fila; }
|
||||||
|
const pagos = req.ocFolio ? pagosDeOrden_(libro, req.ocFolio).map(x => { delete x.fila; return x; }) : [];
|
||||||
|
const proyectoActivo = !!proyecto && proyecto.estado === 'Activo' && !!c;
|
||||||
|
const r = Object.assign({}, req); delete r.fila;
|
||||||
|
return {
|
||||||
|
requisicion: r,
|
||||||
|
renglones: (renglonesDeReqs_(libro)[f] || {}).renglones || [],
|
||||||
|
eventos: eventosDeReqs_(libro)[f] || [],
|
||||||
|
proyecto: proyecto ? { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado,
|
||||||
|
ubicacion: proyecto.ubicacion } : { folio: req.proyectoFolio, nombre: '', cliente: '', estado: '', ubicacion: '' },
|
||||||
|
moneda: c ? c.moneda : '', marca: c ? c.marca : '',
|
||||||
|
orden: orden, pagos: pagos,
|
||||||
|
proveedores: proveedoresDelLibro_(libro).map(p => { const x = Object.assign({}, p); delete x.fila; return x; }),
|
||||||
|
ivaTasas: IVA_OC, metodos: METODOS_PAGO, hoy: hoyIso_(),
|
||||||
|
puedeAutorizar: req.estado === REQ_SOLICITADA && proyectoActivo,
|
||||||
|
puedeRevisar: req.estado === REQ_SOLICITADA,
|
||||||
|
puedePagar: req.estado === REQ_AUTORIZADA && !!orden && orden.estado === OC_EMITIDA,
|
||||||
|
puedeCancelarPago: req.estado === REQ_PAGADA,
|
||||||
|
puedeCerrar: req.estado === REQ_PAGADA,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequisicion(folioReq) {
|
||||||
|
exigirCompras_();
|
||||||
|
return vistaRequisicion_(libroCompras_(), folioReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autoriza una requisición Solicitada: emite su orden de compra (mismo flujo y PDF, con la línea "Requisición: REQ-…") y la
|
||||||
|
* requisición pasa a Autorizada con su oc_folio. oc = datos de la orden (ver emitirOrdenCompra_) + idSolicitud.
|
||||||
|
*/
|
||||||
|
function autorizarRequisicion(folioReq, oc) {
|
||||||
|
exigirCompras_();
|
||||||
|
const d0 = oc || {};
|
||||||
|
const libro = libroCompras_();
|
||||||
|
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
|
||||||
|
const cache = CacheService.getScriptCache();
|
||||||
|
const claveCache = idSolicitud ? 'OC_' + idSolicitud : '';
|
||||||
|
|
||||||
|
return conCandado_(() => {
|
||||||
|
if (claveCache) {
|
||||||
|
const previo = cache.get(claveCache);
|
||||||
|
if (previo) return JSON.parse(previo);
|
||||||
|
}
|
||||||
|
const { hoja, req } = buscarReq_(libro, folioReq);
|
||||||
|
const nuevo = transicionReq(req.estado, 'autorizar');
|
||||||
|
const proyecto = proyectoParaCompras_(req.proyectoFolio);
|
||||||
|
const r = emitirOrdenCompra_(libro, d0, proyecto, req.folio);
|
||||||
|
cambiarReq_(hoja, req, nuevo, { motivo: '', ocFolio: r.folio, revisar: true });
|
||||||
|
eventoReq_(libro, req.folio, nuevo, '');
|
||||||
|
bitacora_(libro, 'Autorizar requisición', proyecto.folio, req.folio + ' · ' + r.folio, '', HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
const resultado = { folio: req.folio, ocFolio: r.folio, url: r.url, total: r.total, moneda: r.moneda,
|
||||||
|
vista: vistaRequisicion_(libro, req.folio) };
|
||||||
|
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||||
|
return resultado;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rechazar (final) o devolver (el solicitante la corrige) una requisición Solicitada; motivo obligatorio. */
|
||||||
|
function revisarRequisicion_(folioReq, motivo, accion) {
|
||||||
|
exigirCompras_();
|
||||||
|
const m = motivoObligatorio_(motivo, 'Escribe el motivo (lo verá quien hizo la requisición).');
|
||||||
|
const libro = libroCompras_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const { hoja, req } = buscarReq_(libro, folioReq);
|
||||||
|
const nuevo = transicionReq(req.estado, accion);
|
||||||
|
cambiarReq_(hoja, req, nuevo, { motivo: m, revisar: true });
|
||||||
|
eventoReq_(libro, req.folio, nuevo, m);
|
||||||
|
bitacora_(libro, (accion === 'rechazar' ? 'Rechazar' : 'Devolver') + ' requisición', req.proyectoFolio, req.folio, m, HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaRequisicion_(libro, req.folio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function rechazarRequisicion(folioReq, motivo) { return revisarRequisicion_(folioReq, motivo, 'rechazar'); }
|
||||||
|
function devolverRequisicion(folioReq, motivo) { return revisarRequisicion_(folioReq, motivo, 'devolver'); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pago a proveedor (uno por orden): p = {reqFolio o ocFolio, fecha, monto, monedaPago, tipoCambio, metodo, referencia,
|
||||||
|
* comprobante: {nombre, datos}, idSolicitud}. La requisición debe estar Autorizada y su orden Emitida; el monto (convertido a
|
||||||
|
* la moneda de la orden) debe ser el total de la orden ±1.00. La orden y la requisición pasan a Pagada.
|
||||||
|
*/
|
||||||
|
function registrarPagoProveedor(p) {
|
||||||
|
exigirCompras_();
|
||||||
|
const d0 = p || {};
|
||||||
|
const bytes = bytesDeAdjunto_(d0.comprobante, 'No se pudo leer el comprobante. Vuelve a adjuntarlo.');
|
||||||
|
const tipo = validarComprobante(bytes);
|
||||||
|
const libro = libroCompras_();
|
||||||
|
|
||||||
|
const idSolicitud = String(d0.idSolicitud || '').slice(0, 64);
|
||||||
|
const cache = CacheService.getScriptCache();
|
||||||
|
const claveCache = idSolicitud ? 'PGP_' + idSolicitud : '';
|
||||||
|
|
||||||
|
return conCandado_(() => {
|
||||||
|
if (claveCache) {
|
||||||
|
const previo = cache.get(claveCache);
|
||||||
|
if (previo) return JSON.parse(previo);
|
||||||
|
}
|
||||||
|
let reqFolio = String(d0.reqFolio || '').trim();
|
||||||
|
if (!reqFolio && d0.ocFolio) reqFolio = buscarOrden_(libro, d0.ocFolio).orden.reqFolio;
|
||||||
|
if (!reqFolio) throw new Error('Esta orden no tiene requisición: no se puede registrar el pago.');
|
||||||
|
const { hoja, req } = buscarReq_(libro, reqFolio);
|
||||||
|
const nuevo = transicionReq(req.estado, 'pagar');
|
||||||
|
const { hoja: hO, orden } = buscarOrden_(libro, req.ocFolio);
|
||||||
|
if (d0.ocFolio && String(d0.ocFolio).trim().toUpperCase() !== orden.folio.toUpperCase()) {
|
||||||
|
throw new Error('La orden ' + String(d0.ocFolio).toUpperCase() + ' no es la de la requisición ' + req.folio + '.');
|
||||||
|
}
|
||||||
|
if (orden.estado !== OC_EMITIDA) throw new Error('La orden ' + orden.folio + ' está ' + orden.estado.toLowerCase() + ': no se puede pagar.');
|
||||||
|
if (pagosDeOrden_(libro, orden.folio).some(x => x.estado === 'Vigente')) throw new Error('La orden ' + orden.folio + ' ya tiene un pago.');
|
||||||
|
const pago = normalizarPagoProveedor(d0, { moneda: orden.moneda, total: orden.total, fechaOrden: orden.fecha, hoy: hoyIso_() });
|
||||||
|
|
||||||
|
const hP = hojaCompras_(libro, 'PagosProveedores');
|
||||||
|
const folio = siguienteFolio_(PREFIJO_PGP, hP, 'CONSECUTIVO_PGP_');
|
||||||
|
const n = pagosDeOrden_(libro, orden.folio).length + 1;
|
||||||
|
const archivo = privado_(carpetaRequisiciones_(req.proyectoFolio).createFile(Utilities.newBlob(bytes, tipo.mime,
|
||||||
|
nombreArchivoReq(req.folio, 'Comprobante pago', tipo.ext, n))));
|
||||||
|
const t = textoCelda;
|
||||||
|
hP.appendRow([folio, textoFecha_(pago.fecha), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'),
|
||||||
|
orden.folio, req.folio, req.proyectoFolio, pago.monto, pago.monedaPago, pago.tipoCambio, pago.montoAplicado, orden.moneda,
|
||||||
|
pago.metodo, t(pago.referencia), archivo.getId(), archivo.getUrl(), t(VENDEDOR_.email),
|
||||||
|
t([VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ')), 'Vigente', '', '', '']);
|
||||||
|
hO.getRange(orden.fila, COL_ESTADO_OC).setValue(OC_PAGADA);
|
||||||
|
cambiarReq_(hoja, req, nuevo, {});
|
||||||
|
eventoReq_(libro, req.folio, nuevo, '');
|
||||||
|
bitacora_(libro, 'Registrar pago a proveedor', req.proyectoFolio, folio + ' · ' + orden.folio + ' · ' + req.folio + ' · ' +
|
||||||
|
formatoMoneda(pago.monto) + ' ' + pago.monedaPago + (pago.tipoCambio ? ' · TC ' + pago.tipoCambio + ' = ' +
|
||||||
|
formatoMoneda(pago.montoAplicado) + ' ' + orden.moneda : '') + ' · ' + pago.metodo, '', HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
const resultado = { folio: folio, url: archivo.getUrl(), vista: vistaRequisicion_(libro, req.folio) };
|
||||||
|
if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h
|
||||||
|
return resultado;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancela un pago a proveedor (no se borra), con motivo: la orden vuelve a Emitida y la requisición a Autorizada. */
|
||||||
|
function cancelarPagoProveedor(folioPago, motivo) {
|
||||||
|
exigirCompras_();
|
||||||
|
const m = motivoObligatorio_(motivo, 'Escribe el motivo de la cancelación.');
|
||||||
|
const libro = libroCompras_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const folio = String(folioPago || '').trim().toUpperCase();
|
||||||
|
const hP = hojaCompras_(libro, 'PagosProveedores');
|
||||||
|
let pago = null;
|
||||||
|
filasDeArea_(hP, HOJAS_COMPRAS, 'PagosProveedores').forEach((r, i) => {
|
||||||
|
if (String(r[0]).toUpperCase() === folio) pago = pagoProvDesdeFila_(r, i + 2);
|
||||||
|
});
|
||||||
|
if (!pago) throw new Error('No existe el pago ' + folio + '.');
|
||||||
|
if (pago.estado !== 'Vigente') throw new Error('El pago ' + pago.folio + ' ya está cancelado.');
|
||||||
|
const { hoja, req } = buscarReq_(libro, pago.reqFolio);
|
||||||
|
if (req.estado === REQ_CERRADA) throw new Error('La requisición ' + req.folio + ' ya está cerrada (material recibido): el pago no se puede cancelar.');
|
||||||
|
const nuevo = transicionReq(req.estado, 'cancelarPago');
|
||||||
|
const { hoja: hO, orden } = buscarOrden_(libro, pago.ocFolio);
|
||||||
|
hP.getRange(pago.fila, 18, 1, 4).setValues([['Cancelado', textoCelda(VENDEDOR_.email),
|
||||||
|
Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), textoCelda(m)]]);
|
||||||
|
if (orden.estado === OC_PAGADA) hO.getRange(orden.fila, COL_ESTADO_OC).setValue(OC_EMITIDA);
|
||||||
|
cambiarReq_(hoja, req, nuevo, {});
|
||||||
|
eventoReq_(libro, req.folio, nuevo, 'Se canceló el pago.');
|
||||||
|
bitacora_(libro, 'Cancelar pago a proveedor', req.proyectoFolio, pago.folio + ' · ' + orden.folio + ' · ' + req.folio + ' · ' +
|
||||||
|
formatoMoneda(pago.monto) + ' ' + pago.monedaPago, m, HOJAS_COMPRAS);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaRequisicion_(libro, req.folio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compras cierra una requisición Pagada en lugar del solicitante (p. ej. ya no está en la obra), con motivo obligatorio. */
|
||||||
|
function cerrarRequisicionCompras(folioReq, motivo, notas) {
|
||||||
|
exigirCompras_();
|
||||||
|
const m = motivoObligatorio_(motivo, 'Escribe por qué cierras la requisición en lugar de quien la pidió.');
|
||||||
|
const nota = notaOpcional_(notas);
|
||||||
|
const libro = libroCompras_();
|
||||||
|
return conCandado_(() => {
|
||||||
|
const { hoja, req } = buscarReq_(libro, folioReq);
|
||||||
|
cerrarReq_(libro, hoja, req, nota, m);
|
||||||
|
SpreadsheetApp.flush();
|
||||||
|
return vistaRequisicion_(libro, req.folio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correr desde el editor: crea la carpeta "Cotizador Helgen — Compras" (en Mi unidad) y su libro (con todas sus pestañas,
|
||||||
|
* también las de requisiciones), y deja los dos en "Restringido" (también si ya existían). No comparte nada: a quien deba abrir los PDF se le comparte la carpeta en Drive.
|
||||||
*/
|
*/
|
||||||
function prepararCompras() {
|
function prepararCompras() {
|
||||||
const libro = libroCompras_();
|
const libro = libroCompras_();
|
||||||
const carpeta = carpetaCompras_(); // primero la carpeta: el libro hereda su acceso y Drive no deja quitarlo en el hijo
|
const carpeta = carpetaCompras_(); // primero la carpeta: el libro hereda su acceso y Drive no deja quitarlo en el hijo
|
||||||
const archivoLibro = privado_(DriveApp.getFileById(libro.getId()));
|
const archivoLibro = privado_(DriveApp.getFileById(libro.getId()));
|
||||||
|
Object.keys(HOJAS_COMPRAS).forEach(n => hojaCompras_(libro, n)); // pestañas nuevas (requisiciones, pagos) y encabezados
|
||||||
getConfig_(); // agrega TERMINOS_OC a la pestaña Config si falta
|
getConfig_(); // agrega TERMINOS_OC a la pestaña Config si falta
|
||||||
Logger.log('Carpeta de compras: ' + carpeta.getUrl() + ' (restringida)');
|
Logger.log('Carpeta de compras: ' + carpeta.getUrl() + ' (restringida)');
|
||||||
Logger.log('Libro de compras: ' + archivoLibro.getUrl() + ' (restringido)');
|
Logger.log('Libro de compras: ' + archivoLibro.getUrl() + ' (restringido)');
|
||||||
Logger.log('ROL_COMPRAS: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COMPRAS') || '(vacío: nadie tiene acceso)'));
|
const props = PropertiesService.getScriptProperties();
|
||||||
|
Logger.log('ROL_COMPRAS: ' + (props.getProperty('ROL_COMPRAS') || '(vacío: nadie tiene acceso)'));
|
||||||
|
Logger.log('ROL_REQUISICION: ' + (props.getProperty('ROL_REQUISICION') || '(vacío)'));
|
||||||
|
Logger.log('SOLO_REQUISICION: ' + (props.getProperty('SOLO_REQUISICION') || '(vacío)'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ======
|
// ====== 5. LÓGICA PURA (sin servicios de Google; se puede probar fuera) ======
|
||||||
@ -2296,8 +2855,16 @@ function normalizarPago(p, proyecto) {
|
|||||||
* Script vienen con signo (-128…127). Acepta PDF, JPG, PNG y HEIC; máximo MAX_COMPROBANTE. Devuelve {mime, ext}.
|
* Script vienen con signo (-128…127). Acepta PDF, JPG, PNG y HEIC; máximo MAX_COMPROBANTE. Devuelve {mime, ext}.
|
||||||
*/
|
*/
|
||||||
function validarComprobante(bytes) {
|
function validarComprobante(bytes) {
|
||||||
if (!bytes || !bytes.length) throw new Error('Adjunta el comprobante de pago (PDF o imagen).');
|
return validarArchivo(bytes, 'el comprobante de pago', 'El comprobante');
|
||||||
if (bytes.length > MAX_COMPROBANTE) throw new Error('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.');
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Igual que validarComprobante para otro archivo (p. ej. la cotización de una requisición). que = "la cotización del
|
||||||
|
* proveedor" (para "Adjunta …"); sujeto = "La cotización" (para "… pesa más de 10 MB" y "… debe ser PDF o imagen").
|
||||||
|
*/
|
||||||
|
function validarArchivo(bytes, que, sujeto) {
|
||||||
|
if (!bytes || !bytes.length) throw new Error('Adjunta ' + que + ' (PDF o imagen).');
|
||||||
|
if (bytes.length > MAX_COMPROBANTE) throw new Error(sujeto + ' pesa más de 10 MB. Reduce la imagen o el PDF.');
|
||||||
const b = i => (bytes[i] || 0) & 0xFF;
|
const b = i => (bytes[i] || 0) & 0xFF;
|
||||||
const txt = (desde, n) => String.fromCharCode.apply(null, Array.from({ length: n }, (_, i) => b(desde + i)));
|
const txt = (desde, n) => String.fromCharCode.apply(null, Array.from({ length: n }, (_, i) => b(desde + i)));
|
||||||
if (txt(0, 5) === '%PDF-') return { mime: 'application/pdf', ext: 'pdf' };
|
if (txt(0, 5) === '%PDF-') return { mime: 'application/pdf', ext: 'pdf' };
|
||||||
@ -2306,7 +2873,7 @@ function validarComprobante(bytes) {
|
|||||||
if (txt(4, 4) === 'ftyp' && ['heic', 'heix', 'hevc', 'heim', 'heis', 'mif1', 'msf1'].indexOf(txt(8, 4)) !== -1) {
|
if (txt(4, 4) === 'ftyp' && ['heic', 'heix', 'hevc', 'heim', 'heis', 'mif1', 'msf1'].indexOf(txt(8, 4)) !== -1) {
|
||||||
return { mime: 'image/heic', ext: 'heic' };
|
return { mime: 'image/heic', ext: 'heic' };
|
||||||
}
|
}
|
||||||
throw new Error('El comprobante debe ser PDF o imagen (JPG, PNG o HEIC).');
|
throw new Error(sujeto + ' debe ser PDF o imagen (JPG, PNG o HEIC).');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Correos de una propiedad ("a@helgen.tech, b@helgen.tech"), en minúsculas; solo del dominio. */
|
/** Correos de una propiedad ("a@helgen.tech, b@helgen.tech"), en minúsculas; solo del dominio. */
|
||||||
@ -2568,6 +3135,93 @@ function terminosOcParaPdf(cfg) {
|
|||||||
return lineas.length ? lineas : TERMINOS_OC_INICIALES.split('\n');
|
return lineas.length ? lineas : TERMINOS_OC_INICIALES.split('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Requisiciones de materiales y pago a proveedores ----
|
||||||
|
|
||||||
|
const REQ_SOLICITADA = 'Solicitada', REQ_AUTORIZADA = 'Autorizada', REQ_PAGADA = 'Pagada', REQ_CERRADA = 'Cerrada',
|
||||||
|
REQ_RECHAZADA = 'Rechazada', REQ_DEVUELTA = 'Devuelta', REQ_CANCELADA = 'Cancelada';
|
||||||
|
const MAX_RENGLONES_REQ = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambios de estado válidos de una requisición: acción → {de: [estados], a: estado}.
|
||||||
|
* Solicitada → Autorizada (genera la orden) → Pagada → Cerrada; Solicitada → Rechazada (final) | Devuelta → Solicitada;
|
||||||
|
* Solicitada/Devuelta → Cancelada (el solicitante). Cancelar el pago: Pagada → Autorizada; cancelar la orden: Autorizada →
|
||||||
|
* Solicitada.
|
||||||
|
*/
|
||||||
|
const TRANSICIONES_REQ = {
|
||||||
|
autorizar: { de: [REQ_SOLICITADA], a: REQ_AUTORIZADA },
|
||||||
|
rechazar: { de: [REQ_SOLICITADA], a: REQ_RECHAZADA },
|
||||||
|
devolver: { de: [REQ_SOLICITADA], a: REQ_DEVUELTA },
|
||||||
|
reenviar: { de: [REQ_DEVUELTA], a: REQ_SOLICITADA },
|
||||||
|
cancelar: { de: [REQ_SOLICITADA, REQ_DEVUELTA], a: REQ_CANCELADA },
|
||||||
|
pagar: { de: [REQ_AUTORIZADA], a: REQ_PAGADA },
|
||||||
|
cancelarPago: { de: [REQ_PAGADA], a: REQ_AUTORIZADA },
|
||||||
|
cerrar: { de: [REQ_PAGADA], a: REQ_CERRADA },
|
||||||
|
cancelarOc: { de: [REQ_AUTORIZADA], a: REQ_SOLICITADA },
|
||||||
|
};
|
||||||
|
const VERBOS_REQ = { autorizar: 'autorizar', rechazar: 'rechazar', devolver: 'devolver', reenviar: 'reenviar', cancelar: 'cancelar',
|
||||||
|
pagar: 'pagar', cancelarPago: 'cancelar el pago de', cerrar: 'cerrar', cancelarOc: 'regresar a Solicitada' };
|
||||||
|
|
||||||
|
/** Estado nuevo de una requisición tras la acción; error si no se puede desde su estado actual. */
|
||||||
|
function transicionReq(estado, accion) {
|
||||||
|
const t = TRANSICIONES_REQ[accion];
|
||||||
|
if (!t) throw new Error('Acción no válida.');
|
||||||
|
if (t.de.indexOf(estado) === -1) {
|
||||||
|
throw new Error('La requisición está ' + String(estado || '').toLowerCase() + ': no se puede ' + VERBOS_REQ[accion] +
|
||||||
|
' (solo si está ' + t.de.map(e => e.toLowerCase()).join(' o ') + ').');
|
||||||
|
}
|
||||||
|
return t.a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renglones de una requisición (sin costos): [{descripcion, cantidad, unidad, notas}]. Los renglones sin descripción se
|
||||||
|
* ignoran; al menos uno; cantidad mayor a 0; unidad por omisión PZA.
|
||||||
|
*/
|
||||||
|
function normalizarRequisicion(p) {
|
||||||
|
const limpio = s => String(s == null ? '' : s).trim().replace(/\s+/g, ' ');
|
||||||
|
const renglones = ((p && Array.isArray(p.renglones)) ? p.renglones : [])
|
||||||
|
.filter(r => limpio(r && r.descripcion))
|
||||||
|
.map(r => {
|
||||||
|
const descripcion = limpio(r.descripcion);
|
||||||
|
const cantTxt = r.cantidad, cantidad = Number(cantTxt);
|
||||||
|
if (descripcion.length > 500) throw new Error('La descripción "' + descripcion.slice(0, 30) + '…" debe tener máximo 500 caracteres.');
|
||||||
|
if (cantTxt === '' || cantTxt == null || !isFinite(cantidad) || cantidad <= 0) {
|
||||||
|
throw new Error('La cantidad de "' + descripcion.slice(0, 40) + '" debe ser mayor a 0.');
|
||||||
|
}
|
||||||
|
if (cantidad > 1e7) throw new Error('La cantidad de "' + descripcion.slice(0, 40) + '" no es válida.');
|
||||||
|
const notas = limpio(r.notas);
|
||||||
|
if (notas.length > 300) throw new Error('Las notas de "' + descripcion.slice(0, 30) + '…" deben tener máximo 300 caracteres.');
|
||||||
|
return { descripcion: descripcion, cantidad: Math.round(cantidad * 10000) / 10000,
|
||||||
|
unidad: limpio(r.unidad).toUpperCase().slice(0, 20) || 'PZA', notas: notas };
|
||||||
|
});
|
||||||
|
if (!renglones.length) throw new Error('Agrega al menos un renglón con descripción y cantidad.');
|
||||||
|
if (renglones.length > MAX_RENGLONES_REQ) throw new Error('Una requisición admite máximo ' + MAX_RENGLONES_REQ + ' renglones.');
|
||||||
|
return renglones;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un pago a proveedor. orden = {moneda, total, fechaOrden, hoy}. Fecha no futura ni anterior a la orden; método y
|
||||||
|
* referencia como en cobranza; si la moneda del pago es otra, tipo de cambio obligatorio (pesos por dólar). El monto en la
|
||||||
|
* moneda de la orden debe ser su total, con tolerancia de TOLERANCIA_PAGO (1.00).
|
||||||
|
*/
|
||||||
|
function normalizarPagoProveedor(p, orden) {
|
||||||
|
const d = p || {};
|
||||||
|
const fecha = validarFechaIso(d.fecha, orden.hoy, 'La fecha del pago');
|
||||||
|
if (orden.fechaOrden && fecha < orden.fechaOrden) throw new Error('La fecha del pago no puede ser anterior a la orden (' + orden.fechaOrden + ').');
|
||||||
|
const pago = normalizarPago(Object.assign({}, d, { fecha: fecha, etapaOrden: 1 }),
|
||||||
|
{ moneda: orden.moneda, etapas: 1, hoy: orden.hoy });
|
||||||
|
if (Math.abs(aCentavos(pago.montoAplicado) - aCentavos(orden.total)) > aCentavos(TOLERANCIA_PAGO)) {
|
||||||
|
throw new Error('El pago (' + formatoMoneda(pago.montoAplicado) + ' ' + orden.moneda + ') debe ser igual al total de la orden (' +
|
||||||
|
formatoMoneda(orden.total) + ' ' + orden.moneda + ').');
|
||||||
|
}
|
||||||
|
return { fecha: pago.fecha, monto: pago.monto, monedaPago: pago.monedaPago, tipoCambio: pago.tipoCambio,
|
||||||
|
montoAplicado: pago.montoAplicado, metodo: pago.metodo, referencia: pago.referencia };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "REQ-2026-0001 — Cotización.pdf"; el archivo n (n > 1) lleva " (n)": el anterior no se borra. */
|
||||||
|
function nombreArchivoReq(folio, que, ext, n) {
|
||||||
|
return String(folio) + ' — ' + que + (n > 1 ? ' (' + n + ')' : '') + '.' + ext;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Búsqueda en catálogos del SAT (c_ClaveProdServ / c_ClaveUnidad) ----
|
// ---- Búsqueda en catálogos del SAT (c_ClaveProdServ / c_ClaveUnidad) ----
|
||||||
|
|
||||||
const PALABRAS_VACIAS_SAT = ('de del la las el los y o u a al en con sin para por un una unos unas que se su sus ' +
|
const PALABRAS_VACIAS_SAT = ('de del la las el los y o u a al en con sin para por un una unos unas que se su sus ' +
|
||||||
@ -3033,6 +3687,7 @@ function construirHtmlOrdenCompra(d, cfg, logoUrl) {
|
|||||||
'<table style="width:auto;margin-left:auto;">' +
|
'<table style="width:auto;margin-left:auto;">' +
|
||||||
'<tr><td colspan="2" style="font-size:20px;font-weight:bold;letter-spacing:2px;color:' + AZUL + ';padding:0 0 3px;text-align:right;">ORDEN DE COMPRA</td></tr>' +
|
'<tr><td colspan="2" style="font-size:20px;font-weight:bold;letter-spacing:2px;color:' + AZUL + ';padding:0 0 3px;text-align:right;">ORDEN DE COMPRA</td></tr>' +
|
||||||
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Proyecto', d.proyectoFolio) +
|
docFila('Folio', d.folio, true) + docFila('Fecha', d.fecha) + docFila('Proyecto', d.proyectoFolio) +
|
||||||
|
(d.reqFolio ? docFila('Requisición', d.reqFolio) : '') +
|
||||||
(usd ? docFila('Moneda', 'USD (dólares estadounidenses)', true) : '') +
|
(usd ? docFila('Moneda', 'USD (dólares estadounidenses)', true) : '') +
|
||||||
'</table></td></tr></table>' +
|
'</table></td></tr></table>' +
|
||||||
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
|
'<div style="border-top:3px solid ' + AQUA + ';margin-top:8px;"></div>' +
|
||||||
|
|||||||
@ -164,7 +164,7 @@ const sinProy=cot('',50);
|
|||||||
|
|
||||||
// --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
|
// --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
|
||||||
const dAna=ok('ana','getDatosIniciales');
|
const dAna=ok('ana','getDatosIniciales');
|
||||||
assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false,compras:false});
|
assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:false});
|
||||||
assert(!/contrato|pagos|saldo/i.test(JSON.stringify(dAna)),'datos iniciales sin cobranza');
|
assert(!/contrato|pagos|saldo/i.test(JSON.stringify(dAna)),'datos iniciales sin cobranza');
|
||||||
const COB=[['listarProyectosCobranza',['']],['getCobranzaProyecto',[p1]],['marcarAceptada',[p1,cH]],['guardarEsquema',[p1,P.esquemaPorOmision()]],
|
const COB=[['listarProyectosCobranza',['']],['getCobranzaProyecto',[p1]],['marcarAceptada',[p1,cH]],['guardarEsquema',[p1,P.esquemaPorOmision()]],
|
||||||
['firmarContrato',[p1,HOY_ISO]],['desbloquearContrato',[p1,'motivo largo']],['registrarPago',[{proyectoFolio:p1}]],['cancelarPago',['PAG-'+ANIO+'-0001','motivo']]];
|
['firmarContrato',[p1,HOY_ISO]],['desbloquearContrato',[p1,'motivo largo']],['registrarPago',[{proyectoFolio:p1}]],['cancelarPago',['PAG-'+ANIO+'-0001','motivo']]];
|
||||||
@ -174,8 +174,8 @@ for(const [f,args] of COB) falla('intruso',f,args,/@helgen.tech/,'SESION');
|
|||||||
assert.throws(()=>ctx.getCobranzaProyecto(p1),/Inicia sesión/,'desde el editor (sin token) tampoco');
|
assert.throws(()=>ctx.getCobranzaProyecto(p1),/Inicia sesión/,'desde el editor (sin token) tampoco');
|
||||||
|
|
||||||
// --- Tania (rol de cobranza, sin desbloqueo) ---
|
// --- Tania (rol de cobranza, sin desbloqueo) ---
|
||||||
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false,compras:false});
|
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false,compras:false,requisicion:false});
|
||||||
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true,compras:false});
|
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true,compras:false,requisicion:false});
|
||||||
const lista=ok('tania','listarProyectosCobranza','');
|
const lista=ok('tania','listarProyectosCobranza','');
|
||||||
assert.deepEqual(lista.map(p=>p.folio+':'+p.contrato),[p2+':',p1+':']);
|
assert.deepEqual(lista.map(p=>p.folio+':'+p.contrato),[p2+':',p1+':']);
|
||||||
// Carpeta y libro: fuera de "Cotizador Helgen", libro dentro de la carpeta de cobranza
|
// Carpeta y libro: fuera de "Cotizador Helgen", libro dentro de la carpeta de cobranza
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Compras (fase 3): órdenes de compra por proyecto, proveedores, PDF y margen. Permisos con ROL_COMPRAS validados en el
|
// Compras (fase 3): órdenes de compra por proyecto (siempre desde una requisición autorizada), proveedores, PDF y margen. Permisos con ROL_COMPRAS validados en el
|
||||||
// servidor; el margen solo lo ve ROL_COBRANZA. Todo por doPost con tokens simulados (mismos simulacros que cobranza.test.js).
|
// servidor; el margen solo lo ve ROL_COBRANZA. Todo por doPost con tokens simulados (mismos simulacros que cobranza.test.js).
|
||||||
// Uso: node tests/compras.test.js . (carpeta con Code.js y Logo.js)
|
// Uso: node tests/compras.test.js . (carpeta con Code.js y Logo.js)
|
||||||
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
||||||
@ -61,13 +61,14 @@ const db=libro('DB','Base de datos',{Config:hoja([['Clave','Valor'],['PREFIJO_FO
|
|||||||
libro('CAT','Catálogo',{Catalogo:hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']])});
|
libro('CAT','Catálogo',{Catalogo:hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']])});
|
||||||
const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT',
|
const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT',
|
||||||
ROL_COBRANZA:'efrain@helgen.tech, tania@helgen.tech',ADMIN_DESBLOQUEO:'efrain@helgen.tech',
|
ROL_COBRANZA:'efrain@helgen.tech, tania@helgen.tech',ADMIN_DESBLOQUEO:'efrain@helgen.tech',
|
||||||
ROL_COMPRAS:'efrain@helgen.tech; Pedro@helgen.tech, intruso@gmail.com'},cache={};
|
ROL_COMPRAS:'efrain@helgen.tech; Pedro@helgen.tech, intruso@gmail.com',ROL_REQUISICION:'obrero@helgen.tech'},cache={};
|
||||||
|
|
||||||
// ---- Tokens: efrain (cobranza + compras + admin), tania (cobranza sin compras), pedro (compras sin cobranza), ana (vendedora sin rol) ----
|
// ---- Tokens: efrain (cobranza + compras + admin), tania (cobranza sin compras), pedro (compras sin cobranza), ana (vendedora sin rol),
|
||||||
|
// obrero (solo ROL_REQUISICION: pide materiales) ----
|
||||||
const ahora=Math.floor(Date.now()/1000);
|
const ahora=Math.floor(Date.now()/1000);
|
||||||
const claims=(email,n,a)=>({aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email,email_verified:'true',hd:'helgen.tech',given_name:n,family_name:a});
|
const claims=(email,n,a)=>({aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email,email_verified:'true',hd:'helgen.tech',given_name:n,family_name:a});
|
||||||
const tokens={efrain:claims('efrain@helgen.tech','Efrain','Sosa'),tania:claims('tania@helgen.tech','Tania','R'),
|
const tokens={efrain:claims('efrain@helgen.tech','Efrain','Sosa'),tania:claims('tania@helgen.tech','Tania','R'),
|
||||||
ana:claims('ana@helgen.tech','Ana','López'),pedro:claims('pedro@helgen.tech','Pedro','Ruiz'),intruso:{...claims('intruso@gmail.com','X','Y'),hd:undefined}};
|
ana:claims('ana@helgen.tech','Ana','López'),obrero:claims('obrero@helgen.tech','Juan','Obrero'),pedro:claims('pedro@helgen.tech','Pedro','Ruiz'),intruso:{...claims('intruso@gmail.com','X','Y'),hd:undefined}};
|
||||||
|
|
||||||
const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMonth()+1)).replace('dd',pad(d.getDate()))
|
const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMonth()+1)).replace('dd',pad(d.getDate()))
|
||||||
.replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
|
.replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
|
||||||
@ -159,54 +160,66 @@ const cot=(proyectoFolio,precio,extra)=>ok('ana','guardarCotizacion',{cliente:{n
|
|||||||
const c1=cot(p1,10000), c1usd=cot(p1,100,{moneda:'USD'}), c2=cot(p2,5000,{moneda:'USD',marca:'energy'}), c3=cot(p3,100), c4=cot(p4,100);
|
const c1=cot(p1,10000), c1usd=cot(p1,100,{moneda:'USD'}), c2=cot(p2,5000,{moneda:'USD',marca:'energy'}), c3=cot(p3,100), c4=cot(p4,100);
|
||||||
for(const [p,c] of [[p1,c1],[p2,c2],[p4,c4]]){ok('efrain','marcarAceptada',p,c,true);ok('efrain','firmarContrato',p,HOY_ISO)}
|
for(const [p,c] of [[p1,c1],[p2,c2],[p4,c4]]){ok('efrain','marcarAceptada',p,c,true);ok('efrain','firmarContrato',p,HOY_ISO)}
|
||||||
ok('efrain','marcarAceptada',p3,c3);
|
ok('efrain','marcarAceptada',p3,c3);
|
||||||
const filaP4=db.pest.Proyectos.m.findIndex(r=>r[0]===p4); db.pest.Proyectos.m[filaP4][6]='Cancelado';
|
|
||||||
// Los proyectos nuevos ya no tienen subcarpeta "Órdenes de compra"
|
// Los proyectos nuevos ya no tienen subcarpeta "Órdenes de compra"
|
||||||
const carpP1=carpetas[db.pest.Proyectos.m.find(r=>r[0]===p1)[7]];
|
const carpP1=carpetas[db.pest.Proyectos.m.find(r=>r[0]===p1)[7]];
|
||||||
assert.deepEqual(carpP1.hijos.map(h=>h.nombre),['Cotizaciones']);
|
assert.deepEqual(carpP1.hijos.map(h=>h.nombre),['Cotizaciones']);
|
||||||
|
|
||||||
// --- Sin rol de compras: no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
|
// --- Sin rol de compras: no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
|
||||||
assert.deepEqual({...ok('ana','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:false});
|
assert.deepEqual({...ok('ana','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:false});
|
||||||
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false,compras:false});
|
assert.deepEqual({...ok('tania','getDatosIniciales').permisos},{cobranza:true,desbloqueo:false,compras:false,requisicion:false});
|
||||||
assert.deepEqual({...ok('pedro','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:true});
|
assert.deepEqual({...ok('pedro','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:true,requisicion:false});
|
||||||
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true,compras:true});
|
assert.deepEqual({...ok('efrain','getDatosIniciales').permisos},{cobranza:true,desbloqueo:true,compras:true,requisicion:false});
|
||||||
const OC=f=>'OC-'+ANIO+'-'+f;
|
assert.deepEqual({...ok('obrero','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:true});
|
||||||
const COMPRAS=[['listarProyectosCompras',['']],['getComprasProyecto',[p1]],['guardarOrdenCompra',[{proyectoFolio:p1,...base}]],
|
const OC=f=>'OC-'+ANIO+'-'+f, REQ=f=>'REQ-'+ANIO+'-'+f;
|
||||||
['marcarOcRecibida',[OC('0001'),HOY_ISO]],['cancelarOrdenCompra',[OC('0001'),'motivo largo']]];
|
const COMPRAS=[['listarProyectosCompras',['']],['getComprasProyecto',[p1]],['cancelarOrdenCompra',[OC('0001'),'motivo largo']],
|
||||||
for(const quien of ['ana','tania']) for(const [f,args] of COMPRAS) falla(quien,f,args,/No tienes permiso de compras/,'PERMISO');
|
['listarRequisiciones',['']],['getRequisicion',[REQ('0001')]],['autorizarRequisicion',[REQ('0001'),{}]],
|
||||||
|
['rechazarRequisicion',[REQ('0001'),'motivo largo']],['devolverRequisicion',[REQ('0001'),'motivo largo']],
|
||||||
|
['registrarPagoProveedor',[{reqFolio:REQ('0001')}]],['cancelarPagoProveedor',['PGP-'+ANIO+'-0001','motivo largo']],
|
||||||
|
['cerrarRequisicionCompras',[REQ('0001'),'motivo largo']]];
|
||||||
|
for(const quien of ['ana','tania','obrero']) for(const [f,args] of COMPRAS) falla(quien,f,args,/No tienes permiso de compras/,'PERMISO');
|
||||||
for(const [f,args] of COMPRAS) falla('intruso',f,args,/@helgen.tech/,'SESION');
|
for(const [f,args] of COMPRAS) falla('intruso',f,args,/@helgen.tech/,'SESION');
|
||||||
|
// Las órdenes solo salen de una requisición: ya no hay emisión directa ni "Recibida" desde compras
|
||||||
|
for(const f of ['guardarOrdenCompra','marcarOcRecibida']) falla('pedro',f,[{}],/Función no permitida/);
|
||||||
assert.equal(props.COMPRAS_DB_ID,undefined,'una llamada sin rol no crea nada');
|
assert.equal(props.COMPRAS_DB_ID,undefined,'una llamada sin rol no crea nada');
|
||||||
assert.throws(()=>ctx.getComprasProyecto(p1),/Inicia sesión/,'desde el editor (sin token) tampoco');
|
assert.throws(()=>ctx.getComprasProyecto(p1),/Inicia sesión/,'desde el editor (sin token) tampoco');
|
||||||
// Compras sin cobranza: no ve cobranza (ni el margen)
|
// Compras sin cobranza: no ve cobranza (ni el margen)
|
||||||
falla('pedro','getCobranzaProyecto',[p1],/No tienes permiso de cobranza/,'PERMISO');
|
falla('pedro','getCobranzaProyecto',[p1],/No tienes permiso de cobranza/,'PERMISO');
|
||||||
|
|
||||||
|
// Requisiciones (las pide obrero): toda orden de compra sale de una requisición autorizada
|
||||||
|
const PDF=Buffer.from('%PDF-1.4 cotizacion del proveedor').toString('base64');
|
||||||
|
const req=(proyectoFolio)=>ok('obrero','guardarRequisicion',{proyectoFolio,renglones:[{descripcion:'Material',cantidad:1,unidad:'pza'}],
|
||||||
|
cotizacion:{nombre:'cot.pdf',datos:PDF},idSolicitud:'rq'+(++nId)}).folio;
|
||||||
|
const reqP4=req(p4);
|
||||||
|
const filaP4=db.pest.Proyectos.m.findIndex(r=>r[0]===p4); db.pest.Proyectos.m[filaP4][6]='Cancelado';
|
||||||
|
|
||||||
// --- Lista: solo Activos con contrato firmado, con su moneda y sin montos ---
|
// --- Lista: solo Activos con contrato firmado, con su moneda y sin montos ---
|
||||||
const lista=ok('pedro','listarProyectosCompras','');
|
const lista=ok('pedro','listarProyectosCompras','');
|
||||||
assert.deepEqual(lista.map(p=>p.folio+':'+p.moneda+':'+p.marca),[p2+':USD:energy',p1+':MXN:helgen']);
|
assert.deepEqual(lista.map(p=>p.folio+':'+p.moneda+':'+p.marca),[p2+':USD:energy',p1+':MXN:helgen']);
|
||||||
assert(!/total|subtotal|contrat/i.test(JSON.stringify(lista)),'sin montos del contrato');
|
assert(!/total|subtotal|contrat/i.test(JSON.stringify(lista)),'sin montos del contrato');
|
||||||
assert.deepEqual(ok('pedro','listarProyectosCompras','texas').map(p=>p.folio),[p2]);
|
assert.deepEqual(ok('pedro','listarProyectosCompras','texas').map(p=>p.folio),[p2]);
|
||||||
|
|
||||||
// --- Vista: sin montos del contrato ni margen; crea el libro y la carpeta privados ---
|
// --- Vista: sin montos del contrato ni margen; el libro y la carpeta son privados ---
|
||||||
let v=ok('pedro','getComprasProyecto',p1.toLowerCase());
|
let v=ok('pedro','getComprasProyecto',p1.toLowerCase());
|
||||||
assert.deepEqual([v.proyecto.folio,v.moneda,v.marca,v.puedeEmitir,v.ordenes.length,v.proyecto.ubicacion],[p1,'MXN','helgen',true,0,'Querétaro, Qro.']);
|
assert.deepEqual([v.proyecto.folio,v.moneda,v.marca,v.ordenes.length,v.proyecto.ubicacion],[p1,'MXN','helgen',0,'Querétaro, Qro.']);
|
||||||
assert(!('margen' in v)&&!('contrato' in v)&&!/11600|10000/.test(JSON.stringify(v)),'compras no ve el contratado ni el margen');
|
assert(!('margen' in v)&&!('contrato' in v)&&!/11600|10000/.test(JSON.stringify(v)),'compras no ve el contratado ni el margen');
|
||||||
const carpCom=carpetas[props.COMPRAS_FOLDER_ID], LC=libros[props.COMPRAS_DB_ID];
|
const carpCom=carpetas[props.COMPRAS_FOLDER_ID], LC=libros[props.COMPRAS_DB_ID];
|
||||||
assert.equal(ruta(carpCom),'Mi unidad/Cotizador Helgen — Compras');
|
assert.equal(ruta(carpCom),'Mi unidad/Cotizador Helgen — Compras');
|
||||||
assert.equal(LC.nombre,'Cotizador Helgen — Compras (datos)'); assert.equal(archivos[LC.id].padre,carpCom);
|
assert.equal(LC.nombre,'Cotizador Helgen — Compras (datos)'); assert.equal(archivos[LC.id].padre,carpCom);
|
||||||
assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','OrdenesCompra','Proveedores']);
|
assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','OrdenesCompra','PagosProveedores','Proveedores','Requisiciones','RequisicionesEventos','RequisicionesRenglones']);
|
||||||
assert.deepEqual([carpCom.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'carpeta y libro de compras restringidos');
|
assert.deepEqual([carpCom.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'carpeta y libro de compras restringidos');
|
||||||
const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1);
|
const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1);
|
||||||
assert.equal(ok('pedro','getComprasProyecto',p3).puedeEmitir,false,'sin contrato firmado no se emite');
|
|
||||||
|
|
||||||
// --- Emitir: folio, línea de negocio del contrato (no del navegador), costo capturado, PDF privado ---
|
// --- Emitir (al autorizar): folio, línea de negocio del contrato (no del navegador), costo capturado, PDF privado ---
|
||||||
const oc=(extra)=>({proyectoFolio:p1,...base,fechaEntrega:HOY_ISO,proveedor:{razonSocial:'Paneles del Bajío',contacto:'Luis Pérez',telefono:'+52 442 111 2233'},
|
const oc=(extra)=>({...base,fechaEntrega:HOY_ISO,proveedor:{razonSocial:'Paneles del Bajío',contacto:'Luis Pérez',telefono:'+52 442 111 2233'},
|
||||||
renglones:[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',cantidad:2,costo:1000,precio:4100}],
|
renglones:[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',cantidad:2,costo:1000,precio:4100}],
|
||||||
notas:'<script>x</script>',marca:'energy',idSolicitud:'o'+(++nId),...extra});
|
notas:'<script>x</script>',marca:'energy',proyectoFolio:p2,idSolicitud:'o'+(++nId),...extra});
|
||||||
let r=ok('pedro','guardarOrdenCompra',oc({idSolicitud:'oc1'}));
|
const emitir=(quien,proyectoFolio,x)=>{const R=req(proyectoFolio);const r=ok(quien,'autorizarRequisicion',R,x);return {...r,folio:r.ocFolio,req:R}};
|
||||||
assert.deepEqual([r.folio,r.total,r.moneda],[OC('0001'),2320,'MXN']);
|
let r=emitir('pedro',p1,oc({idSolicitud:'oc1'}));
|
||||||
|
assert.deepEqual([r.folio,r.total,r.moneda,r.req,r.vista.requisicion.estado,r.vista.requisicion.ocFolio],[OC('0001'),2320,'MXN',REQ('0002'),'Autorizada',OC('0001')]);
|
||||||
let f=H('OrdenesCompra')[1];
|
let f=H('OrdenesCompra')[1];
|
||||||
assert.deepEqual([f[0],f[1],f[2],f[3],f[4],f[5],f[7],f[9],f[10],f[11],f[12],f[13],f[14],f[15],f[16],f[17],f[18],f[19],f[20],f[25],f[26],f[28]],
|
assert.deepEqual([f[0],f[1],f[2],f[3],f[4],f[5],f[7],f[9],f[10],f[11],f[12],f[13],f[14],f[15],f[16],f[17],f[18],f[19],f[20],f[25],f[26],f[28],f[34]],
|
||||||
[r.folio,HOY_ISO,p1,'Helgen','SIN-RFC:paneles del bajio','Paneles del Bajío','Luis Pérez','+52 442 111 2233',HOY_ISO,'Querétaro, Qro.',
|
[r.folio,HOY_ISO,p1,'Helgen','SIN-RFC:paneles del bajio','Paneles del Bajío','Luis Pérez','+52 442 111 2233',HOY_ISO,'Querétaro, Qro.',
|
||||||
'Crédito 30 días','MXN',0.16,2000,320,2320,'MXN','',2000,'pedro@helgen.tech','Pedro Ruiz','Emitida']);
|
'Crédito 30 días','MXN',0.16,2000,320,2320,'MXN','',2000,'pedro@helgen.tech','Pedro Ruiz','Emitida',REQ('0002')],'el proyecto sale de la requisición, no del navegador');
|
||||||
assert.deepEqual(JSON.parse(f[21]).map(x=>x.costo+'/'+x.importe+'/'+('precio' in x)),['1000/2000/false'],'el costo es el capturado; el precio de venta no se guarda');
|
assert.deepEqual(JSON.parse(f[21]).map(x=>x.costo+'/'+x.importe+'/'+('precio' in x)),['1000/2000/false'],'el costo es el capturado; el precio de venta no se guarda');
|
||||||
const pdfOc=archivos[f[23]];
|
const pdfOc=archivos[f[23]];
|
||||||
assert.equal(ruta(pdfOc.padre),'Mi unidad/Cotizador Helgen — Compras/'+ANIO+'/'+p1);
|
assert.equal(ruta(pdfOc.padre),'Mi unidad/Cotizador Helgen — Compras/'+ANIO+'/'+p1);
|
||||||
@ -214,53 +227,61 @@ assert.equal(pdfOc.blob.name,r.folio+' — Paneles del Bajío.pdf'); assert.equa
|
|||||||
assert.deepEqual([pdfOc.acceso,pdfOc.padre.acceso,pdfOc.padre.padre.acceso],['PRIVATE','PRIVATE','PRIVATE'],'PDF y subcarpetas restringidos');
|
assert.deepEqual([pdfOc.acceso,pdfOc.padre.acceso,pdfOc.padre.padre.acceso],['PRIVATE','PRIVATE','PRIVATE'],'PDF y subcarpetas restringidos');
|
||||||
const html=pdfOc.blob.html;
|
const html=pdfOc.blob.html;
|
||||||
assert(/ORDEN DE COMPRA/.test(html)&&/Helgen Servicios Especializados/.test(html)&&/RFC HSE240304PC1/.test(html));
|
assert(/ORDEN DE COMPRA/.test(html)&&/Helgen Servicios Especializados/.test(html)&&/RFC HSE240304PC1/.test(html));
|
||||||
|
assert(/>Requisición<\/td>/.test(html)&&html.includes(REQ('0002')),'el PDF lleva la línea "Requisición: REQ-…"');
|
||||||
for(const t of ['Favor de indicar el folio de esta orden en su factura.','Facturar a nombre de la razón social y RFC indicados arriba.',
|
for(const t of ['Favor de indicar el folio de esta orden en su factura.','Facturar a nombre de la razón social y RFC indicados arriba.',
|
||||||
'Precios fijos según esta orden; cualquier cambio requiere una nueva orden.','Entregar en el lugar y fecha indicados.']) assert(html.includes(t),'término: '+t);
|
'Precios fijos según esta orden; cualquier cambio requiere una nueva orden.','Entregar en el lugar y fecha indicados.']) assert(html.includes(t),'término: '+t);
|
||||||
assert(/<script>/.test(html)&&!/<script>x/.test(html),'notas escapadas');
|
assert(/<script>/.test(html)&&!/<script>x/.test(html),'notas escapadas');
|
||||||
assert(!/4,100|4100/.test(html),'sin precio de venta'); assert(html.includes('DOS MIL TRESCIENTOS VEINTE PESOS 00/100 M.N.'));
|
assert(!/4,100|4100/.test(html),'sin precio de venta'); assert(html.includes('DOS MIL TRESCIENTOS VEINTE PESOS 00/100 M.N.'));
|
||||||
// Idempotencia
|
// Idempotencia
|
||||||
assert.equal(ok('pedro','guardarOrdenCompra',oc({idSolicitud:'oc1'})).folio,r.folio); assert.equal(H('OrdenesCompra').length,2);
|
assert.equal(ok('pedro','autorizarRequisicion',REQ('0002'),oc({idSolicitud:'oc1'})).ocFolio,r.folio); assert.equal(H('OrdenesCompra').length,2);
|
||||||
// Rechazos que no gastan folio
|
// Rechazos que no gastan folio (la requisición sigue Solicitada)
|
||||||
falla('pedro','guardarOrdenCompra',[oc({proyectoFolio:p3})],/contrato firmado/);
|
falla('obrero','guardarRequisicion',[{proyectoFolio:p3,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/activos con contrato firmado/);
|
||||||
falla('pedro','guardarOrdenCompra',[oc({proyectoFolio:p4})],/está cancelado: no admite órdenes/);
|
falla('pedro','autorizarRequisicion',[reqP4,oc()],/está cancelado: no admite órdenes/);
|
||||||
falla('pedro','guardarOrdenCompra',[oc({moneda:'USD'})],/proyecto es en MXN y la orden en USD: captura el tipo de cambio/);
|
const rPend=req(p1);
|
||||||
falla('pedro','guardarOrdenCompra',[oc({renglones:[{descripcion:'Inversor',cantidad:1,costo:''}]})],/costo unitario de "Inversor"/);
|
falla('pedro','autorizarRequisicion',[rPend,oc({moneda:'USD'})],/proyecto es en MXN y la orden en USD: captura el tipo de cambio/);
|
||||||
falla('pedro','guardarOrdenCompra',[oc({proyectoFolio:'PRY-2000-0001'})],/No existe el proyecto/);
|
falla('pedro','autorizarRequisicion',[rPend,oc({renglones:[{descripcion:'Inversor',cantidad:1,costo:''}]})],/costo unitario de "Inversor"/);
|
||||||
|
falla('pedro','autorizarRequisicion',['REQ-2000-0001',oc()],/No existe la requisición/);
|
||||||
|
falla('pedro','autorizarRequisicion',[REQ('0002'),oc()],/está autorizada: no se puede autorizar/);
|
||||||
assert.equal(props['CONSECUTIVO_OC_'+ANIO],'1','los rechazos no gastan folio'); assert.equal(H('OrdenesCompra').length,2);
|
assert.equal(props['CONSECUTIVO_OC_'+ANIO],'1','los rechazos no gastan folio'); assert.equal(H('OrdenesCompra').length,2);
|
||||||
|
assert.equal(ok('pedro','getRequisicion',rPend).requisicion.estado,'Solicitada');
|
||||||
// Orden en USD en un proyecto MXN: tipo de cambio obligatorio; el costo para el margen queda en pesos
|
// Orden en USD en un proyecto MXN: tipo de cambio obligatorio; el costo para el margen queda en pesos
|
||||||
r=ok('efrain','guardarOrdenCompra',oc({moneda:'USD',tipoCambio:18.5,ivaTasa:0,renglones:[{descripcion:'Inversor',cantidad:1,costo:100}],
|
r=emitir('efrain',p1,oc({moneda:'USD',tipoCambio:18.5,ivaTasa:0,renglones:[{descripcion:'Inversor',cantidad:1,costo:100}],
|
||||||
proveedor:{razonSocial:'Paneles del Bajío',rfc:'PDB200101AB1',correo:'ventas@pdb.mx'},lugarEntrega:'Bodega Norte',fechaEntrega:MANANA}));
|
proveedor:{razonSocial:'Paneles del Bajío',rfc:'PDB200101AB1',correo:'ventas@pdb.mx'},lugarEntrega:'Bodega Norte',fechaEntrega:MANANA}));
|
||||||
|
const R2=r.req;
|
||||||
f=H('OrdenesCompra')[2];
|
f=H('OrdenesCompra')[2];
|
||||||
assert.deepEqual([r.folio,f[4],f[13],f[14],f[16],f[17],f[18],f[19],f[20],f[11]],[OC('0002'),'PDB200101AB1','USD',0,0,100,'MXN',18.5,1850,'Bodega Norte']);
|
assert.deepEqual([r.folio,f[4],f[13],f[14],f[16],f[17],f[18],f[19],f[20],f[11]],[OC('0002'),'PDB200101AB1','USD',0,0,100,'MXN',18.5,1850,'Bodega Norte']);
|
||||||
assert(/P\. Unitario|Costo unit\. \(USD\)/.test(archivos[f[23]].blob.html)&&/Moneda/.test(archivos[f[23]].blob.html)&&!/18\.5/.test(archivos[f[23]].blob.html),'USD en el PDF; el tipo de cambio no sale');
|
assert(/P\. Unitario|Costo unit\. \(USD\)/.test(archivos[f[23]].blob.html)&&/Moneda/.test(archivos[f[23]].blob.html)&&!/18\.5/.test(archivos[f[23]].blob.html),'USD en el PDF; el tipo de cambio no sale');
|
||||||
// Proveedor: el que tenía solo razón social toma el RFC (una sola fila) y conserva el contacto
|
// Proveedor: el que tenía solo razón social toma el RFC (una sola fila) y conserva el contacto
|
||||||
assert.equal(H('Proveedores').length,2,'un solo proveedor');
|
assert.equal(H('Proveedores').length,2,'un solo proveedor');
|
||||||
assert.deepEqual(H('Proveedores')[1].slice(0,6),['PDB200101AB1','Paneles del Bajío','PDB200101AB1','Luis Pérez','ventas@pdb.mx','+52 442 111 2233']);
|
assert.deepEqual(H('Proveedores')[1].slice(0,6),['PDB200101AB1','Paneles del Bajío','PDB200101AB1','Luis Pérez','ventas@pdb.mx','+52 442 111 2233']);
|
||||||
|
assert.deepEqual(ok('pedro','getRequisicion',rPend).proveedores.map(p=>p.etiqueta+'|'+p.rfc+'|'+p.contacto),['Paneles del Bajío|PDB200101AB1|Luis Pérez']);
|
||||||
v=ok('pedro','getComprasProyecto',p1);
|
v=ok('pedro','getComprasProyecto',p1);
|
||||||
assert.deepEqual(v.proveedores.map(p=>p.etiqueta+'|'+p.rfc+'|'+p.contacto),['Paneles del Bajío|PDB200101AB1|Luis Pérez']);
|
assert.deepEqual(v.ordenes.map(x=>x.folio+':'+x.reqFolio),[OC('0002')+':'+R2,OC('0001')+':'+REQ('0002')],'más reciente primero, con su requisición');
|
||||||
assert.deepEqual(v.ordenes.map(x=>x.folio),[OC('0002'),OC('0001')],'más reciente primero');
|
|
||||||
// Proyecto USD de Helgen Energy pagado en pesos: costo en dólares; el PDF dice Helgen (no Helgen Energy)
|
// Proyecto USD de Helgen Energy pagado en pesos: costo en dólares; el PDF dice Helgen (no Helgen Energy)
|
||||||
r=ok('pedro','guardarOrdenCompra',oc({proyectoFolio:p2,moneda:'MXN',tipoCambio:18.5,renglones:[{descripcion:'Cable',cantidad:1,costo:1850}],
|
r=emitir('pedro',p2,oc({moneda:'MXN',tipoCambio:18.5,renglones:[{descripcion:'Cable',cantidad:1,costo:1850}],proveedor:{razonSocial:'Cables SA'}}));
|
||||||
proveedor:{razonSocial:'Cables SA'}}));
|
|
||||||
f=H('OrdenesCompra')[3];
|
f=H('OrdenesCompra')[3];
|
||||||
assert.deepEqual([f[3],f[13],f[18],f[20],f[11]],['Helgen Energy','MXN','USD',100,'Austin, TX'],'la línea de negocio sale del contrato');
|
assert.deepEqual([f[3],f[13],f[18],f[20],f[11]],['Helgen Energy','MXN','USD',100,'Austin, TX'],'la línea de negocio sale del contrato');
|
||||||
assert(/Helgen Servicios Especializados/.test(archivos[f[23]].blob.html)&&!/HELGEN ENERGY|Helgen Energy/.test(archivos[f[23]].blob.html));
|
assert(/Helgen Servicios Especializados/.test(archivos[f[23]].blob.html)&&!/HELGEN ENERGY|Helgen Energy/.test(archivos[f[23]].blob.html));
|
||||||
// Una tercera orden en p1 que se cancela
|
// Una tercera orden en p1 que se cancela
|
||||||
const r3=ok('pedro','guardarOrdenCompra',oc({renglones:[{descripcion:'Estructura',cantidad:1,costo:5000}]}));
|
const r3=emitir('pedro',p1,oc({renglones:[{descripcion:'Estructura',cantidad:1,costo:5000}]}));
|
||||||
|
|
||||||
// --- Recibida y cancelada (nunca se borran) ---
|
// --- Pagada, recibida (al cerrar la requisición) y cancelada (nunca se borran) ---
|
||||||
falla('pedro','marcarOcRecibida',[OC('0002'),'2099-01-01'],/no puede ser futura/);
|
const pagoR2={reqFolio:R2,fecha:HOY_ISO,monto:100,monedaPago:'USD',metodo:'Transferencia',referencia:'SPEI 1',comprobante:{nombre:'p.pdf',datos:PDF},idSolicitud:'pg1'};
|
||||||
falla('pedro','marcarOcRecibida',[OC('0002'),AYER],/anterior a la orden/);
|
v=ok('pedro','registrarPagoProveedor',pagoR2).vista;
|
||||||
v=ok('pedro','marcarOcRecibida',OC('0002'),HOY_ISO);
|
assert.deepEqual([v.requisicion.estado,v.orden.estado],['Pagada','Pagada']);
|
||||||
assert.deepEqual(v.ordenes.find(x=>x.folio===OC('0002')).estado+'|'+v.ordenes.find(x=>x.folio===OC('0002')).fechaRecepcion,'Recibida|'+HOY_ISO);
|
falla('pedro','cancelarOrdenCompra',[OC('0002'),'Ya no se necesita'],/ya está pagada: no se puede cancelar/);
|
||||||
falla('pedro','marcarOcRecibida',[OC('0002'),HOY_ISO],/ya está recibida/);
|
ok('obrero','cerrarRequisicion',R2,'');
|
||||||
|
f=H('OrdenesCompra')[2];
|
||||||
|
assert.deepEqual([f[28],f[29],f[30]],['Recibida',HOY_ISO,'obrero@helgen.tech'],'recibida con la fecha y el correo del solicitante');
|
||||||
falla('pedro','cancelarOrdenCompra',[OC('0002'),'Ya no se necesita'],/ya se recibió: no se puede cancelar/);
|
falla('pedro','cancelarOrdenCompra',[OC('0002'),'Ya no se necesita'],/ya se recibió: no se puede cancelar/);
|
||||||
falla('pedro','cancelarOrdenCompra',[r3.folio,''],/motivo/);
|
falla('pedro','cancelarOrdenCompra',[r3.folio,''],/motivo/);
|
||||||
v=ok('pedro','cancelarOrdenCompra',r3.folio,'El proveedor no tiene existencias');
|
v=ok('pedro','cancelarOrdenCompra',r3.folio,'El proveedor no tiene existencias');
|
||||||
const fc=H('OrdenesCompra').find(x=>x[0]===r3.folio);
|
const fc=H('OrdenesCompra').find(x=>x[0]===r3.folio);
|
||||||
assert.deepEqual([fc[28],fc[31],!!fc[32],fc[33]],['Cancelada','pedro@helgen.tech',true,'El proveedor no tiene existencias']);
|
assert.deepEqual([fc[28],fc[31],!!fc[32],fc[33]],['Cancelada','pedro@helgen.tech',true,'El proveedor no tiene existencias']);
|
||||||
|
let vr=ok('pedro','getRequisicion',r3.req);
|
||||||
|
assert.deepEqual([vr.requisicion.estado,vr.requisicion.ocFolio,vr.orden,vr.puedeAutorizar],['Solicitada','',null,true],'su requisición regresa a Solicitada para autorizarla de nuevo');
|
||||||
|
assert(/Se canceló la orden/.test(vr.eventos[vr.eventos.length-1].nota));
|
||||||
falla('pedro','cancelarOrdenCompra',[r3.folio,'otra vez'],/ya está cancelada/);
|
falla('pedro','cancelarOrdenCompra',[r3.folio,'otra vez'],/ya está cancelada/);
|
||||||
falla('pedro','marcarOcRecibida',[r3.folio,HOY_ISO],/ya está cancelada/);
|
|
||||||
assert.equal(H('OrdenesCompra').length,5,'la cancelada sigue en la hoja');
|
assert.equal(H('OrdenesCompra').length,5,'la cancelada sigue en la hoja');
|
||||||
|
|
||||||
// --- Margen (solo cobranza): contratado sin IVA − órdenes vigentes sin IVA, en la moneda del proyecto ---
|
// --- Margen (solo cobranza): contratado sin IVA − órdenes vigentes sin IVA, en la moneda del proyecto ---
|
||||||
@ -287,13 +308,16 @@ ok('efrain','desbloquearContrato',p1,'Prueba de moneda con órdenes');
|
|||||||
falla('efrain','marcarAceptada',[p1,c1usd],/órdenes de compra en MXN/);
|
falla('efrain','marcarAceptada',[p1,c1usd],/órdenes de compra en MXN/);
|
||||||
// Sin contrato firmado: sale de la lista y no se emiten órdenes; las que tiene se siguen viendo
|
// Sin contrato firmado: sale de la lista y no se emiten órdenes; las que tiene se siguen viendo
|
||||||
assert.deepEqual(ok('pedro','listarProyectosCompras','').map(p=>p.folio),[p2]);
|
assert.deepEqual(ok('pedro','listarProyectosCompras','').map(p=>p.folio),[p2]);
|
||||||
v=ok('pedro','getComprasProyecto',p1); assert.deepEqual([v.puedeEmitir,v.ordenes.length],[false,3]);
|
v=ok('pedro','getComprasProyecto',p1); assert.equal(v.ordenes.length,3);
|
||||||
falla('pedro','guardarOrdenCompra',[oc()],/contrato firmado/);
|
assert.equal(ok('pedro','getRequisicion',rPend).puedeAutorizar,false);
|
||||||
|
falla('pedro','autorizarRequisicion',[rPend,oc()],/contrato firmado/);
|
||||||
|
falla('obrero','guardarRequisicion',[{proyectoFolio:p1,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/activos con contrato firmado/);
|
||||||
ok('efrain','firmarContrato',p1,HOY_ISO);
|
ok('efrain','firmarContrato',p1,HOY_ISO);
|
||||||
|
|
||||||
// --- Bitácora, separación de datos y reparación del acceso ---
|
// --- Bitácora, separación de datos y reparación del acceso ---
|
||||||
const acciones=bit().map(b=>b[2]);
|
const acciones=bit().map(b=>b[2]);
|
||||||
for(const a of ['Emitir orden de compra','Recibir orden de compra','Cancelar orden de compra']) assert(acciones.includes(a),'bitácora: '+a);
|
for(const a of ['Solicitar requisición','Autorizar requisición','Emitir orden de compra','Registrar pago a proveedor','Cerrar requisición',
|
||||||
|
'Cancelar orden de compra','Requisición a Solicitada']) assert(acciones.includes(a),'bitácora: '+a);
|
||||||
assert(bit().every(b=>/@helgen\.tech$/.test(b[1])));
|
assert(bit().every(b=>/@helgen\.tech$/.test(b[1])));
|
||||||
assert.equal(bit().find(b=>b[2]==='Cancelar orden de compra')[5],'El proveedor no tiene existencias');
|
assert.equal(bit().find(b=>b[2]==='Cancelar orden de compra')[5],'El proveedor no tiene existencias');
|
||||||
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos'],'nada de compras en la Base de datos');
|
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos'],'nada de compras en la Base de datos');
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
// Interfaz (web/cotizador/index.html): corre la capa api-web y el script de la app con un DOM y un fetch simulados.
|
// Interfaz (web/cotizador/index.html): corre la capa api-web y el script de la app con un DOM y un fetch simulados.
|
||||||
// Cubre: si el servidor rechaza el proyecto (codigo PROYECTO), se limpia el campo, se refresca la lista y se avisa;
|
// Cubre: si el servidor rechaza el proyecto (codigo PROYECTO), se limpia el campo, se refresca la lista y se avisa;
|
||||||
// moneda USD (el catálogo no pone precio); pestaña Cobranza solo con permiso; aceptar con confirmación; pago con comprobante;
|
// moneda USD (el catálogo no pone precio); pestaña Cobranza solo con permiso; aceptar con confirmación; pago con comprobante;
|
||||||
// margen en Cobranza; pestaña Compras solo con permiso, renglones del catálogo sin costo, tipo de cambio, recibir y cancelar.
|
// margen en Cobranza; pestaña Compras solo con permiso: requisición → orden precargada (renglones sin costo, tipo de cambio),
|
||||||
|
// pago con comprobante, cancelar el pago, cerrar con motivo; pestaña Requisiciones (nueva, corregir, recibí el material) y
|
||||||
|
// SOLO_REQUISICION (solo esa pestaña).
|
||||||
// Uso: node tests/interfaz.test.js . (carpeta del proyecto)
|
// Uso: node tests/interfaz.test.js . (carpeta del proyecto)
|
||||||
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
||||||
const html=fs.readFileSync(path.join(process.argv[2],'web/cotizador/index.html'),'utf8');
|
const html=fs.readFileSync(path.join(process.argv[2],'web/cotizador/index.html'),'utf8');
|
||||||
@ -18,14 +20,16 @@ const TC={valor:18.45,fuente:'Google Finance'};
|
|||||||
|
|
||||||
/** App cargada con un DOM y un servidor simulados nuevos. */
|
/** App cargada con un DOM y un servidor simulados nuevos. */
|
||||||
function montar(datosIniciales){
|
function montar(datosIniciales){
|
||||||
function elemento(tag){const e={tag,value:'',textContent:'',innerHTML:'',className:'',title:'',src:'',placeholder:'',disabled:false,hijos:[],oyentes:{},
|
function elemento(tag){const e={tag,value:'',textContent:'',className:'',title:'',src:'',placeholder:'',disabled:false,hijos:[],oyentes:{},
|
||||||
_clases:new Set(),porClase:{},
|
_clases:new Set(),porClase:{},
|
||||||
classList:{add:c=>e._clases.add(c),remove:c=>e._clases.delete(c),contains:c=>e._clases.has(c),toggle:(c,f)=>f?e._clases.add(c):e._clases.delete(c)},
|
classList:{add:c=>e._clases.add(c),remove:c=>e._clases.delete(c),contains:c=>e._clases.has(c),toggle:(c,f)=>f?e._clases.add(c):e._clases.delete(c)},
|
||||||
addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f),
|
addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f),
|
||||||
disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})),
|
disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})),
|
||||||
focus(){},remove(){},appendChild:h=>e.hijos.push(h),
|
focus(){},remove(){},appendChild:h=>e.hijos.push(h),
|
||||||
querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')),
|
querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')),
|
||||||
querySelectorAll:sel=>sel==='tr'?e.hijos:sel==='input'?Object.values(e.porClase):[]};return e}
|
querySelectorAll:sel=>sel==='tr'?e.hijos:sel==='input'?Object.values(e.porClase):[]};
|
||||||
|
// Como el DOM: innerHTML = '' quita los renglones agregados con appendChild
|
||||||
|
let h='';Object.defineProperty(e,'innerHTML',{get:()=>h,set:v=>{h=v;if(v==='')e.hijos.length=0}});return e}
|
||||||
const porId={};
|
const porId={};
|
||||||
const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')),
|
const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')),
|
||||||
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)};
|
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)};
|
||||||
@ -282,68 +286,186 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
|
|||||||
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
|
B.run('pintarCobranza('+JSON.stringify({...firmada,puedeDesbloquear:true})+')');
|
||||||
assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML));
|
assert(/Desbloquear contrato/.test(B.$('cobContrato').innerHTML));
|
||||||
|
|
||||||
// ===== Compras (con permiso, sin cobranza) =====
|
// ===== Compras (con permiso, sin cobranza): requisiciones → orden → pago → cierre =====
|
||||||
const C=montar({ivaTasa:0.16,catalogo:CAT,tipoCambio:TC,clientes:[],proyectos:[],marcas:{},permisos:{cobranza:false,desbloqueo:false,compras:true}});
|
const C=montar({ivaTasa:0.16,catalogo:CAT,tipoCambio:TC,clientes:[],proyectos:[],marcas:{},permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:false}});
|
||||||
await esperar(5);
|
await esperar(5);
|
||||||
assert(!C.$('tabCom').classList.contains('oculto')&&C.$('tabCob').classList.contains('oculto'),'compras ve Compras y no Cobranza');
|
assert(!C.$('tabCom').classList.contains('oculto')&&C.$('tabCob').classList.contains('oculto')&&C.$('tabReq').classList.contains('oculto'),'compras ve Compras y no Cobranza ni Requisiciones');
|
||||||
assert(/PAN-550 · PZA/.test(C.$('ocCatalogo').innerHTML)&&!/4,100|4100|222/.test(C.$('ocCatalogo').innerHTML+C.$('ocClaves').innerHTML),'listas sin precio de venta');
|
assert(/PAN-550 · PZA/.test(C.$('ocCatalogo').innerHTML)&&!/4,100|4100|222/.test(C.$('ocCatalogo').innerHTML+C.$('ocClaves').innerHTML),'listas sin precio de venta');
|
||||||
|
const RQ={folio:'REQ-2026-0001',fecha:'2026-10-20',proyectoFolio:'PRY-2026-0001',proyectoNombre:'Techo',solicitante:'Juan Obrero',solicitanteEmail:'juan@helgen.tech',
|
||||||
|
estado:'Solicitada',ocFolio:'',renglones:2};
|
||||||
C.respuestas.listarProyectosCompras={ok:true,datos:[{...PRY,moneda:'MXN',marca:'helgen'}]};
|
C.respuestas.listarProyectosCompras={ok:true,datos:[{...PRY,moneda:'MXN',marca:'helgen'}]};
|
||||||
C.run("verTab('com')"); await esperar();
|
C.respuestas.listarRequisiciones={ok:true,datos:[RQ]};
|
||||||
|
C.$('rcEstado').value='Solicitada'; C.run("verTab('com')"); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='listarRequisiciones').args,['','Solicitada']);
|
||||||
|
assert(/REQ-2026-0001/.test(C.$('rcLista').innerHTML)&&/Juan Obrero/.test(C.$('rcLista').innerHTML)&&/abrirReqCom/.test(C.$('rcLista').innerHTML));
|
||||||
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(C.$('comProyectosList').innerHTML)&&/MXN · Helgen/.test(C.$('comProyectosList').innerHTML));
|
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(C.$('comProyectosList').innerHTML)&&/MXN · Helgen/.test(C.$('comProyectosList').innerHTML));
|
||||||
const PROV={id:'PDB200101AB1',razonSocial:'Paneles del Bajío',rfc:'PDB200101AB1',contacto:'Luis Pérez',correo:'v@pdb.mx',telefono:'442',etiqueta:'Paneles del Bajío'};
|
const PROV={id:'PDB200101AB1',razonSocial:'Paneles del Bajío',rfc:'PDB200101AB1',contacto:'Luis Pérez',correo:'v@pdb.mx',telefono:'442',etiqueta:'Paneles del Bajío'};
|
||||||
const vistaCom=(extra={})=>({proyecto:PRY,moneda:'MXN',marca:'helgen',puedeEmitir:true,ordenes:[],proveedores:[PROV],ivaTasas:[0.16,0.08,0],hoy:'2026-10-20',...extra});
|
const vistaReq=(extra={},req={})=>({requisicion:{folio:'REQ-2026-0001',fecha:'2026-10-20',proyectoFolio:'PRY-2026-0001',solicitante:'Juan Obrero',
|
||||||
C.respuestas.getComprasProyecto={ok:true,datos:vistaCom()};
|
solicitanteEmail:'juan@helgen.tech',estado:'Solicitada',motivo:'',cotizacionId:'COT1',cotizacionUrl:'https://drive/cot',ocFolio:'',...req},
|
||||||
C.$('comProyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; C.$('comProyecto').disparar('change'); await esperar();
|
renglones:[{renglon:1,descripcion:'Cable THW 10',cantidad:100,unidad:'M',notas:'rojo'},{renglon:2,descripcion:'Tubo',cantidad:12,unidad:'PZA',notas:''}],
|
||||||
assert.equal(C.llamadas.find(c=>c.funcion==='getComprasProyecto').args[0],'PRY-2026-0001');
|
eventos:[{fecha:'2026-10-20 10:00',estado:'Solicitada',nombre:'Juan Obrero',correo:'juan@helgen.tech',nota:''}],
|
||||||
assert(!C.$('comModRenglones').classList.contains('oculto')&&!C.$('comForm').classList.contains('oculto')&&!C.$('comProveedor').classList.contains('oculto'));
|
proyecto:PRY,moneda:'MXN',marca:'helgen',orden:null,pagos:[],proveedores:[PROV],ivaTasas:[0.16,0.08,0],metodos:['Transferencia','Efectivo','Cheque'],hoy:'2026-10-20',
|
||||||
|
puedeAutorizar:true,puedeRevisar:true,puedePagar:false,puedeCancelarPago:false,puedeCerrar:false,...extra});
|
||||||
|
C.respuestas.getRequisicion={ok:true,datos:vistaReq()};
|
||||||
|
C.run("abrirReqCom('REQ-2026-0001')"); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='getRequisicion').args,['REQ-2026-0001']);
|
||||||
|
assert(C.$('comVistaLista').classList.contains('oculto')&&!C.$('comVistaReq').classList.contains('oculto'));
|
||||||
|
assert.equal(C.$('rcCotVisor').src,'https://drive.google.com/file/d/COT1/preview','la cotización a un lado');
|
||||||
|
assert(/Cable THW 10/.test(C.$('rcRenglones').innerHTML)&&!C.$('rcRevision').classList.contains('oculto')&&!C.$('comModOc').classList.contains('oculto')&&C.$('comModPago').classList.contains('oculto'));
|
||||||
|
// El formulario de la OC viene precargado con los renglones (notas en la descripción) y sin costo
|
||||||
|
const filasOc=C.$('ocFilas').hijos;
|
||||||
|
assert.deepEqual(filasOc.map(t=>[t.querySelector('.desc').value,t.querySelector('.cant').value,t.querySelector('.uni').value,t.querySelector('.costo').value]),
|
||||||
|
[['Cable THW 10 — rojo',100,'M',''],['Tubo',12,'PZA','']]);
|
||||||
assert.deepEqual([C.$('ocLugar').value,C.$('ocMoneda').value,C.$('ocIva').value,C.$('ocEntrega').min],['Qro','MXN','0.16','2026-10-20'],'por omisión: ubicación, moneda del proyecto, IVA');
|
assert.deepEqual([C.$('ocLugar').value,C.$('ocMoneda').value,C.$('ocIva').value,C.$('ocEntrega').min],['Qro','MXN','0.16','2026-10-20'],'por omisión: ubicación, moneda del proyecto, IVA');
|
||||||
assert(/Moneda del proyecto: <b>MXN<\/b>/.test(C.$('comInfo').innerHTML)&&/no tiene órdenes/.test(C.$('ocLista').innerHTML));
|
|
||||||
// Proveedor registrado: se autorrellena
|
// Proveedor registrado: se autorrellena
|
||||||
C.$('prvRazon').value='Paneles del Bajío'; C.$('prvRazon').disparar('change');
|
C.$('prvRazon').value='Paneles del Bajío'; C.$('prvRazon').disparar('change');
|
||||||
assert.deepEqual([C.$('prvRfc').value,C.$('prvContacto').value,C.$('prvCorreo').value],['PDB200101AB1','Luis Pérez','v@pdb.mx']);
|
assert.deepEqual([C.$('prvRfc').value,C.$('prvContacto').value,C.$('prvCorreo').value],['PDB200101AB1','Luis Pérez','v@pdb.mx']);
|
||||||
// Renglón del catálogo: clave, descripción y unidad; el costo queda vacío
|
// Renglón del catálogo: clave, descripción y unidad; el costo queda vacío
|
||||||
const ren=C.$('ocFilas').hijos[C.$('ocFilas').hijos.length-1];
|
C.run('agregarRenglonOc()');
|
||||||
|
const ren=filasOc[filasOc.length-1];
|
||||||
ren.querySelector('.desc').value='Panel solar 550 W'; ren.querySelector('.desc').disparar('change');
|
ren.querySelector('.desc').value='Panel solar 550 W'; ren.querySelector('.desc').disparar('change');
|
||||||
assert.deepEqual([ren.querySelector('.clave').value,ren.querySelector('.uni').value,ren.querySelector('.costo').value],['PAN-550','PZA',''],'sin precio de venta');
|
assert.deepEqual([ren.querySelector('.clave').value,ren.querySelector('.uni').value,ren.querySelector('.costo').value],['PAN-550','PZA',''],'sin precio de venta');
|
||||||
ren.querySelector('.cant').value='2'; ren.querySelector('.costo').value='1000'; C.run('recalcularOc()');
|
filasOc[0].querySelector('.costo').value='10'; filasOc[1].querySelector('.costo').value='50'; ren.querySelector('.cant').value='2'; ren.querySelector('.costo').value='1000'; C.run('recalcularOc()');
|
||||||
assert.equal(C.$('ocTot').textContent,'$2,320.00 MXN');
|
assert.equal(C.$('ocTot').textContent,'$4,176.00 MXN');
|
||||||
C.$('ocIva').value='0'; C.run('recalcularOc()');
|
C.$('ocIva').value='0'; C.run('recalcularOc()');
|
||||||
assert.deepEqual([C.$('ocTot').textContent,C.$('ocLblIva').textContent],['$2,000.00 MXN','IVA (0%)']);
|
assert.deepEqual([C.$('ocTot').textContent,C.$('ocLblIva').textContent],['$3,600.00 MXN','IVA (0%)']);
|
||||||
// Moneda distinta a la del proyecto: aparece el tipo de cambio
|
// Moneda distinta a la del proyecto: aparece el tipo de cambio
|
||||||
assert(C.$('ocTcCaja').classList.contains('oculto'));
|
assert(C.$('ocTcCaja').classList.contains('oculto'));
|
||||||
C.$('ocMoneda').value='USD'; C.run('pintarTcOc()');
|
C.$('ocMoneda').value='USD'; C.run('pintarTcOc()');
|
||||||
assert(!C.$('ocTcCaja').classList.contains('oculto')); assert.equal(C.$('ocLblTot').textContent,'TOTAL USD');
|
assert(!C.$('ocTcCaja').classList.contains('oculto')); assert.equal(C.$('ocLblTot').textContent,'TOTAL USD');
|
||||||
C.$('ocTc').value='18.5'; C.$('ocEntrega').value='2026-10-25'; C.$('ocCondiciones').value='Contado';
|
C.$('ocTc').value='18.5'; C.$('ocEntrega').value='2026-10-25'; C.$('ocCondiciones').value='Contado';
|
||||||
const ORD={folio:'OC-2026-0001',fecha:'2026-10-20',proveedor:'Paneles del Bajío',moneda:'USD',total:2000,tipoCambio:18.5,fechaEntrega:'2026-10-25',
|
const ORD={folio:'OC-2026-0001',fecha:'2026-10-20',proveedor:'Paneles del Bajío',moneda:'USD',total:3200,tipoCambio:18.5,fechaEntrega:'2026-10-25',
|
||||||
lugarEntrega:'Qro',estado:'Emitida',pdfUrl:'https://drive/oc'};
|
lugarEntrega:'Qro',condicionesPago:'Contado',estado:'Emitida',pdfUrl:'https://drive/oc',reqFolio:'REQ-2026-0001'};
|
||||||
C.respuestas.guardarOrdenCompra={ok:true,datos:{folio:'OC-2026-0001',url:'https://drive/oc',total:2000,moneda:'USD',vista:vistaCom({ordenes:[ORD]})}};
|
const autorizada=vistaReq({orden:ORD,puedeAutorizar:false,puedeRevisar:false,puedePagar:true},{estado:'Autorizada',ocFolio:'OC-2026-0001'});
|
||||||
|
C.respuestas.autorizarRequisicion={ok:true,datos:{folio:'REQ-2026-0001',ocFolio:'OC-2026-0001',url:'https://drive/oc',total:3200,moneda:'USD',vista:autorizada}};
|
||||||
const idOc=C.run('ID_OC'); C.run('guardarOc()'); await esperar();
|
const idOc=C.run('ID_OC'); C.run('guardarOc()'); await esperar();
|
||||||
const go=C.llamadas.find(c=>c.funcion==='guardarOrdenCompra').args[0];
|
const go=C.llamadas.find(c=>c.funcion==='autorizarRequisicion');
|
||||||
assert.deepEqual([go.proyectoFolio,go.proveedor.razonSocial,go.proveedor.rfc,go.moneda,go.tipoCambio,go.ivaTasa,go.fechaEntrega,go.lugarEntrega,go.condicionesPago,go.idSolicitud],
|
assert.equal(go.args[0],'REQ-2026-0001');
|
||||||
['PRY-2026-0001','Paneles del Bajío','PDB200101AB1','USD','18.5','0','2026-10-25','Qro','Contado',idOc]);
|
assert.deepEqual([go.args[1].proveedor.razonSocial,go.args[1].proveedor.rfc,go.args[1].moneda,go.args[1].tipoCambio,go.args[1].ivaTasa,go.args[1].fechaEntrega,
|
||||||
const rr=go.renglones.find(x=>x.descripcion==='Panel solar 550 W');
|
go.args[1].lugarEntrega,go.args[1].condicionesPago,go.args[1].idSolicitud,'proyectoFolio' in go.args[1]],
|
||||||
|
['Paneles del Bajío','PDB200101AB1','USD','18.5','0','2026-10-25','Qro','Contado',idOc,false],'el proyecto sale de la requisición en el servidor');
|
||||||
|
const rr=go.args[1].renglones.find(x=>x.descripcion==='Panel solar 550 W');
|
||||||
assert.deepEqual([rr.clave,rr.cantidad,rr.costo,'precio' in rr,'_tr' in rr],['PAN-550',2,'1000',false,false]);
|
assert.deepEqual([rr.clave,rr.cantidad,rr.costo,'precio' in rr,'_tr' in rr],['PAN-550',2,'1000',false,false]);
|
||||||
assert(/OC-2026-0001/.test(C.$('aviso').innerHTML)&&C.run('ID_OC')!==idOc,'id nuevo para la siguiente orden');
|
assert(/REQ-2026-0001/.test(C.$('aviso').innerHTML)&&/OC-2026-0001/.test(C.$('aviso').innerHTML));
|
||||||
assert.deepEqual([C.$('ocMoneda').value,C.$('ocTc').value,C.$('prvRazon').value],['MXN','',''],'se limpia el formulario');
|
assert(C.$('comModOc').classList.contains('oculto')&&!C.$('comModPago').classList.contains('oculto')&&!C.$('rcFormPago').classList.contains('oculto'));
|
||||||
assert(C.$('ocTcCaja').classList.contains('oculto'));
|
assert(/OC-2026-0001/.test(C.$('rcOrden').innerHTML)&&/\$3,200\.00 USD/.test(C.$('rcOrden').innerHTML)&&/cancelarOcDesdeReq/.test(C.$('rcOrden').innerHTML));
|
||||||
const li=C.$('ocLista').innerHTML;
|
assert.deepEqual([C.$('ppMonto').value,C.$('ppMoneda').value,C.$('ppFecha').max],[3200,'USD','2026-10-20'],'el pago se precarga con el total de la orden');
|
||||||
assert(/OC-2026-0001/.test(li)&&/\$2,000\.00 USD/.test(li)&&/TC 18\.5/.test(li)&&/25\/10\/2026/.test(li)&&/recibirOc/.test(li)&&/cancelarOc/.test(li));
|
// Pago: comprobante obligatorio; en otra moneda pide tipo de cambio
|
||||||
// Recibida: pide fecha; si se cancela el diálogo no llama
|
C.run('registrarPagoProv()'); await esperar();
|
||||||
C.dialogos.prompt=null; C.run("recibirOc('OC-2026-0001')"); await esperar();
|
assert(!C.llamadas.some(c=>c.funcion==='registrarPagoProveedor')&&/Adjunta el comprobante/.test(C.$('aviso').innerHTML));
|
||||||
assert(!C.llamadas.some(c=>c.funcion==='marcarOcRecibida'));
|
C.$('ppMoneda').value='MXN'; C.run('pintarTcPago()'); assert(!C.$('ppTcCaja').classList.contains('oculto'));
|
||||||
C.respuestas.marcarOcRecibida={ok:true,datos:vistaCom({ordenes:[{...ORD,estado:'Recibida',fechaRecepcion:'2026-10-20',recibio:'pedro@helgen.tech'}]})};
|
C.$('ppMonto').value='59200'; C.$('ppTc').value='18.5'; C.$('ppRef').value='SPEI 1';
|
||||||
C.dialogos.prompt=' 2026-10-20 '; C.run("recibirOc('OC-2026-0001')"); await esperar();
|
C.$('ppArchivo').files=[{name:'spei.pdf',type:'application/pdf',size:2000,b64:'JVBERi0='}];
|
||||||
assert.deepEqual(C.llamadas.find(c=>c.funcion==='marcarOcRecibida').args,['OC-2026-0001','2026-10-20']);
|
const pagada=vistaReq({orden:{...ORD,estado:'Pagada'},pagos:[{folio:'PGP-2026-0001',fecha:'2026-10-20',monto:59200,monedaPago:'MXN',tipoCambio:18.5,montoAplicado:3200,monedaOc:'USD',
|
||||||
assert(/chip firmado/.test(C.$('ocLista').innerHTML)&&!/recibirOc|cancelarOc/.test(C.$('ocLista').innerHTML),'recibida: sin acciones');
|
metodo:'Transferencia',referencia:'SPEI 1',estado:'Vigente',comprobanteUrl:'https://drive/pgp'}],puedeAutorizar:false,puedeRevisar:false,puedeCancelarPago:true,puedeCerrar:true},{estado:'Pagada',ocFolio:'OC-2026-0001'});
|
||||||
// Cancelar: motivo obligatorio; permiso revocado → error del servidor
|
C.respuestas.registrarPagoProveedor={ok:true,datos:{folio:'PGP-2026-0001',url:'https://drive/pgp',vista:pagada}};
|
||||||
C.dialogos.prompt=' '; C.run("cancelarOc('OC-2026-0001')"); await esperar();
|
const idPgp=C.run('ID_PGP'); C.run('registrarPagoProv()'); await esperar(20);
|
||||||
assert(!C.llamadas.some(c=>c.funcion==='cancelarOrdenCompra'));
|
const rpp=C.llamadas.find(c=>c.funcion==='registrarPagoProveedor').args[0];
|
||||||
C.respuestas.cancelarOrdenCompra={ok:false,codigo:'PERMISO',error:'No tienes permiso de compras.'};
|
assert.deepEqual([rpp.reqFolio,rpp.monto,rpp.monedaPago,rpp.tipoCambio,rpp.metodo,rpp.referencia,rpp.comprobante.datos,rpp.idSolicitud],
|
||||||
C.dialogos.prompt='Sin existencias'; C.run("cancelarOc('OC-2026-0001')"); await esperar();
|
['REQ-2026-0001','59200','MXN','18.5','Transferencia','SPEI 1','JVBERi0=',idPgp]);
|
||||||
assert.deepEqual(C.llamadas.find(c=>c.funcion==='cancelarOrdenCompra').args,['OC-2026-0001','Sin existencias']);
|
assert(/PGP-2026-0001/.test(C.$('rcPagos').innerHTML)&&/cancelarPagoProv/.test(C.$('rcPagos').innerHTML)&&C.$('rcFormPago').classList.contains('oculto')&&!C.$('rcCierre').classList.contains('oculto'));
|
||||||
|
assert(!/cancelarOcDesdeReq/.test(C.$('rcOrden').innerHTML),'una orden pagada ya no se cancela');
|
||||||
|
// Cancelar el pago: motivo obligatorio
|
||||||
|
C.dialogos.prompt=' '; C.run("cancelarPagoProv('PGP-2026-0001')"); await esperar();
|
||||||
|
assert(!C.llamadas.some(c=>c.funcion==='cancelarPagoProveedor'));
|
||||||
|
C.respuestas.cancelarPagoProveedor={ok:true,datos:autorizada};
|
||||||
|
C.dialogos.prompt='Tipo de cambio equivocado'; C.run("cancelarPagoProv('PGP-2026-0001')"); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='cancelarPagoProveedor').args,['PGP-2026-0001','Tipo de cambio equivocado']);
|
||||||
|
// Cerrar en lugar del solicitante: motivo obligatorio
|
||||||
|
C.run('pintarReqCom('+JSON.stringify(pagada)+')');
|
||||||
|
C.$('rcMotivoCierre').value=''; C.run('cerrarReqCom()'); await esperar();
|
||||||
|
assert(!C.llamadas.some(c=>c.funcion==='cerrarRequisicionCompras')&&/Escribe por qué cierras/.test(C.$('aviso').innerHTML));
|
||||||
|
C.respuestas.cerrarRequisicionCompras={ok:true,datos:vistaReq({orden:{...ORD,estado:'Recibida',fechaRecepcion:'2026-10-20',recibio:'pedro@helgen.tech'},puedeAutorizar:false,puedeRevisar:false},{estado:'Cerrada'})};
|
||||||
|
C.$('rcMotivoCierre').value='Ya no está en la obra'; C.$('rcNotasCierre').value='Completo'; C.run('cerrarReqCom()'); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='cerrarRequisicionCompras').args,['REQ-2026-0001','Ya no está en la obra','Completo']);
|
||||||
|
assert(C.$('rcCierre').classList.contains('oculto')&&/Recibida el 20\/10\/2026/.test(C.$('rcOrden').innerHTML));
|
||||||
|
// Devolver: motivo obligatorio; permiso revocado → error del servidor
|
||||||
|
C.run('pintarReqCom('+JSON.stringify(vistaReq())+')');
|
||||||
|
C.dialogos.prompt=null; C.run("revisarReqCom('devolverRequisicion')"); await esperar();
|
||||||
|
assert(!C.llamadas.some(c=>c.funcion==='devolverRequisicion'));
|
||||||
|
C.respuestas.devolverRequisicion={ok:false,codigo:'PERMISO',error:'No tienes permiso de compras.'};
|
||||||
|
C.dialogos.prompt='Falta el calibre'; C.run("revisarReqCom('devolverRequisicion')"); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='devolverRequisicion').args,['REQ-2026-0001','Falta el calibre']);
|
||||||
assert(/No tienes permiso de compras/.test(C.$('aviso').innerHTML));
|
assert(/No tienes permiso de compras/.test(C.$('aviso').innerHTML));
|
||||||
// Proyecto que ya no admite órdenes (p. ej. contrato desbloqueado): solo la lista
|
// Órdenes por proyecto: con su requisición; sin "Recibida" (se recibe al cerrar la requisición); cancelar solo emitidas
|
||||||
C.run('pintarCompras('+JSON.stringify(vistaCom({proyecto:{...PRY,folio:'PRY-2026-0002'},puedeEmitir:false,moneda:''}))+')');
|
C.respuestas.getComprasProyecto={ok:true,datos:{proyecto:PRY,moneda:'MXN',marca:'helgen',ordenes:[ORD,{...ORD,folio:'OC-2026-0002',estado:'Pagada'}],hoy:'2026-10-20'}};
|
||||||
assert(C.$('comModRenglones').classList.contains('oculto')&&C.$('comForm').classList.contains('oculto')&&/no admite órdenes nuevas/.test(C.$('comNoEmite').textContent));
|
C.$('comProyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; C.$('comProyecto').disparar('change'); await esperar();
|
||||||
|
const li=C.$('ocLista').innerHTML;
|
||||||
|
assert(/OC-2026-0001/.test(li)&&/REQ-2026-0001/.test(li)&&/\$3,200\.00 USD/.test(li)&&/TC 18\.5/.test(li)&&/25\/10\/2026/.test(li)&&/chip firmado">Pagada/.test(li));
|
||||||
|
assert(!/recibirOc|marcarOcRecibida/.test(li)&&(li.match(/cancelarOc\(/g)||[]).length===1,'sin botón Recibida; solo la emitida se cancela');
|
||||||
|
assert(!/guardarOrdenCompra|marcarOcRecibida|function recibirOc/.test(html),'ya no hay emisión directa ni "Recibida" desde compras');
|
||||||
|
C.dialogos.prompt='Sin existencias'; C.respuestas.cancelarOrdenCompra={ok:true,datos:{proyecto:PRY,moneda:'MXN',marca:'helgen',ordenes:[{...ORD,estado:'Cancelada'}],hoy:'2026-10-20'}};
|
||||||
|
C.run("cancelarOc('OC-2026-0001')"); await esperar();
|
||||||
|
assert.deepEqual(C.llamadas.find(c=>c.funcion==='cancelarOrdenCompra').args,['OC-2026-0001','Sin existencias']);
|
||||||
|
assert(/fila-cancelada/.test(C.$('ocLista').innerHTML));
|
||||||
|
|
||||||
|
// ===== Requisiciones: quien pide materiales (ROL_REQUISICION, sin otros roles) =====
|
||||||
|
const RQS=[{folio:'REQ-2026-0002',fecha:'2026-10-20',proyectoFolio:'PRY-2026-0001',proyectoNombre:'Techo',estado:'Devuelta',motivo:'Falta el calibre',notasRecepcion:'',
|
||||||
|
renglones:[{renglon:1,descripcion:'Tubo',cantidad:12,unidad:'PZA',notas:''}],eventos:[{fecha:'2026-10-20 10:00',estado:'Solicitada',nombre:'Juan',nota:''},
|
||||||
|
{fecha:'2026-10-20 11:00',estado:'Devuelta',nombre:'Pedro Ruiz',nota:'Falta el calibre'}],puedeEditar:true,puedeCancelar:true,puedeCerrar:false},
|
||||||
|
{folio:'REQ-2026-0001',fecha:'2026-10-19',proyectoFolio:'PRY-2026-0001',proyectoNombre:'Techo',estado:'Pagada',motivo:'',notasRecepcion:'',
|
||||||
|
renglones:[{renglon:1,descripcion:'Cable',cantidad:100,unidad:'M',notas:'rojo'}],eventos:[{fecha:'2026-10-20 12:00',estado:'Pagada',nombre:'Pedro Ruiz',nota:''}],
|
||||||
|
puedeEditar:false,puedeCancelar:false,puedeCerrar:true}];
|
||||||
|
const R=montar({ivaTasa:0.16,catalogo:CAT,tipoCambio:TC,clientes:[],proyectos:[],marcas:{},permisos:{cobranza:false,desbloqueo:false,compras:false,requisicion:true}});
|
||||||
|
R.respuestas.listarProyectosRequisicion={ok:true,datos:[{folio:'PRY-2026-0001',nombre:'Techo'}]};
|
||||||
|
R.respuestas.misRequisiciones={ok:true,datos:RQS};
|
||||||
|
await esperar(5);
|
||||||
|
assert(!R.$('tabReq').classList.contains('oculto')&&R.$('tabCom').classList.contains('oculto')&&!R.$('tabNueva').classList.contains('oculto'),'con ROL_REQUISICION usa el cotizador y ve Requisiciones');
|
||||||
|
assert(/Panel solar 550 W/.test(R.$('reqCatalogo').innerHTML)&&!/4,100|4100|222/.test(R.$('reqCatalogo').innerHTML),'sugerencias sin precio');
|
||||||
|
R.run("verTab('req')"); await esperar();
|
||||||
|
assert.equal(R.$('reqProyectosList').innerHTML,'<option value="PRY-2026-0001 — Techo">','solo folio y nombre');
|
||||||
|
const lista=R.$('reqLista').innerHTML;
|
||||||
|
assert(/REQ-2026-0002/.test(lista)&&/Motivo:<\/b> Falta el calibre/.test(lista)&&/editarReq\('REQ-2026-0002'\)/.test(lista)&&/cancelarReq\('REQ-2026-0002'\)/.test(lista));
|
||||||
|
assert(/recibiMaterial\('REQ-2026-0001'\)/.test(lista)&&!/recibiMaterial\('REQ-2026-0002'\)/.test(lista)&&/Pedro Ruiz/.test(lista),'"Recibí el material" solo en la pagada; historial con quién');
|
||||||
|
// Nueva: proyecto, renglones y cotización obligatorios
|
||||||
|
const filasR=R.$('reqFilas').hijos;
|
||||||
|
assert.equal(filasR.length,1,'un renglón vacío al entrar');
|
||||||
|
filasR[0].querySelector('.desc').value='Panel solar 550 W'; filasR[0].querySelector('.desc').disparar('change');
|
||||||
|
assert.equal(filasR[0].querySelector('.uni').value,'PZA','del catálogo solo la unidad');
|
||||||
|
filasR[0].querySelector('.cant').value='10'; filasR[0].querySelector('.notas').value='Monocristalino';
|
||||||
|
R.run('enviarRequisicion()'); await esperar();
|
||||||
|
assert(/Elige un proyecto/.test(R.$('aviso').innerHTML));
|
||||||
|
R.$('reqProyecto').value='PRY-2026-0001 — Techo';
|
||||||
|
R.run('enviarRequisicion()'); await esperar();
|
||||||
|
assert(/Adjunta la cotización/.test(R.$('aviso').innerHTML)&&!R.llamadas.some(c=>c.funcion==='guardarRequisicion'));
|
||||||
|
R.$('reqArchivo').files=[{name:'cot.jpg',type:'image/jpeg',size:11*1024*1024,b64:'/9j/'}];
|
||||||
|
R.run('enviarRequisicion()'); await esperar();
|
||||||
|
assert(/pesa más de 10 MB/.test(R.$('aviso').innerHTML)&&!R.llamadas.some(c=>c.funcion==='guardarRequisicion'));
|
||||||
|
R.$('reqArchivo').files=[{name:'cot.jpg',type:'image/jpeg',size:2000,b64:'/9j/'}];
|
||||||
|
R.respuestas.guardarRequisicion=c=>({ok:true,datos:{folio:c.args[0].folio||'REQ-2026-0003',requisiciones:RQS}});
|
||||||
|
const idReq=R.run('ID_REQ'); R.run('enviarRequisicion()'); await esperar(20);
|
||||||
|
let gr=R.llamadas.filter(c=>c.funcion==='guardarRequisicion')[0].args[0];
|
||||||
|
assert.deepEqual([gr.folio,gr.proyectoFolio,gr.renglones,gr.cotizacion.datos,gr.idSolicitud],
|
||||||
|
['','PRY-2026-0001',[{descripcion:'Panel solar 550 W',cantidad:'10',unidad:'PZA',notas:'Monocristalino'}],'/9j/',idReq]);
|
||||||
|
assert(!('precio' in gr.renglones[0])&&!('costo' in gr.renglones[0]),'sin precios');
|
||||||
|
assert(/REQ-2026-0003<\/b> enviada/.test(R.$('aviso').innerHTML)&&R.run('ID_REQ')!==idReq);
|
||||||
|
// Corregir una devuelta: mismo folio, sin cambiar de proyecto; la cotización nueva es opcional
|
||||||
|
R.run("editarReq('REQ-2026-0002')");
|
||||||
|
assert.deepEqual([R.$('reqProyecto').disabled,R.$('reqTitulo').textContent,R.$('btnReq').textContent],[true,'1 · Corregir REQ-2026-0002','Reenviar requisición']);
|
||||||
|
assert(/Falta el calibre/.test(R.$('reqEditando').innerHTML));
|
||||||
|
const fr=R.$('reqFilas').hijos; fr[fr.length-1].querySelector('.desc').value='Tubo conduit cédula 40';
|
||||||
|
R.$('reqArchivo').files=[];
|
||||||
|
R.run('enviarRequisicion()'); await esperar(20);
|
||||||
|
gr=R.llamadas.filter(c=>c.funcion==='guardarRequisicion')[1].args[0];
|
||||||
|
assert.deepEqual([gr.folio,gr.cotizacion,gr.renglones.map(x=>x.descripcion)],['REQ-2026-0002',null,['Tubo conduit cédula 40']]);
|
||||||
|
assert(/corregida y reenviada/.test(R.$('aviso').innerHTML)&&!R.$('reqProyecto').disabled);
|
||||||
|
// Recibí el material (notas opcionales); cancelar pide motivo
|
||||||
|
R.respuestas.cerrarRequisicion={ok:true,datos:RQS};
|
||||||
|
R.dialogos.prompt=null; R.run("recibiMaterial('REQ-2026-0001')"); await esperar();
|
||||||
|
assert(!R.llamadas.some(c=>c.funcion==='cerrarRequisicion'),'si cancela el diálogo no cierra');
|
||||||
|
R.dialogos.prompt='Faltó 1 pieza'; R.run("recibiMaterial('REQ-2026-0001')"); await esperar();
|
||||||
|
assert.deepEqual(R.llamadas.find(c=>c.funcion==='cerrarRequisicion').args,['REQ-2026-0001','Faltó 1 pieza']);
|
||||||
|
R.dialogos.prompt=' '; R.run("cancelarReq('REQ-2026-0002')"); await esperar();
|
||||||
|
assert(!R.llamadas.some(c=>c.funcion==='cancelarRequisicion'));
|
||||||
|
|
||||||
|
// ===== SOLO_REQUISICION: solo la pestaña Requisiciones =====
|
||||||
|
const S=montar({soloRequisicion:true,ivaTasa:0.16,catalogo:[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA'}],tipoCambio:null,clientes:[],proyectos:[],
|
||||||
|
marcas:{},permisos:{cobranza:false,desbloqueo:false,compras:false,requisicion:true}});
|
||||||
|
S.respuestas.listarProyectosRequisicion={ok:true,datos:[]}; S.respuestas.misRequisiciones={ok:true,datos:[]};
|
||||||
|
await esperar(10);
|
||||||
|
for(const t of ['tabNueva','tabHist','tabCat','tabCob','tabCom']) assert(S.$(t).classList.contains('oculto'),'oculta '+t);
|
||||||
|
assert(!S.$('tabReq').classList.contains('oculto')&&!S.$('vistaReq').classList.contains('oculto')&&S.$('vistaNueva').classList.contains('oculto'));
|
||||||
|
assert(S.llamadas.some(c=>c.funcion==='misRequisiciones')&&/Todavía no tienes requisiciones/.test(S.$('reqLista').innerHTML));
|
||||||
|
assert(!S.llamadas.some(c=>['listarCotizaciones','getCatalogo','listarProyectos'].includes(c.funcion)),'no llama nada más');
|
||||||
console.log('OK interfaz');
|
console.log('OK interfaz');
|
||||||
})().catch(e=>{console.error(e);process.exit(1)});
|
})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
|||||||
379
tests/requisiciones.test.js
Normal file
379
tests/requisiciones.test.js
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
// Requisiciones de materiales y pago a proveedores: el solicitante (ROL_REQUISICION / SOLO_REQUISICION) solo ve las suyas y
|
||||||
|
// nunca montos, cliente, margen, pagos ni órdenes; compras (ROL_COMPRAS) autoriza (genera la OC), rechaza, devuelve, paga y
|
||||||
|
// puede cerrar con motivo. Estados válidos e inválidos, folios REQ/PGP, conversión a OC, pago (comprobante, monto = total de la
|
||||||
|
// OC, tipo de cambio), cancelar el pago y cierre. Todo por doPost con tokens simulados (mismos simulacros que compras.test.js).
|
||||||
|
// Uso: node tests/requisiciones.test.js . (carpeta con Code.js y Logo.js)
|
||||||
|
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
||||||
|
const dir=process.argv[2];
|
||||||
|
let src=fs.readFileSync(path.join(dir,'Logo.js'),'utf8')+'\n'+fs.readFileSync(path.join(dir,'Code.js'),'utf8');
|
||||||
|
const CID='123-abc.apps.googleusercontent.com';
|
||||||
|
src=src.replace(/const CLIENT_ID_GOOGLE = '[^']*';/,"const CLIENT_ID_GOOGLE = '"+CID+"';");
|
||||||
|
const ANIO=String(new Date().getFullYear());
|
||||||
|
const pad=n=>String(n).padStart(2,'0');
|
||||||
|
const HOY=new Date(), HOY_ISO=HOY.getFullYear()+'-'+pad(HOY.getMonth()+1)+'-'+pad(HOY.getDate());
|
||||||
|
|
||||||
|
// ---- Hojas y libros simulados ----
|
||||||
|
// Como Sheets: un texto que parece fecha ("2026-10-20", "2026-10-20 17:30") se guarda como fecha; con apóstrofo
|
||||||
|
// inicial se guarda como texto (y getValues lo devuelve sin el apóstrofo).
|
||||||
|
const comoSheets=v=>{if(typeof v!=='string')return v;if(v[0]==="'")return v.slice(1);
|
||||||
|
const x=v.match(/^(\d{4})-(\d\d)-(\d\d)(?: (\d\d):(\d\d)(?::(\d\d))?)?$/);
|
||||||
|
return x?new Date(+x[1],+x[2]-1,+x[3],+(x[4]||0),+(x[5]||0),+(x[6]||0)):v};
|
||||||
|
function hoja(m){const h={m,getLastRow:()=>m.length,getMaxRows:()=>1000,setFrozenRows(){},setColumnWidth(){},
|
||||||
|
getDataRange:()=>({getValues:()=>m.map(r=>r.slice()),getDisplayValues:()=>m.map(r=>r.map(x=>x instanceof Date?'20/10/2026':String(x??'')))}),
|
||||||
|
appendRow:a=>m.push(a.map(comoSheets)),deleteRow:n=>m.splice(n-1,1),
|
||||||
|
getRange:(r,c=1,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}}:({
|
||||||
|
getValues:()=>Array.from({length:nr},(_,i)=>{const f=(m[r-1+i]||[]).slice(c-1,c-1+nc);while(f.length<nc)f.push('');return f}),
|
||||||
|
setValue(v){m[r-1][c-1]=comoSheets(v);return this},
|
||||||
|
setValues(vs){vs.forEach((x,i)=>{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=comoSheets(y))});return this},
|
||||||
|
setNumberFormat(){return this},setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this}})};
|
||||||
|
return h}
|
||||||
|
const libros={};
|
||||||
|
function libro(id,nombre,pestanas){const L={id,nombre,pest:pestanas||{},getId:()=>id,getUrl:()=>'https://sheets/'+id,
|
||||||
|
getSheetByName:n=>L.pest[n]||null,getSheets:()=>Object.values(L.pest),
|
||||||
|
insertSheet:n=>{if(L.pest[n])throw new Error('ya existe');return L.pest[n]=hoja([])},
|
||||||
|
deleteSheet:h=>{for(const k in L.pest)if(L.pest[k]===h)delete L.pest[k]}};
|
||||||
|
libros[id]=L;return L}
|
||||||
|
|
||||||
|
// ---- Drive simulado ----
|
||||||
|
let nId=0; const carpetas={}, archivos={};
|
||||||
|
// Como Workspace de helgen.tech: todo lo nuevo nace compartido con el dominio (acceso 'DOMAIN')
|
||||||
|
// DriveApp.setSharing da "Access denied" en helgen.tech: el código usa la API de Drive (permissions) con el token del script
|
||||||
|
const compartible=o=>Object.assign(o,{acceso:'DOMAIN',setSharing:()=>{throw new Error('Access denied: DriveApp.')}});
|
||||||
|
function apiDrive(url,op){const m=url.match(/drive\/v3\/files\/([^/?]+)\/permissions(?:\/([^?]+))?/);
|
||||||
|
const it=carpetas[decodeURIComponent(m[1])]||archivos[decodeURIComponent(m[1])];
|
||||||
|
assert(op&&op.headers&&op.headers.Authorization==='Bearer TOKEN-SCRIPT','API de Drive con el token del script');
|
||||||
|
const resp=(c,b)=>({getResponseCode:()=>c,getContentText:()=>JSON.stringify(b||{})});
|
||||||
|
if(!it)return resp(404);
|
||||||
|
// Como Drive: el acceso del dominio se hereda de las carpetas y no se puede quitar en el hijo mientras el padre lo tenga
|
||||||
|
let heredado=false;for(let f=it.padre;f;f=f.padre)if(f.acceso==='DOMAIN')heredado=true;
|
||||||
|
if(op.method==='delete'){assert.equal(m[2],'dom');if(heredado)return resp(403,{error:{reason:'cannotDeletePermission'}});it.acceso='PRIVATE';return resp(204)}
|
||||||
|
return resp(200,{permissions:[{id:'own',type:'user'}].concat(it.acceso==='DOMAIN'||heredado?[{id:'dom',type:'domain'}]:[])})}
|
||||||
|
function carpeta(nombre,padre){const f=compartible({id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false}); Object.assign(f,{
|
||||||
|
getId:()=>f.id,getName:()=>f.nombre,getUrl:()=>'https://drive/'+f.id,isTrashed:()=>f.trashed,
|
||||||
|
getFoldersByName:n=>{const l=f.hijos.filter(h=>h.nombre===n);let i=0;return{hasNext:()=>i<l.length,next:()=>l[i++]}},
|
||||||
|
createFolder:n=>{const h=carpeta(n,f);f.hijos.push(h);return h},
|
||||||
|
createFile:b=>{const id='A'+(++nId);const a=compartible({id,blob:b,padre:f});archivos[id]=a;f.archivos.push(a);return Object.assign(a,{getId:()=>id,getUrl:()=>'https://drive/file/'+id})}});
|
||||||
|
carpetas[f.id]=f;return f}
|
||||||
|
const miUnidad=carpeta('Mi unidad',null), maestra=miUnidad.createFolder('Cotizador Helgen'), pdfRaiz=maestra.createFolder('Cotizaciones Helgen (PDF)');
|
||||||
|
miUnidad.acceso='PRIVATE'; // la raíz de Mi unidad no se comparte; la carpeta maestra sí (todo el dominio, §6.11)
|
||||||
|
const ruta=f=>f.padre?ruta(f.padre)+'/'+f.nombre:f.nombre;
|
||||||
|
archivos.DB={id:'DB',padre:maestra};
|
||||||
|
|
||||||
|
const db=libro('DB','Base de datos',{Config:hoja([['Clave','Valor'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16]]),Cotizaciones:hoja([])});
|
||||||
|
libro('CAT','Catálogo',{Catalogo:hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT'],['PAN-550','Panel solar 550 W','PZA',4100,'','','','']])});
|
||||||
|
const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT',
|
||||||
|
ROL_COBRANZA:'efrain@helgen.tech, tania@helgen.tech',ADMIN_DESBLOQUEO:'efrain@helgen.tech',
|
||||||
|
ROL_COMPRAS:'efrain@helgen.tech, pedro@helgen.tech',ROL_REQUISICION:'obrero@helgen.tech, rosa@helgen.tech, pedro@helgen.tech',
|
||||||
|
SOLO_REQUISICION:'luis@helgen.tech, rosa@helgen.tech'},cache={};
|
||||||
|
|
||||||
|
// ---- Tokens: efrain (cobranza + compras + admin), tania (cobranza), pedro (compras + requisiciones), ana (vendedora sin rol),
|
||||||
|
// obrero (ROL_REQUISICION), rosa y luis (SOLO_REQUISICION: solo la pestaña Requisiciones; luis no está en ROL_REQUISICION) ----
|
||||||
|
const ahora=Math.floor(Date.now()/1000);
|
||||||
|
const claims=(email,n,a)=>({aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email,email_verified:'true',hd:'helgen.tech',given_name:n,family_name:a});
|
||||||
|
const tokens={efrain:claims('efrain@helgen.tech','Efrain','Sosa'),tania:claims('tania@helgen.tech','Tania','R'),
|
||||||
|
ana:claims('ana@helgen.tech','Ana','López'),obrero:claims('obrero@helgen.tech','Juan','Obrero'),rosa:claims('rosa@helgen.tech','Rosa','Mena'),luis:claims('luis@helgen.tech','Luis','Campo'),pedro:claims('pedro@helgen.tech','Pedro','Ruiz'),intruso:{...claims('intruso@gmail.com','X','Y'),hd:undefined}};
|
||||||
|
|
||||||
|
const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMonth()+1)).replace('dd',pad(d.getDate()))
|
||||||
|
.replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
|
||||||
|
const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo
|
||||||
|
const ctx={console:{...console,warn(){}},Date,JSON,
|
||||||
|
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})},
|
||||||
|
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})},
|
||||||
|
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
|
||||||
|
Session:{getActiveUser:()=>({getEmail:()=>''})},
|
||||||
|
Logger:{log(){}},
|
||||||
|
Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c},
|
||||||
|
base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')),
|
||||||
|
DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]},
|
||||||
|
ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'},
|
||||||
|
UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t];
|
||||||
|
return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
|
||||||
|
ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})},
|
||||||
|
SpreadsheetApp:{flush(){},openById:id=>{if(!libros[id])throw new Error('No encontrado');return libros[id]},
|
||||||
|
create:n=>{const L=libro('S'+(++nId),n,{'Hoja 1':hoja([])});archivos[L.id]=compartible({id:L.id,padre:miUnidad});return L},
|
||||||
|
newDataValidation:()=>{const b={requireValueInList:()=>b,setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}},
|
||||||
|
DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]},
|
||||||
|
createFolder:n=>miUnidad.createFolder(n),getRootFolder:()=>miUnidad,
|
||||||
|
getFileById:id=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return archivos[id].padre}}},
|
||||||
|
moveTo:f=>{archivos[id].padre=f},setSharing:()=>archivos[id].setSharing(),getId:()=>id,getUrl:()=>'https://drive/file/'+id}),
|
||||||
|
Access:{PRIVATE:'PRIVATE',DOMAIN:'DOMAIN'},Permission:{NONE:'NONE',VIEW:'VIEW'}},
|
||||||
|
};
|
||||||
|
vm.createContext(ctx); vm.runInContext(src,ctx);
|
||||||
|
const post=(quien,funcion,...args)=>JSON.parse(ctx.doPost({postData:{contents:JSON.stringify({funcion,args,token:quien})}}).t);
|
||||||
|
const ok=(quien,funcion,...args)=>{const r=post(quien,funcion,...args);assert(r.ok,funcion+': '+r.error);return r.datos};
|
||||||
|
const falla=(quien,funcion,args,re,codigo)=>{const r=post(quien,funcion,...args);assert.equal(r.ok,false,funcion+' debía fallar');
|
||||||
|
assert(re.test(r.error),funcion+': '+r.error);if(codigo)assert.equal(r.codigo,codigo,funcion+' codigo');return r};
|
||||||
|
|
||||||
|
const MANANA=(()=>{const d=new Date(HOY.getTime()+86400000);return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())})();
|
||||||
|
const AYER=(()=>{const d=new Date(HOY.getTime()-86400000);return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())})();
|
||||||
|
function vivos(f){return f.hijos.filter(h=>!h.trashed)}
|
||||||
|
|
||||||
|
|
||||||
|
const PDF=Buffer.from('%PDF-1.4 cotizacion del proveedor').toString('base64');
|
||||||
|
const JPG=Buffer.from([0xFF,0xD8,0xFF,0xE0,1,2,3]).toString('base64');
|
||||||
|
const REQ=f=>'REQ-'+ANIO+'-'+f, OC=f=>'OC-'+ANIO+'-'+f, PGP=f=>'PGP-'+ANIO+'-'+f;
|
||||||
|
|
||||||
|
// ================= Lógica pura =================
|
||||||
|
const P=ctx;
|
||||||
|
const rl=P.normalizarRequisicion({renglones:[{descripcion:' Cable THW 10 ',cantidad:'25.5',unidad:'m',notas:' rojo '},{descripcion:'',cantidad:3}]});
|
||||||
|
assert.deepEqual(rl.map(r=>({...r})),[{descripcion:'Cable THW 10',cantidad:25.5,unidad:'M',notas:'rojo'}],'limpia y quita renglones vacíos');
|
||||||
|
assert.equal(P.normalizarRequisicion({renglones:[{descripcion:'Tornillo',cantidad:1}]})[0].unidad,'PZA','unidad por omisión');
|
||||||
|
for(const [mal,re] of [[[],/al menos un renglón/],[[{descripcion:'A',cantidad:0}],/cantidad de "A" debe ser mayor a 0/],
|
||||||
|
[[{descripcion:'A',cantidad:-1}],/mayor a 0/],[[{descripcion:'A',cantidad:''}],/mayor a 0/],[[{descripcion:'A',cantidad:'x'}],/mayor a 0/],
|
||||||
|
[[{descripcion:'A'.repeat(501),cantidad:1}],/500 caracteres/],[[{descripcion:'A',cantidad:1,notas:'n'.repeat(301)}],/300 caracteres/],
|
||||||
|
[Array.from({length:101},()=>({descripcion:'A',cantidad:1})),/máximo 100 renglones/]]){
|
||||||
|
assert.throws(()=>P.normalizarRequisicion({renglones:mal}),re,JSON.stringify(mal).slice(0,60));
|
||||||
|
}
|
||||||
|
// Estados: todas las transiciones válidas y algunas inválidas
|
||||||
|
const validas={autorizar:['Solicitada','Autorizada'],rechazar:['Solicitada','Rechazada'],devolver:['Solicitada','Devuelta'],
|
||||||
|
reenviar:['Devuelta','Solicitada'],cancelar:['Solicitada','Cancelada'],pagar:['Autorizada','Pagada'],cancelarPago:['Pagada','Autorizada'],
|
||||||
|
cerrar:['Pagada','Cerrada'],cancelarOc:['Autorizada','Solicitada']};
|
||||||
|
for(const [acc,[de,a]] of Object.entries(validas)) assert.equal(P.transicionReq(de,acc),a,acc);
|
||||||
|
assert.equal(P.transicionReq('Devuelta','cancelar'),'Cancelada','también se cancela una devuelta');
|
||||||
|
const ESTADOS=['Solicitada','Autorizada','Pagada','Cerrada','Rechazada','Devuelta','Cancelada'];
|
||||||
|
for(const acc of Object.keys(validas)) for(const e of ESTADOS){
|
||||||
|
let paso=true; try{P.transicionReq(e,acc)}catch(x){paso=false;assert(/no se puede/.test(x.message),x.message)}
|
||||||
|
const esperado=e===validas[acc][0]||(acc==='cancelar'&&e==='Devuelta');
|
||||||
|
assert.equal(paso,esperado,acc+' desde '+e);
|
||||||
|
}
|
||||||
|
for(const e of ['Cerrada','Rechazada','Cancelada']) for(const acc of Object.keys(validas)) assert.throws(()=>P.transicionReq(e,acc),/no se puede/,'final: '+e);
|
||||||
|
assert.throws(()=>P.transicionReq('Solicitada','borrar'),/Acción no válida/);
|
||||||
|
// Pago a proveedor: total de la OC ±1.00, tipo de cambio si la moneda es otra
|
||||||
|
const ORDEN={moneda:'MXN',total:2320,fechaOrden:'2026-10-20',hoy:'2026-10-22'};
|
||||||
|
const pp=P.normalizarPagoProveedor({fecha:'2026-10-21',monto:'2320.9',metodo:'transferencia',referencia:' SPEI 9 '},ORDEN);
|
||||||
|
assert.deepEqual([pp.fecha,pp.monto,pp.monedaPago,pp.tipoCambio,pp.montoAplicado,pp.metodo,pp.referencia],['2026-10-21',2320.9,'MXN','',2320.9,'Transferencia','SPEI 9']);
|
||||||
|
assert.equal(P.normalizarPagoProveedor({fecha:'2026-10-21',monto:2319,metodo:'Efectivo'},ORDEN).referencia,'','efectivo sin referencia');
|
||||||
|
assert.equal(P.normalizarPagoProveedor({fecha:'2026-10-21',monto:125.41,monedaPago:'USD',tipoCambio:18.5,metodo:'Cheque',referencia:'1'},ORDEN).montoAplicado,2320.09);
|
||||||
|
for(const [mal,re] of [[{monto:2321.01},/debe ser igual al total de la orden \(\$2,320\.00 MXN\)/],[{monto:2318.99},/igual al total/],
|
||||||
|
[{monto:0},/mayor a 0/],[{monedaPago:'USD',monto:125.41},/tipo de cambio/],[{metodo:'Bitcoin'},/método de pago/],
|
||||||
|
[{referencia:''},/referencia/],[{fecha:'2026-10-23'},/no puede ser futura/],[{fecha:'2026-10-19'},/anterior a la orden/],[{fecha:''},/obligatoria/]]){
|
||||||
|
assert.throws(()=>P.normalizarPagoProveedor({fecha:'2026-10-21',monto:2320,metodo:'Transferencia',referencia:'R',...mal},ORDEN),re,JSON.stringify(mal));
|
||||||
|
}
|
||||||
|
assert.deepEqual([P.nombreArchivoReq(REQ('0001'),'Cotización','pdf',1),P.nombreArchivoReq(REQ('0001'),'Comprobante pago','jpg',2)],
|
||||||
|
[REQ('0001')+' — Cotización.pdf',REQ('0001')+' — Comprobante pago (2).jpg']);
|
||||||
|
assert.throws(()=>P.validarArchivo([],'la cotización del proveedor','La cotización'),/Adjunta la cotización del proveedor/);
|
||||||
|
assert.throws(()=>P.validarArchivo([1,2,3,4,5,6,7,8,9,10,11,12],'la cotización del proveedor','La cotización'),/La cotización debe ser PDF o imagen/);
|
||||||
|
assert.throws(()=>P.validarComprobante([]),/Adjunta el comprobante de pago/,'el mensaje de cobranza no cambia');
|
||||||
|
|
||||||
|
// ================= Servidor (doPost) =================
|
||||||
|
const proy=(nombre,id,ubicacion)=>ok('ana','crearProyecto',{nombre,ubicacion,cliente:{nombre:'Cliente Secreto',empresa:'Empresa Oculta SA'},idSolicitud:id}).folio;
|
||||||
|
const p1=proy('Techo solar','p1','Querétaro, Qro.'), p2=proy('Planta Texas','p2','Austin, TX'), p3=proy('Sin firmar','p3','');
|
||||||
|
const cot=(proyectoFolio,precio,extra)=>ok('ana','guardarCotizacion',{cliente:{nombre:'Cliente Secreto',empresa:'Empresa Oculta SA'},
|
||||||
|
items:[{descripcion:'Servicio',cantidad:1,precio}],proyectoFolio,idSolicitud:'c'+(++nId),...extra}).folio;
|
||||||
|
const c1=cot(p1,98765), c2=cot(p2,5000,{moneda:'USD'}), c3=cot(p3,100);
|
||||||
|
for(const [p,c] of [[p1,c1],[p2,c2]]){ok('efrain','marcarAceptada',p,c,true);ok('efrain','firmarContrato',p,HOY_ISO)}
|
||||||
|
ok('efrain','marcarAceptada',p3,c3);
|
||||||
|
|
||||||
|
// --- Permisos de la pestaña ---
|
||||||
|
assert.deepEqual({...ok('obrero','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:true});
|
||||||
|
assert.deepEqual({...ok('pedro','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:true,requisicion:true});
|
||||||
|
assert.equal(ok('ana','getDatosIniciales').permisos.requisicion,false);
|
||||||
|
const TRAB=[['listarProyectosRequisicion',['']],['misRequisiciones',[]],['guardarRequisicion',[{}]],['cancelarRequisicion',[REQ('0001'),'motivo largo']],
|
||||||
|
['cerrarRequisicion',[REQ('0001'),'']]];
|
||||||
|
for(const quien of ['ana','tania']) for(const [f,args] of TRAB) falla(quien,f,args,/No tienes permiso para hacer requisiciones/,'PERMISO');
|
||||||
|
for(const [f,args] of TRAB) falla('intruso',f,args,/@helgen.tech/,'SESION');
|
||||||
|
|
||||||
|
// --- SOLO_REQUISICION: solo su pestaña; el servidor le niega todo lo demás ---
|
||||||
|
const dl=ok('luis','getDatosIniciales');
|
||||||
|
assert.deepEqual([dl.soloRequisicion,dl.permisos.requisicion,dl.permisos.compras,dl.clientes.length,dl.proyectos.length,dl.tipoCambio],[true,true,false,0,0,null]);
|
||||||
|
assert.deepEqual(dl.catalogo.map(p=>({...p})),[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA'}],'catálogo sin precios');
|
||||||
|
assert(!/Cliente Secreto|Empresa Oculta|4100|98765/.test(JSON.stringify(dl)),'sin clientes, precios ni montos');
|
||||||
|
for(const [f,args] of [['getClientes',[]],['listarCotizaciones',['']],['getCatalogo',[]],['guardarProducto',[{}]],['guardarCotizacion',[{}]],
|
||||||
|
['sugerirClavesSat',['panel']],['listarProyectos',['']],['crearProyecto',[{}]],['listarProyectosCompras',['']],['getCobranzaProyecto',[p1]]]){
|
||||||
|
falla('luis',f,args,/solo tiene acceso a Requisiciones/,'PERMISO');
|
||||||
|
falla('rosa',f,args,/solo tiene acceso a Requisiciones/,'PERMISO');
|
||||||
|
}
|
||||||
|
assert.equal(ok('obrero','listarCotizaciones','').length>0,true,'ROL_REQUISICION sin SOLO usa el cotizador como siempre');
|
||||||
|
|
||||||
|
// --- Proyectos para pedir: Activos con contrato firmado; solo folio y nombre ---
|
||||||
|
const lp=ok('luis','listarProyectosRequisicion','');
|
||||||
|
assert.deepEqual(lp.map(p=>({...p})),[{folio:p2,nombre:'Planta Texas'},{folio:p1,nombre:'Techo solar'}]);
|
||||||
|
assert.deepEqual(ok('luis','listarProyectosRequisicion','texas').map(p=>p.folio),[p2]);
|
||||||
|
assert.deepEqual(ok('luis','listarProyectosRequisicion','Cliente Secreto'),[],'no busca por cliente');
|
||||||
|
|
||||||
|
// --- Crear: cotización obligatoria (PDF, JPG, PNG o HEIC, máx. 10 MB); folio REQ; carpeta privada; Solicitada ---
|
||||||
|
const nueva=(quien,extra)=>({proyectoFolio:p1,renglones:[{descripcion:'Cable THW 10',cantidad:100,unidad:'m',notas:'rojo'},
|
||||||
|
{descripcion:'Tubo conduit 3/4',cantidad:'12',unidad:'pza'}],cotizacion:{nombre:'cot.pdf',datos:PDF},idSolicitud:'r'+(++nId),...extra});
|
||||||
|
falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:null})],/Adjunta la cotización del proveedor/);
|
||||||
|
falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.from('hola mundo txt').toString('base64')}})],/La cotización debe ser PDF o imagen/);
|
||||||
|
falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.concat([Buffer.from('%PDF-'),Buffer.alloc(10*1024*1024)]).toString('base64')}})],/pesa más de 10 MB/);
|
||||||
|
falla('obrero','guardarRequisicion',[nueva('obrero',{renglones:[{descripcion:'A',cantidad:0}]})],/mayor a 0/);
|
||||||
|
falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:p3})],/activos con contrato firmado/);
|
||||||
|
falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:'PRY-2000-0001'})],/No existe el proyecto/);
|
||||||
|
assert.equal(props['CONSECUTIVO_REQ_'+ANIO],undefined,'los rechazos no gastan folio');
|
||||||
|
let r=ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq1'}));
|
||||||
|
assert.equal(r.folio,REQ('0001'));
|
||||||
|
assert.equal(ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq1'})).folio,REQ('0001'),'idempotencia');
|
||||||
|
const LC=libros[props.COMPRAS_DB_ID], H=n=>LC.pest[n].m;
|
||||||
|
assert.equal(H('Requisiciones').length,2,'una sola requisición');
|
||||||
|
let f=H('Requisiciones')[1];
|
||||||
|
assert.deepEqual([f[0],f[1],f[2],f[3],f[4],f[5],f[9]],[REQ('0001'),HOY_ISO,p1,'obrero@helgen.tech','Juan Obrero','Solicitada','']);
|
||||||
|
assert.deepEqual(H('RequisicionesRenglones').slice(1).map(x=>x.join('|')),[REQ('0001')+'|1|1|Cable THW 10|100|M|rojo',REQ('0001')+'|1|2|Tubo conduit 3/4|12|PZA|'],'sin costo');
|
||||||
|
const cot1=archivos[f[7]];
|
||||||
|
assert.equal(ruta(cot1.padre),'Mi unidad/Cotizador Helgen — Compras/'+ANIO+'/'+p1+'/Requisiciones');
|
||||||
|
assert.equal(cot1.blob.name,REQ('0001')+' — Cotización.pdf'); assert.equal(cot1.blob.mime,'application/pdf');
|
||||||
|
assert.deepEqual([cot1.acceso,cot1.padre.acceso,cot1.padre.padre.acceso,cot1.padre.padre.padre.acceso],['PRIVATE','PRIVATE','PRIVATE','PRIVATE'],'restringido de arriba abajo');
|
||||||
|
// Otra de rosa (JPG) y otra de luis (SOLO_REQUISICION sin ROL_REQUISICION: también puede)
|
||||||
|
const rRosa=ok('rosa','guardarRequisicion',nueva('rosa',{proyectoFolio:p2,cotizacion:{nombre:'foto.jpg',datos:JPG}})).folio;
|
||||||
|
assert.equal(archivos[H('Requisiciones')[2][7]].blob.name,rRosa+' — Cotización.jpg');
|
||||||
|
const rLuis=ok('luis','guardarRequisicion',nueva('luis')).folio;
|
||||||
|
assert.deepEqual([rRosa,rLuis],[REQ('0002'),REQ('0003')]);
|
||||||
|
|
||||||
|
// --- El solicitante solo ve SUS requisiciones, sin montos, cliente, margen, pagos ni órdenes ---
|
||||||
|
let mias=ok('obrero','misRequisiciones');
|
||||||
|
assert.deepEqual(mias.map(x=>x.folio),[REQ('0001')]);
|
||||||
|
assert.deepEqual(ok('rosa','misRequisiciones').map(x=>x.folio),[rRosa]);
|
||||||
|
assert.deepEqual([mias[0].proyectoNombre,mias[0].estado,mias[0].renglones.length,mias[0].puedeCancelar,mias[0].puedeEditar,mias[0].puedeCerrar],
|
||||||
|
['Techo solar','Solicitada',2,true,false,false]);
|
||||||
|
assert.deepEqual(mias[0].eventos.map(e=>e.estado+':'+e.correo),['Solicitada:obrero@helgen.tech']);
|
||||||
|
falla('rosa','cancelarRequisicion',[REQ('0001'),'No es mía pero la cancelo'],/Solo quien hizo la requisición/,'PERMISO');
|
||||||
|
falla('rosa','guardarRequisicion',[{folio:REQ('0001'),renglones:[{descripcion:'X',cantidad:1}]}],/Solo quien hizo la requisición/,'PERMISO');
|
||||||
|
falla('rosa','cerrarRequisicion',[REQ('0001'),''],/Solo quien hizo la requisición/,'PERMISO');
|
||||||
|
|
||||||
|
// --- Compras ve todas ---
|
||||||
|
let lr=ok('pedro','listarRequisiciones','');
|
||||||
|
assert.deepEqual(lr.map(x=>x.folio+':'+x.estado+':'+x.renglones),[rLuis+':Solicitada:2',rRosa+':Solicitada:2',REQ('0001')+':Solicitada:2']);
|
||||||
|
assert.deepEqual(ok('pedro','listarRequisiciones','rosa').map(x=>x.folio),[rRosa]);
|
||||||
|
assert.deepEqual(ok('pedro','listarRequisiciones','','Autorizada'),[]);
|
||||||
|
let vr=ok('pedro','getRequisicion',REQ('0001'));
|
||||||
|
assert.deepEqual([vr.requisicion.estado,vr.moneda,vr.renglones.length,vr.puedeAutorizar,vr.puedeRevisar,vr.puedePagar,vr.orden,vr.requisicion.cotizacionId],
|
||||||
|
['Solicitada','MXN',2,true,true,false,null,f[7]]);
|
||||||
|
|
||||||
|
// --- Devolver → corregir y reenviar (mismo folio; versión nueva de renglones; cotización nueva opcional) ---
|
||||||
|
falla('pedro','devolverRequisicion',[REQ('0001'),'no'],/motivo/);
|
||||||
|
vr=ok('pedro','devolverRequisicion',REQ('0001'),'Falta el calibre del tubo');
|
||||||
|
assert.deepEqual([vr.requisicion.estado,vr.requisicion.motivo,vr.requisicion.reviso],['Devuelta','Falta el calibre del tubo','pedro@helgen.tech']);
|
||||||
|
falla('pedro','autorizarRequisicion',[REQ('0001'),{}],/está devuelta: no se puede autorizar/);
|
||||||
|
mias=ok('obrero','misRequisiciones');
|
||||||
|
assert.deepEqual([mias[0].estado,mias[0].motivo,mias[0].puedeEditar,mias[0].puedeCancelar],['Devuelta','Falta el calibre del tubo',true,true]);
|
||||||
|
assert.deepEqual(mias[0].eventos.map(e=>e.estado+':'+e.nota),['Solicitada:','Devuelta:Falta el calibre del tubo']);
|
||||||
|
r=ok('obrero','guardarRequisicion',{folio:REQ('0001'),proyectoFolio:p2,renglones:[{descripcion:'Tubo conduit 3/4 cédula 40',cantidad:12,unidad:'pza'}],idSolicitud:'rq-re1'});
|
||||||
|
assert.equal(r.folio,REQ('0001'));
|
||||||
|
mias=r.requisiciones;
|
||||||
|
assert.deepEqual([mias[0].estado,mias[0].motivo,mias[0].proyectoFolio,mias[0].renglones.map(x=>x.descripcion)],['Solicitada','',p1,['Tubo conduit 3/4 cédula 40']],'mismo proyecto; renglones nuevos');
|
||||||
|
assert.equal(H('RequisicionesRenglones').filter(x=>x[0]===REQ('0001')).length,3,'la versión anterior se conserva');
|
||||||
|
assert.equal(H('Requisiciones')[1][7],f[7],'sin cotización nueva se conserva la anterior');
|
||||||
|
falla('obrero','guardarRequisicion',[{folio:REQ('0001'),renglones:[{descripcion:'X',cantidad:1}]}],/está solicitada: no se puede reenviar/);
|
||||||
|
ok('pedro','devolverRequisicion',REQ('0001'),'Ahora falta la cotización firmada');
|
||||||
|
ok('obrero','guardarRequisicion',{folio:REQ('0001'),renglones:[{descripcion:'Tubo conduit 3/4 cédula 40',cantidad:12}],cotizacion:{datos:JPG},idSolicitud:'rq-re2'});
|
||||||
|
f=H('Requisiciones')[1];
|
||||||
|
assert.equal(archivos[f[7]].blob.name,REQ('0001')+' — Cotización (3).jpg','cotización nueva; la anterior no se borra');
|
||||||
|
assert(archivos[cot1.id],'la cotización anterior sigue en Drive');
|
||||||
|
|
||||||
|
// --- Rechazar (final) y cancelar (el solicitante) ---
|
||||||
|
falla('pedro','rechazarRequisicion',[rRosa,''],/motivo/);
|
||||||
|
ok('pedro','rechazarRequisicion',rRosa,'El material no corresponde al proyecto');
|
||||||
|
falla('pedro','autorizarRequisicion',[rRosa,{}],/está rechazada: no se puede autorizar/);
|
||||||
|
falla('rosa','cancelarRequisicion',[rRosa,'Ya no la necesito'],/está rechazada: no se puede cancelar/);
|
||||||
|
assert.deepEqual(ok('rosa','misRequisiciones').map(x=>x.estado+':'+x.motivo+':'+x.puedeEditar),['Rechazada:El material no corresponde al proyecto:false']);
|
||||||
|
falla('luis','cancelarRequisicion',[rLuis,''],/motivo/);
|
||||||
|
ok('luis','cancelarRequisicion',rLuis,'Se consiguió en la obra');
|
||||||
|
falla('pedro','devolverRequisicion',[rLuis,'Revisa cantidades'],/está cancelada: no se puede devolver/);
|
||||||
|
assert.equal(H('Requisiciones').length,4,'nada se borra');
|
||||||
|
|
||||||
|
// --- Autorizar: genera la OC con los datos de compras ---
|
||||||
|
const oc=(extra)=>({proveedor:{razonSocial:'Eléctrica del Centro',rfc:'EDC200101AB1'},fechaEntrega:MANANA,condicionesPago:'Contado',
|
||||||
|
ivaTasa:0.16,renglones:[{descripcion:'Tubo conduit 3/4 cédula 40',unidad:'PZA',cantidad:12,costo:100}],idSolicitud:'o'+(++nId),...extra});
|
||||||
|
r=ok('pedro','autorizarRequisicion',REQ('0001'),oc({idSolicitud:'au1'}));
|
||||||
|
assert.deepEqual([r.folio,r.ocFolio,r.total,r.moneda,r.vista.requisicion.estado,r.vista.orden.folio,r.vista.orden.reqFolio,r.vista.puedePagar],
|
||||||
|
[REQ('0001'),OC('0001'),1392,'MXN','Autorizada',OC('0001'),REQ('0001'),true]);
|
||||||
|
assert.equal(ok('pedro','autorizarRequisicion',REQ('0001'),oc({idSolicitud:'au1'})).ocFolio,OC('0001'),'idempotencia');
|
||||||
|
assert.equal(H('OrdenesCompra').length,2);
|
||||||
|
assert.equal(H('OrdenesCompra')[1][34],REQ('0001'),'la OC guarda su requisición');
|
||||||
|
mias=ok('obrero','misRequisiciones');
|
||||||
|
assert.equal(mias[0].estado,'Autorizada');
|
||||||
|
assert(!/OC-|1392|1,392|98765|Cliente Secreto|Empresa Oculta|Eléctrica|margen|pago|comprobante/i.test(JSON.stringify(mias)),
|
||||||
|
'el solicitante no ve orden, montos, cliente, proveedor, margen ni pagos');
|
||||||
|
falla('obrero','cerrarRequisicion',[REQ('0001'),''],/está autorizada: no se puede cerrar \(solo si está pagada\)/);
|
||||||
|
|
||||||
|
// --- Pago a proveedor: comprobante obligatorio, monto = total de la OC, tipo de cambio si es otra moneda ---
|
||||||
|
const pago=(extra)=>({reqFolio:REQ('0001'),fecha:HOY_ISO,monto:1392,monedaPago:'MXN',metodo:'Transferencia',referencia:'SPEI 55',
|
||||||
|
comprobante:{nombre:'spei.pdf',datos:PDF},idSolicitud:'pg'+(++nId),...extra});
|
||||||
|
falla('pedro','registrarPagoProveedor',[pago({comprobante:null})],/Adjunta el comprobante de pago/);
|
||||||
|
falla('pedro','registrarPagoProveedor',[pago({monto:1000})],/debe ser igual al total de la orden \(\$1,392\.00 MXN\)/);
|
||||||
|
falla('pedro','registrarPagoProveedor',[pago({monto:75.25,monedaPago:'USD'})],/tipo de cambio/);
|
||||||
|
falla('pedro','registrarPagoProveedor',[pago({referencia:''})],/referencia/);
|
||||||
|
falla('pedro','registrarPagoProveedor',[pago({reqFolio:'',ocFolio:'OC-2000-0001'})],/No existe la orden/);
|
||||||
|
falla('obrero','registrarPagoProveedor',[pago()],/No tienes permiso de compras/,'PERMISO');
|
||||||
|
assert.equal(props['CONSECUTIVO_PGP_'+ANIO],undefined,'los rechazos no gastan folio');
|
||||||
|
// Desde la OC (mismo registro), en dólares con tipo de cambio: 75.25 × 18.5 = 1392.13 (dentro de ±1.00)
|
||||||
|
r=ok('pedro','registrarPagoProveedor',pago({reqFolio:'',ocFolio:OC('0001'),monto:75.25,monedaPago:'USD',tipoCambio:18.5,idSolicitud:'pg-ok'}));
|
||||||
|
assert.equal(r.folio,PGP('0001'));
|
||||||
|
assert.equal(ok('pedro','registrarPagoProveedor',pago({idSolicitud:'pg-ok'})).folio,PGP('0001'),'idempotencia');
|
||||||
|
assert.deepEqual([r.vista.requisicion.estado,r.vista.orden.estado,r.vista.pagos.length,r.vista.puedeCerrar,r.vista.puedeCancelarPago],['Pagada','Pagada',1,true,true]);
|
||||||
|
let fp=H('PagosProveedores')[1];
|
||||||
|
assert.deepEqual([fp[0],fp[1],fp[3],fp[4],fp[5],fp[6],fp[7],fp[8],fp[9],fp[10],fp[11],fp[12],fp[17]],
|
||||||
|
[PGP('0001'),HOY_ISO,OC('0001'),REQ('0001'),p1,75.25,'USD',18.5,1392.13,'MXN','Transferencia','SPEI 55','Vigente']);
|
||||||
|
const comp=archivos[fp[13]];
|
||||||
|
assert.equal(ruta(comp.padre),'Mi unidad/Cotizador Helgen — Compras/'+ANIO+'/'+p1+'/Requisiciones');
|
||||||
|
assert.equal(comp.blob.name,REQ('0001')+' — Comprobante pago.pdf'); assert.equal(comp.acceso,'PRIVATE');
|
||||||
|
falla('pedro','registrarPagoProveedor',[pago()],/está pagada: no se puede pagar/,'');
|
||||||
|
falla('pedro','cancelarOrdenCompra',[OC('0001'),'Ya no se necesita'],/ya está pagada: no se puede cancelar/);
|
||||||
|
assert.deepEqual(Object.keys(libros[props.COBRANZA_DB_ID].pest).sort(),['Bitacora','Contratos','Esquemas','Pagos'],'los pagos a proveedores no van a cobranza');
|
||||||
|
assert.equal(libros[props.COBRANZA_DB_ID].pest.Pagos.m.length<=1,true,'ni se mezclan con PAG-');
|
||||||
|
// El solicitante ve "Pagada" y la fecha, nunca el comprobante ni el monto
|
||||||
|
mias=ok('obrero','misRequisiciones');
|
||||||
|
assert.deepEqual([mias[0].estado,mias[0].puedeCerrar,mias[0].eventos[mias[0].eventos.length-1].estado],['Pagada',true,'Pagada']);
|
||||||
|
assert(!/PGP-|1392|75\.25|SPEI|drive\/file|comprobante/i.test(JSON.stringify(mias)),'sin comprobante ni monto pagado');
|
||||||
|
|
||||||
|
// --- Cancelar el pago: requisición a Autorizada y OC a Emitida; nunca se borra ---
|
||||||
|
falla('pedro','cancelarPagoProveedor',[PGP('0001'),''],/motivo/);
|
||||||
|
vr=ok('pedro','cancelarPagoProveedor',PGP('0001'),'Se capturó con el tipo de cambio equivocado');
|
||||||
|
assert.deepEqual([vr.requisicion.estado,vr.orden.estado,vr.pagos[0].estado,vr.pagos[0].motivoCancelacion,vr.puedePagar],
|
||||||
|
['Autorizada','Emitida','Cancelado','Se capturó con el tipo de cambio equivocado',true]);
|
||||||
|
falla('pedro','cancelarPagoProveedor',[PGP('0001'),'Otra vez por error'],/ya está cancelado/);
|
||||||
|
mias=ok('obrero','misRequisiciones');
|
||||||
|
assert.deepEqual([mias[0].estado,mias[0].eventos[mias[0].eventos.length-1].nota],['Autorizada','Se canceló el pago.']);
|
||||||
|
assert(!/tipo de cambio equivocado/.test(JSON.stringify(mias)),'el motivo del pago no se le muestra');
|
||||||
|
falla('obrero','cerrarRequisicion',[REQ('0001'),''],/está autorizada: no se puede cerrar/);
|
||||||
|
// Se vuelve a pagar (MXN): segundo comprobante con (2)
|
||||||
|
r=ok('pedro','registrarPagoProveedor',pago({monto:1392}));
|
||||||
|
assert.equal(r.folio,PGP('0002'));
|
||||||
|
assert.equal(archivos[H('PagosProveedores')[2][13]].blob.name,REQ('0001')+' — Comprobante pago (2).pdf');
|
||||||
|
|
||||||
|
// --- Cierre: solo el solicitante, o compras con motivo; solo si está Pagada ---
|
||||||
|
falla('rosa','cerrarRequisicion',[REQ('0001'),''],/Solo quien hizo la requisición/,'PERMISO');
|
||||||
|
falla('pedro','cerrarRequisicion',[REQ('0001'),''],/Solo quien hizo la requisición/,'PERMISO');
|
||||||
|
falla('obrero','cerrarRequisicionCompras',[REQ('0001'),'Lo cierro yo'],/No tienes permiso de compras/,'PERMISO');
|
||||||
|
falla('pedro','cerrarRequisicionCompras',[REQ('0001'),''],/Escribe por qué cierras/);
|
||||||
|
falla('obrero','cerrarRequisicion',[REQ('0001'),'n'.repeat(501)],/500 caracteres/);
|
||||||
|
mias=ok('obrero','cerrarRequisicion',REQ('0001'),'Faltó 1 pieza');
|
||||||
|
assert.deepEqual([mias[0].estado,mias[0].notasRecepcion,mias[0].puedeCerrar,mias[0].eventos[mias[0].eventos.length-1].nota],['Cerrada','Faltó 1 pieza',false,'Material recibido · Faltó 1 pieza']);
|
||||||
|
let fo=H('OrdenesCompra')[1];
|
||||||
|
assert.deepEqual([fo[28],fo[29],fo[30]],['Recibida',HOY_ISO,'obrero@helgen.tech']);
|
||||||
|
falla('obrero','cerrarRequisicion',[REQ('0001'),''],/está cerrada: no se puede cerrar/);
|
||||||
|
falla('pedro','cancelarPagoProveedor',[PGP('0002'),'Se pagó doble por error'],/ya está cerrada \(material recibido\): el pago no se puede cancelar/);
|
||||||
|
falla('pedro','cancelarOrdenCompra',[OC('0001'),'Ya no se necesita'],/ya se recibió/);
|
||||||
|
|
||||||
|
// Compras cierra en lugar del solicitante (con motivo); la OC queda recibida con el correo de compras
|
||||||
|
ok('pedro','guardarRequisicion',nueva('pedro',{proyectoFolio:p2,idSolicitud:'rq-p'}));
|
||||||
|
const R5=REQ('0004');
|
||||||
|
falla('pedro','cerrarRequisicionCompras',[R5,'El trabajador ya no está'],/está solicitada: no se puede cerrar/);
|
||||||
|
r=ok('pedro','autorizarRequisicion',R5,oc({moneda:'USD',ivaTasa:0,renglones:[{descripcion:'Cable',cantidad:1,costo:50}]}));
|
||||||
|
assert.deepEqual([r.ocFolio,r.total,r.moneda],[OC('0002'),50,'USD']);
|
||||||
|
ok('pedro','registrarPagoProveedor',{reqFolio:R5,fecha:HOY_ISO,monto:925,monedaPago:'MXN',tipoCambio:18.5,metodo:'Efectivo',comprobante:{datos:JPG}});
|
||||||
|
vr=ok('efrain','cerrarRequisicionCompras',R5,'El trabajador ya no está en la obra','Llegó completo');
|
||||||
|
assert.deepEqual([vr.requisicion.estado,vr.orden.estado,vr.orden.recibio,vr.requisicion.notasRecepcion],['Cerrada','Recibida','efrain@helgen.tech','Llegó completo']);
|
||||||
|
assert(/^Cerrada por compras: El trabajador ya no está en la obra · Llegó completo$/.test(vr.eventos[vr.eventos.length-1].nota));
|
||||||
|
const fp3=H('PagosProveedores')[3]; assert.deepEqual([fp3[0],fp3[6],fp3[7],fp3[8],fp3[9],fp3[10],fp3[12]],[PGP('0003'),925,'MXN',18.5,50,'USD','']);
|
||||||
|
|
||||||
|
// --- Cancelar la OC de una requisición autorizada: vuelve a Solicitada y se autoriza de nuevo con otra OC ---
|
||||||
|
const R6=ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq-6'})).folio;
|
||||||
|
r=ok('pedro','autorizarRequisicion',R6,oc());
|
||||||
|
ok('pedro','cancelarOrdenCompra',r.ocFolio,'El proveedor subió el precio');
|
||||||
|
vr=ok('pedro','getRequisicion',R6);
|
||||||
|
assert.deepEqual([vr.requisicion.estado,vr.requisicion.ocFolio,vr.puedeAutorizar],['Solicitada','',true]);
|
||||||
|
assert.equal(ok('obrero','misRequisiciones')[0].estado,'Solicitada');
|
||||||
|
r=ok('pedro','autorizarRequisicion',R6,oc({proveedor:{razonSocial:'Otro proveedor'}}));
|
||||||
|
assert.equal(r.ocFolio,OC('0004'),'otra OC con su folio normal');
|
||||||
|
|
||||||
|
// --- Folios y bitácora ---
|
||||||
|
assert.deepEqual([props['CONSECUTIVO_REQ_'+ANIO],props['CONSECUTIVO_PGP_'+ANIO],props['CONSECUTIVO_OC_'+ANIO]],['5','3','4']);
|
||||||
|
const acciones=H('Bitacora').slice(1).map(b=>b[2]);
|
||||||
|
for(const a of ['Solicitar requisición','Devolver requisición','Reenviar requisición','Rechazar requisición','Cancelar requisición',
|
||||||
|
'Autorizar requisición','Registrar pago a proveedor','Cancelar pago a proveedor','Cerrar requisición','Requisición a Solicitada'])
|
||||||
|
assert(acciones.includes(a),'bitácora: '+a);
|
||||||
|
assert.equal(H('Bitacora').slice(1).find(b=>b[2]==='Cancelar pago a proveedor')[5],'Se capturó con el tipo de cambio equivocado');
|
||||||
|
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos'],'nada de requisiciones en la Base de datos');
|
||||||
|
assert(!JSON.stringify(db.pest).includes('REQ-')&&!JSON.stringify(db.pest).includes('PGP-'));
|
||||||
|
console.log('OK requisiciones');
|
||||||
@ -102,7 +102,7 @@ const d=post({funcion:'getDatosIniciales',args:[],token:'t-bueno'}).datos;
|
|||||||
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
|
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
|
||||||
assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola');
|
assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola');
|
||||||
assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos');
|
assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos');
|
||||||
assert.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false,compras:false},'sin ROL_COBRANZA ni ROL_COMPRAS nadie tiene cobranza ni compras');
|
assert.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:false},'sin ROL_COBRANZA ni ROL_COMPRAS ni ROL_REQUISICION nadie tiene esos permisos');
|
||||||
assert.equal(h[1].proyecto,'Sin proyecto');
|
assert.equal(h[1].proyecto,'Sin proyecto');
|
||||||
assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
|
assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
|
||||||
// Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión
|
// Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión
|
||||||
|
|||||||
@ -109,6 +109,7 @@
|
|||||||
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
|
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
|
||||||
<button id="tabHist" onclick="verTab('hist')">Historial</button>
|
<button id="tabHist" onclick="verTab('hist')">Historial</button>
|
||||||
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
|
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
|
||||||
|
<button id="tabReq" class="oculto" onclick="verTab('req')">Requisiciones</button>
|
||||||
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
|
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
|
||||||
<button id="tabCom" class="oculto" onclick="verTab('com')">Compras</button>
|
<button id="tabCom" class="oculto" onclick="verTab('com')">Compras</button>
|
||||||
</nav>
|
</nav>
|
||||||
@ -374,82 +375,191 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===== COMPRAS (solo rol de compras; el servidor lo vuelve a revisar en cada llamada) ===== -->
|
<!-- ===== COMPRAS (solo rol de compras; el servidor lo vuelve a revisar en cada llamada) ===== -->
|
||||||
<section id="vistaCom" class="oculto">
|
<section id="vistaReq" class="oculto">
|
||||||
<div class="modulo">
|
<div class="modulo">
|
||||||
<h2>1 · Proyecto y proveedor</h2>
|
<h2 id="reqTitulo">1 · Nueva requisición</h2>
|
||||||
|
<div id="reqEditando" class="nota oculto" style="margin-bottom:10px"></div>
|
||||||
<div class="proy-fila">
|
<div class="proy-fila">
|
||||||
<div><label>Proyecto (activo y con contrato firmado)</label>
|
<div><label>Proyecto (activo y con contrato firmado) *</label>
|
||||||
<input id="comProyecto" list="comProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
|
<input id="reqProyecto" list="reqProyectosList" autocomplete="off" placeholder="Busca por folio o nombre del proyecto">
|
||||||
<datalist id="comProyectosList"></datalist></div>
|
<datalist id="reqProyectosList"></datalist></div>
|
||||||
<button type="button" class="btn btn-sec" onclick="cargarProyectosCom()">Actualizar lista</button>
|
<button type="button" class="btn btn-sec" onclick="cargarProyectosReq()">Actualizar lista</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="comInfo" class="nota" style="margin-top:6px">Elige un proyecto para emitir órdenes de compra y ver las que tiene.</div>
|
<datalist id="reqCatalogo"></datalist>
|
||||||
<div id="comProveedor" class="grid oculto" style="margin-top:14px">
|
<table style="margin-top:14px">
|
||||||
<div><label>Proveedor (razón social) *</label>
|
|
||||||
<input id="prvRazon" list="proveedoresList" autocomplete="off" maxlength="150" placeholder="Escribe o elige un proveedor">
|
|
||||||
<datalist id="proveedoresList"></datalist></div>
|
|
||||||
<div><label>RFC</label><input id="prvRfc" maxlength="13"></div>
|
|
||||||
<div><label>Contacto</label><input id="prvContacto" maxlength="100"></div>
|
|
||||||
<div><label>Correo</label><input id="prvCorreo" type="email" maxlength="100"></div>
|
|
||||||
<div><label>Teléfono</label><input id="prvTel" maxlength="40"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="comModRenglones" class="modulo oculto">
|
|
||||||
<h2>2 · Renglones</h2>
|
|
||||||
<datalist id="ocCatalogo"></datalist>
|
|
||||||
<datalist id="ocClaves"></datalist>
|
|
||||||
<div class="grid" style="margin-bottom:12px">
|
|
||||||
<div><label>Moneda de la orden *</label>
|
|
||||||
<select id="ocMoneda" onchange="pintarTcOc()">
|
|
||||||
<option value="MXN">MXN · pesos mexicanos</option>
|
|
||||||
<option value="USD">USD · dólares estadounidenses</option>
|
|
||||||
</select></div>
|
|
||||||
<div id="ocTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label>
|
|
||||||
<input id="ocTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
|
|
||||||
<div><label>IVA *</label>
|
|
||||||
<select id="ocIva" onchange="recalcularOc()">
|
|
||||||
<option value="0.16">16%</option>
|
|
||||||
<option value="0.08">8% (región fronteriza)</option>
|
|
||||||
<option value="0">0% (extranjero o exento)</option>
|
|
||||||
</select></div>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
|
<th>Descripción del material *</th><th class="num" style="width:100px">Cantidad *</th><th style="width:90px">Unidad</th>
|
||||||
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">Costo unitario</th>
|
<th style="width:220px">Notas</th><th style="width:30px"></th>
|
||||||
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
|
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="ocFilas"></tbody>
|
<tbody id="reqFilas"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarRenglonOc()">+ Agregar renglón</button>
|
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarRenglonReq()">+ Agregar renglón</button>
|
||||||
<div class="nota" style="margin-top:8px">Del catálogo se llenan clave, descripción y unidad. El costo se captura a mano: nunca es el precio de venta.</div>
|
<div style="margin-top:14px"><label id="reqLblArchivo">Cotización del proveedor (PDF o foto, máx. 10 MB) *</label>
|
||||||
<div class="totales" style="margin-top:12px">
|
<input id="reqArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.heic"></div>
|
||||||
<div><span>Subtotal</span><span id="ocSub">$0.00</span></div>
|
<div class="acciones">
|
||||||
<div><span id="ocLblIva">IVA (16%)</span><span id="ocIvaMonto">$0.00</span></div>
|
<button type="button" id="btnReq" class="btn" onclick="enviarRequisicion()">Enviar requisición</button>
|
||||||
<div class="total"><span id="ocLblTot">TOTAL MXN</span><span id="ocTot">$0.00</span></div>
|
<button type="button" id="btnReqCancelarEdicion" class="btn btn-sec oculto" onclick="limpiarReq()">Cancelar corrección</button>
|
||||||
|
<span class="nota">No lleva precios: compras los toma de la cotización.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modulo">
|
||||||
|
<h2>2 · Mis requisiciones</h2>
|
||||||
|
<div class="acciones" style="margin-top:0"><button type="button" class="btn btn-sec" onclick="cargarMisReq()">Actualizar</button></div>
|
||||||
|
<div id="reqLista" style="margin-top:10px"><div class="nota">Cargando…</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="vistaCom" class="oculto">
|
||||||
|
<div id="comVistaLista">
|
||||||
|
<div class="modulo">
|
||||||
|
<h2>1 · Requisiciones</h2>
|
||||||
|
<div class="proy-fila">
|
||||||
|
<div><label>Buscar (folio, proyecto, solicitante u orden)</label><input id="rcBuscar" autocomplete="off"></div>
|
||||||
|
<div style="max-width:220px;min-width:180px"><label>Estado</label>
|
||||||
|
<select id="rcEstado" onchange="cargarRequisicionesCom()">
|
||||||
|
<option value="">Todas</option><option value="Solicitada">Solicitada (por revisar)</option>
|
||||||
|
<option value="Autorizada">Autorizada (por pagar)</option><option value="Pagada">Pagada (por recibir)</option>
|
||||||
|
<option value="Devuelta">Devuelta</option><option value="Cerrada">Cerrada</option>
|
||||||
|
<option value="Rechazada">Rechazada</option><option value="Cancelada">Cancelada</option>
|
||||||
|
</select></div>
|
||||||
|
<button type="button" class="btn btn-sec" onclick="cargarRequisicionesCom()">Buscar</button>
|
||||||
|
</div>
|
||||||
|
<table style="margin-top:14px">
|
||||||
|
<thead><tr><th>Folio</th><th>Fecha</th><th>Proyecto</th><th>Solicitante</th><th class="num">Renglones</th><th>Estado</th><th>Orden</th></tr></thead>
|
||||||
|
<tbody id="rcLista"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="nota" style="margin-top:8px">Toda orden de compra sale de una requisición: ábrela para autorizarla, pagarla o cerrarla.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modulo">
|
||||||
|
<h2>2 · Órdenes por proyecto</h2>
|
||||||
|
<div class="proy-fila">
|
||||||
|
<div><label>Proyecto (activo y con contrato firmado)</label>
|
||||||
|
<input id="comProyecto" list="comProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
|
||||||
|
<datalist id="comProyectosList"></datalist></div>
|
||||||
|
<button type="button" class="btn btn-sec" onclick="cargarProyectosCom()">Actualizar lista</button>
|
||||||
|
</div>
|
||||||
|
<div id="comInfo" class="nota" style="margin-top:6px">Elige un proyecto para ver sus órdenes de compra.</div>
|
||||||
|
<table style="margin-top:14px">
|
||||||
|
<thead><tr><th>Folio</th><th>Fecha</th><th>Requisición</th><th>Proveedor</th><th class="num">Total</th><th>Entrega</th><th>Estado</th><th>PDF</th><th></th></tr></thead>
|
||||||
|
<tbody id="ocLista"></tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="comModOrdenes" class="modulo oculto">
|
<div id="comVistaReq" class="oculto">
|
||||||
<h2>3 · Entrega y órdenes</h2>
|
<div class="modulo">
|
||||||
<div id="comForm">
|
<div class="acciones" style="margin:0 0 10px"><button type="button" class="btn btn-sec" onclick="verListaCom()">← Requisiciones</button></div>
|
||||||
<div class="grid">
|
<h2 id="rcTitulo">1 · Requisición</h2>
|
||||||
|
<div id="rcInfo" class="nota"></div>
|
||||||
|
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(320px,1fr));margin-top:12px;align-items:start">
|
||||||
|
<div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>#</th><th>Descripción</th><th class="num">Cant.</th><th>Unidad</th><th>Notas</th></tr></thead>
|
||||||
|
<tbody id="rcRenglones"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="rcEventos" class="sat"></div>
|
||||||
|
<div id="rcRevision" class="acciones oculto">
|
||||||
|
<button type="button" class="btn btn-sec" onclick="revisarReqCom('devolverRequisicion')">Devolver para corregir</button>
|
||||||
|
<button type="button" class="btn btn-sec" onclick="revisarReqCom('rechazarRequisicion')">Rechazar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="nota" style="margin-bottom:6px">Cotización del proveedor · <a id="rcCotLink" href="#" target="_blank">Abrir en Drive</a></div>
|
||||||
|
<iframe id="rcCotVisor" title="Cotización del proveedor" style="width:100%;height:520px;border:1px solid var(--borde);border-radius:6px;background:#fff"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="comModOc" class="modulo oculto">
|
||||||
|
<h2>2 · Orden de compra</h2>
|
||||||
|
<div id="comProveedor" class="grid">
|
||||||
|
<div><label>Proveedor (razón social) *</label>
|
||||||
|
<input id="prvRazon" list="proveedoresList" autocomplete="off" maxlength="150" placeholder="Escribe o elige un proveedor">
|
||||||
|
<datalist id="proveedoresList"></datalist></div>
|
||||||
|
<div><label>RFC</label><input id="prvRfc" maxlength="13"></div>
|
||||||
|
<div><label>Contacto</label><input id="prvContacto" maxlength="100"></div>
|
||||||
|
<div><label>Correo</label><input id="prvCorreo" type="email" maxlength="100"></div>
|
||||||
|
<div><label>Teléfono</label><input id="prvTel" maxlength="40"></div>
|
||||||
|
</div>
|
||||||
|
<datalist id="ocCatalogo"></datalist>
|
||||||
|
<datalist id="ocClaves"></datalist>
|
||||||
|
<div class="grid" style="margin:14px 0 12px">
|
||||||
|
<div><label>Moneda de la orden *</label>
|
||||||
|
<select id="ocMoneda" onchange="pintarTcOc()">
|
||||||
|
<option value="MXN">MXN · pesos mexicanos</option>
|
||||||
|
<option value="USD">USD · dólares estadounidenses</option>
|
||||||
|
</select></div>
|
||||||
|
<div id="ocTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label>
|
||||||
|
<input id="ocTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
|
||||||
|
<div><label>IVA *</label>
|
||||||
|
<select id="ocIva" onchange="recalcularOc()">
|
||||||
|
<option value="0.16">16%</option>
|
||||||
|
<option value="0.08">8% (región fronteriza)</option>
|
||||||
|
<option value="0">0% (extranjero o exento)</option>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
|
||||||
|
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">Costo unitario</th>
|
||||||
|
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="ocFilas"></tbody>
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarRenglonOc()">+ Agregar renglón</button>
|
||||||
|
<div class="nota" style="margin-top:8px">Los renglones vienen de la requisición (las notas van en la descripción). Captura el costo de la cotización: nunca es el precio de venta.</div>
|
||||||
|
<div class="totales" style="margin-top:12px">
|
||||||
|
<div><span>Subtotal</span><span id="ocSub">$0.00</span></div>
|
||||||
|
<div><span id="ocLblIva">IVA (16%)</span><span id="ocIvaMonto">$0.00</span></div>
|
||||||
|
<div class="total"><span id="ocLblTot">TOTAL MXN</span><span id="ocTot">$0.00</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid" style="margin-top:14px">
|
||||||
<div><label>Fecha de entrega *</label><input id="ocEntrega" type="date"></div>
|
<div><label>Fecha de entrega *</label><input id="ocEntrega" type="date"></div>
|
||||||
<div style="grid-column:span 2"><label>Lugar de entrega *</label><input id="ocLugar" maxlength="200"></div>
|
<div style="grid-column:span 2"><label>Lugar de entrega *</label><input id="ocLugar" maxlength="200"></div>
|
||||||
<div><label>Condiciones de pago *</label><input id="ocCondiciones" maxlength="200" placeholder="Ej. Contado, crédito 30 días"></div>
|
<div><label>Condiciones de pago *</label><input id="ocCondiciones" maxlength="200" placeholder="Ej. Contado, crédito 30 días"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:12px"><label>Notas para el proveedor (opcional)</label><textarea id="ocNotas" rows="3" maxlength="1000"></textarea></div>
|
<div style="margin-top:12px"><label>Notas para el proveedor (opcional)</label><textarea id="ocNotas" rows="3" maxlength="1000"></textarea></div>
|
||||||
<div class="acciones">
|
<div class="acciones">
|
||||||
<button type="button" id="btnOc" class="btn" onclick="guardarOc()">Emitir orden y generar PDF</button>
|
<button type="button" id="btnOc" class="btn" onclick="guardarOc()">Autorizar y emitir orden (PDF)</button>
|
||||||
<span class="nota">El PDF se guarda en la carpeta privada de compras; no se envía por correo.</span>
|
<span class="nota">El PDF se guarda en la carpeta privada de compras; no se envía por correo.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="comNoEmite" class="nota"></div>
|
|
||||||
<table style="margin-top:14px">
|
<div id="comModPago" class="modulo oculto">
|
||||||
<thead><tr><th>Folio</th><th>Fecha</th><th>Proveedor</th><th class="num">Total</th><th>Entrega</th><th>Estado</th><th>PDF</th><th></th></tr></thead>
|
<h2>3 · Orden, pago y cierre</h2>
|
||||||
<tbody id="ocLista"></tbody>
|
<div id="rcOrden" class="tiles"></div>
|
||||||
</table>
|
<div id="rcFormPago" class="sat oculto">
|
||||||
|
<div class="sat-titulo">Registrar el pago al proveedor (uno por orden)</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>Fecha del pago *</label><input id="ppFecha" type="date"></div>
|
||||||
|
<div><label>Monto *</label><input id="ppMonto" type="number" min="0" step="0.01" style="text-align:right"></div>
|
||||||
|
<div><label>Moneda del pago *</label>
|
||||||
|
<select id="ppMoneda" onchange="pintarTcPago()"><option value="MXN">MXN</option><option value="USD">USD</option></select></div>
|
||||||
|
<div id="ppTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label>
|
||||||
|
<input id="ppTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
|
||||||
|
<div><label>Método *</label>
|
||||||
|
<select id="ppMetodo"><option>Transferencia</option><option>Efectivo</option><option>Cheque</option></select></div>
|
||||||
|
<div><label>Referencia (opcional solo en efectivo)</label><input id="ppRef" maxlength="100"></div>
|
||||||
|
<div><label>Comprobante (PDF o imagen, máx. 10 MB) *</label>
|
||||||
|
<input id="ppArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.heic"></div>
|
||||||
|
</div>
|
||||||
|
<div class="acciones"><button type="button" id="btnPagoProv" class="btn" onclick="registrarPagoProv()">Registrar pago</button>
|
||||||
|
<span class="nota">El monto debe ser el total de la orden (±1.00).</span></div>
|
||||||
|
</div>
|
||||||
|
<table style="margin-top:12px">
|
||||||
|
<thead><tr><th>Pago</th><th>Fecha</th><th class="num">Monto</th><th>Método</th><th>Estado</th><th>Comprobante</th><th></th></tr></thead>
|
||||||
|
<tbody id="rcPagos"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="rcCierre" class="sat oculto">
|
||||||
|
<div class="sat-titulo">Cerrar en lugar de quien la pidió</div>
|
||||||
|
<div class="nota" style="margin-bottom:8px">Normalmente la cierra el solicitante con "Recibí el material". Hazlo tú solo si no puede (p. ej. ya no está en la obra).</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>Motivo *</label><input id="rcMotivoCierre" maxlength="500" placeholder="Ej. El trabajador ya no está en la obra"></div>
|
||||||
|
<div><label>Notas de la recepción (opcional)</label><input id="rcNotasCierre" maxlength="500" placeholder="Ej. Faltó 1 pieza"></div>
|
||||||
|
</div>
|
||||||
|
<div class="acciones"><button type="button" id="btnCerrarCom" class="btn btn-sec" onclick="cerrarReqCom()">Cerrar requisición (material recibido)</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@ -629,11 +739,19 @@
|
|||||||
|
|
||||||
// ---- Arranque ----
|
// ---- Arranque ----
|
||||||
google.script.run.withSuccessHandler(d => {
|
google.script.run.withSuccessHandler(d => {
|
||||||
IVA = d.ivaTasa; CATALOGO = d.catalogo; TC = d.tipoCambio || TC;
|
IVA = d.ivaTasa; CATALOGO = d.catalogo || []; TC = d.tipoCambio || TC;
|
||||||
|
PERMISOS = d.permisos || {};
|
||||||
|
$('tabReq').classList.toggle('oculto', !PERMISOS.requisicion);
|
||||||
|
pintarCatalogoReq();
|
||||||
|
if (d.soloRequisicion) {
|
||||||
|
// Cuenta que solo pide materiales: sin cotizaciones, historial ni catálogo (el servidor se los niega)
|
||||||
|
['tabNueva', 'tabHist', 'tabCat', 'tabCob', 'tabCom'].forEach(id => $(id).classList.add('oculto'));
|
||||||
|
verTab('req');
|
||||||
|
return;
|
||||||
|
}
|
||||||
recibirClientes(d.clientes);
|
recibirClientes(d.clientes);
|
||||||
recibirProyectos(d.proyectos);
|
recibirProyectos(d.proyectos);
|
||||||
// Las pestañas Cobranza y Compras solo se muestran con permiso; el servidor lo revisa en cada llamada
|
// Las pestañas Cobranza, Compras y Requisiciones solo se muestran con permiso; el servidor lo revisa en cada llamada
|
||||||
PERMISOS = d.permisos || {};
|
|
||||||
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
|
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
|
||||||
$('tabCom').classList.toggle('oculto', !PERMISOS.compras);
|
$('tabCom').classList.toggle('oculto', !PERMISOS.compras);
|
||||||
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
||||||
@ -1306,55 +1424,232 @@
|
|||||||
}, err => { listo(); mostrarError(err); });
|
}, err => { listo(); mostrarError(err); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Compras (solo rol de compras) ----
|
// ---- Requisiciones (quien pide materiales: el servidor solo le manda las suyas, sin montos) ----
|
||||||
let COM_PROYECTOS = [], COM = null, PROVEEDORES = [], ID_OC = nuevoId();
|
let REQ_PROYECTOS = [], MIS_REQ = [], REQ_EDITANDO = null, ID_REQ = nuevoId();
|
||||||
const CHIP_OC = { Emitida: 'aceptada', Recibida: 'firmado', Cancelada: 'cancelado' };
|
const CHIP_REQ = { Solicitada: 'aceptada', Devuelta: 'aceptada', Autorizada: 'firmado', Pagada: 'firmado', Cerrada: 'firmado',
|
||||||
const CAMPOS_PROV = { prvRazon: 'razonSocial', prvRfc: 'rfc', prvContacto: 'contacto', prvCorreo: 'correo', prvTel: 'telefono' };
|
Rechazada: 'cancelado', Cancelada: 'cancelado' };
|
||||||
const fechaMx = iso => { const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); return m ? m[3] + '/' + m[2] + '/' + m[1] : String(iso || ''); };
|
const chipReq = e => '<span class="chip ' + (CHIP_REQ[e] || '') + '">' + esc(e) + '</span>';
|
||||||
|
const fechaMx = iso => { const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})(.*)$/); return m ? m[3] + '/' + m[2] + '/' + m[1] + m[4].slice(0, 6) : String(iso || ''); };
|
||||||
|
const botonChico = (texto, accion, sec) => '<button type="button" class="btn' + (sec === false ? '' : ' btn-sec') +
|
||||||
|
'" style="padding:4px 10px;font-size:12px" onclick="' + accion + '">' + texto + '</button>';
|
||||||
|
|
||||||
function cargarProyectosCom() {
|
/** Sugerencias del catálogo para la descripción (sin precios). */
|
||||||
|
function pintarCatalogoReq() {
|
||||||
|
$('reqCatalogo').innerHTML = CATALOGO.map(p =>
|
||||||
|
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + p.unidad) + '">').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cargarProyectosReq() {
|
||||||
google.script.run.withSuccessHandler(lista => {
|
google.script.run.withSuccessHandler(lista => {
|
||||||
COM_PROYECTOS = lista || [];
|
REQ_PROYECTOS = lista || [];
|
||||||
$('comProyectosList').innerHTML = COM_PROYECTOS.map(p =>
|
$('reqProyectosList').innerHTML = REQ_PROYECTOS.map(p => '<option value="' + esc(p.folio + ' — ' + p.nombre) + '">').join('');
|
||||||
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc(p.moneda + ' · ' + (MARCA_TXT[p.marca] || '')) + '">').join('');
|
}).withFailureHandler(mostrarError).listarProyectosRequisicion('');
|
||||||
if (!COM_PROYECTOS.length && !COM) $('comInfo').textContent = 'No hay proyectos activos con contrato firmado.';
|
}
|
||||||
}).withFailureHandler(mostrarError).listarProyectosCompras('');
|
function cargarMisReq() {
|
||||||
|
google.script.run.withSuccessHandler(pintarMisReq).withFailureHandler(mostrarError).misRequisiciones();
|
||||||
|
}
|
||||||
|
function proyectoReqElegido() {
|
||||||
|
const folio = ($('reqProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
|
||||||
|
return (folio && REQ_PROYECTOS.find(p => p.folio === folio)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function proyectoComElegido() {
|
function agregarRenglonReq(r) {
|
||||||
const folio = ($('comProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
|
const d = r || {};
|
||||||
return (folio && COM_PROYECTOS.find(p => p.folio === folio)) || null;
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td><input class="desc" list="reqCatalogo" maxlength="500" placeholder="Ej. Cable THW calibre 10"></td>' +
|
||||||
|
'<td><input class="cant" type="number" min="0" step="any" style="text-align:right"></td>' +
|
||||||
|
'<td><input class="uni" maxlength="20"></td>' +
|
||||||
|
'<td><input class="notas" maxlength="300" placeholder="Opcional"></td>' +
|
||||||
|
'<td><button class="quitar" title="Quitar">×</button></td>';
|
||||||
|
tr.querySelector('.desc').value = d.descripcion || '';
|
||||||
|
tr.querySelector('.cant').value = d.cantidad == null ? '1' : d.cantidad;
|
||||||
|
tr.querySelector('.uni').value = d.unidad || 'PZA';
|
||||||
|
tr.querySelector('.notas').value = d.notas || '';
|
||||||
|
// Del catálogo solo se toma la unidad
|
||||||
|
tr.querySelector('.desc').addEventListener('change', e => {
|
||||||
|
const p = CATALOGO.find(x => x.descripcion === e.target.value);
|
||||||
|
if (p) tr.querySelector('.uni').value = p.unidad;
|
||||||
|
});
|
||||||
|
tr.querySelector('.quitar').onclick = () => tr.remove();
|
||||||
|
$('reqFilas').appendChild(tr);
|
||||||
}
|
}
|
||||||
$('comProyecto').addEventListener('change', () => { const p = proyectoComElegido(); if (p) abrirProyectoCom(p.folio); });
|
function leerRenglonesReq() {
|
||||||
|
return [...$('reqFilas').querySelectorAll('tr')].map(tr => ({
|
||||||
function abrirProyectoCom(folio) {
|
descripcion: tr.querySelector('.desc').value, cantidad: tr.querySelector('.cant').value,
|
||||||
$('comInfo').textContent = 'Cargando…';
|
unidad: tr.querySelector('.uni').value, notas: tr.querySelector('.notas').value,
|
||||||
google.script.run.withSuccessHandler(pintarCompras).withFailureHandler(mostrarError).getComprasProyecto(folio);
|
})).filter(r => r.descripcion.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pinta la pestaña a partir de la respuesta del servidor (vistaCompras_). Al cambiar de proyecto, limpia el formulario. */
|
function limpiarReq() {
|
||||||
function pintarCompras(v) {
|
REQ_EDITANDO = null; ID_REQ = nuevoId();
|
||||||
const cambio = !COM || COM.proyecto.folio !== v.proyecto.folio;
|
$('reqProyecto').value = ''; $('reqProyecto').disabled = false;
|
||||||
COM = v;
|
$('reqFilas').innerHTML = ''; agregarRenglonReq();
|
||||||
$('comProyecto').value = etiquetaCob(v.proyecto);
|
$('reqArchivo').value = '';
|
||||||
$('comInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
|
$('reqTitulo').textContent = '1 · Nueva requisición';
|
||||||
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + esc(MARCA_TXT[v.marca] || '') : '') +
|
$('reqEditando').classList.add('oculto');
|
||||||
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '');
|
$('reqLblArchivo').textContent = 'Cotización del proveedor (PDF o foto, máx. 10 MB) *';
|
||||||
recibirProveedores(v.proveedores);
|
$('btnReq').disabled = false; $('btnReq').textContent = 'Enviar requisición';
|
||||||
['comProveedor', 'comModRenglones', 'comForm'].forEach(id => $(id).classList.toggle('oculto', !v.puedeEmitir));
|
$('btnReqCancelarEdicion').classList.add('oculto');
|
||||||
$('comModOrdenes').classList.remove('oculto');
|
}
|
||||||
$('comNoEmite').textContent = v.puedeEmitir ? '' : 'Este proyecto no admite órdenes nuevas: debe estar Activo y con contrato firmado.';
|
|
||||||
if (v.puedeEmitir && cambio) limpiarOc();
|
function enviarRequisicion() {
|
||||||
$('ocLista').innerHTML = (v.ordenes || []).length ? v.ordenes.map(o => '<tr' + (o.estado === 'Cancelada' ? ' class="fila-cancelada" title="' +
|
if ($('btnReq').disabled) return;
|
||||||
esc('Cancelada por ' + o.canceladoPor + ': ' + o.motivoCancelacion) + '"' : '') + '><td style="white-space:nowrap"><b>' + esc(o.folio) + '</b></td><td>' + esc(fechaMx(o.fecha)) +
|
const archivo = $('reqArchivo').files && $('reqArchivo').files[0];
|
||||||
'</td><td>' + esc(o.proveedor) + '</td><td class="num">' + dinero(o.total, o.moneda) +
|
const proyecto = REQ_EDITANDO ? null : proyectoReqElegido();
|
||||||
(o.tipoCambio ? '<div class="nota">TC ' + esc(o.tipoCambio) + '</div>' : '') + '</td><td>' + esc(fechaMx(o.fechaEntrega)) +
|
if (!REQ_EDITANDO && !proyecto) { mostrarError('Elige un proyecto de la lista.'); return; }
|
||||||
'<div class="nota">' + esc(o.lugarEntrega) + '</div></td><td><span class="chip ' + (CHIP_OC[o.estado] || '') + '"' +
|
const renglones = leerRenglonesReq();
|
||||||
(o.estado === 'Recibida' ? ' title="' + esc('Recibida el ' + fechaMx(o.fechaRecepcion) + ' · ' + o.recibio) + '"' : '') + '>' + esc(o.estado) + '</span></td><td>' +
|
if (!renglones.length) { mostrarError('Agrega al menos un renglón con descripción y cantidad.'); return; }
|
||||||
(o.pdfUrl ? '<a href="' + esc(o.pdfUrl) + '" target="_blank">Ver</a>' : '') + '</td><td style="white-space:nowrap">' +
|
if (!REQ_EDITANDO && !archivo) { mostrarError('Adjunta la cotización del proveedor (PDF o imagen).'); return; }
|
||||||
(o.estado === 'Emitida' ? '<button type="button" class="btn btn-sec" style="padding:4px 10px;font-size:12px" onclick="recibirOc(\'' + esc(o.folio) + '\')">Recibida</button>' +
|
if (archivo && archivo.size > 10 * 1024 * 1024) { mostrarError('La cotización pesa más de 10 MB. Reduce la imagen o el PDF.'); return; }
|
||||||
'<button type="button" class="quitar" title="Cancelar orden" onclick="cancelarOc(\'' + esc(o.folio) + '\')">×</button>' : '') +
|
const texto = $('btnReq').textContent;
|
||||||
'</td></tr>').join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene órdenes de compra.</td></tr>';
|
$('btnReq').disabled = true; $('btnReq').textContent = 'Enviando…';
|
||||||
|
const listo = () => { $('btnReq').disabled = false; $('btnReq').textContent = texto; };
|
||||||
|
const enviar = datos => google.script.run.withSuccessHandler(r => {
|
||||||
|
const corregida = !!REQ_EDITANDO;
|
||||||
|
limpiarReq(); pintarMisReq(r.requisiciones);
|
||||||
|
aviso('ok', 'Requisición <b>' + esc(r.folio) + '</b> ' + (corregida ? 'corregida y reenviada' : 'enviada') + '. Compras la revisará.');
|
||||||
|
}).withFailureHandler(err => { listo(); mostrarError(err); }).guardarRequisicion({
|
||||||
|
folio: REQ_EDITANDO || '', proyectoFolio: proyecto ? proyecto.folio : '', renglones: renglones,
|
||||||
|
cotizacion: datos ? { nombre: archivo.name, datos: datos } : null, idSolicitud: ID_REQ,
|
||||||
|
});
|
||||||
|
if (archivo) leerArchivo(archivo).then(enviar, err => { listo(); mostrarError(err); });
|
||||||
|
else enviar(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pintarMisReq(lista) {
|
||||||
|
MIS_REQ = lista || [];
|
||||||
|
$('reqLista').innerHTML = !MIS_REQ.length ? '<div class="nota">Todavía no tienes requisiciones.</div>' : MIS_REQ.map(r =>
|
||||||
|
'<div class="sat" style="margin-top:10px">' +
|
||||||
|
'<div class="sat-titulo">' + esc(r.folio) + ' · ' + chipReq(r.estado) + ' · ' + esc(r.proyectoFolio) + ' — ' + esc(r.proyectoNombre) + '</div>' +
|
||||||
|
(r.motivo && ['Devuelta', 'Rechazada', 'Cancelada'].indexOf(r.estado) !== -1 ? '<div class="nota"><b>Motivo:</b> ' + esc(r.motivo) + '</div>' : '') +
|
||||||
|
'<ul style="margin:6px 0;padding-left:18px">' + r.renglones.map(x => '<li>' + esc(x.cantidad) + ' ' + esc(x.unidad) + ' — ' +
|
||||||
|
esc(x.descripcion) + (x.notas ? ' <span class="nota">(' + esc(x.notas) + ')</span>' : '') + '</li>').join('') + '</ul>' +
|
||||||
|
'<div class="nota">' + r.eventos.map(e => esc(fechaMx(e.fecha)) + ' · <b>' + esc(e.estado) + '</b> · ' + esc(e.nombre) +
|
||||||
|
(e.nota ? ': ' + esc(e.nota) : '')).join('<br>') + '</div>' +
|
||||||
|
'<div class="acciones">' +
|
||||||
|
(r.puedeCerrar ? botonChico('Recibí el material', "recibiMaterial('" + esc(r.folio) + "')", false) : '') +
|
||||||
|
(r.puedeEditar ? botonChico('Corregir y reenviar', "editarReq('" + esc(r.folio) + "')") : '') +
|
||||||
|
(r.puedeCancelar ? botonChico('Cancelar requisición', "cancelarReq('" + esc(r.folio) + "')") : '') +
|
||||||
|
'</div></div>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devuelta: el solicitante corrige renglones (y si quiere, la cotización) y la reenvía con el mismo folio. */
|
||||||
|
function editarReq(folio) {
|
||||||
|
const r = MIS_REQ.find(x => x.folio === folio);
|
||||||
|
if (!r) return;
|
||||||
|
REQ_EDITANDO = folio; ID_REQ = nuevoId();
|
||||||
|
$('reqProyecto').value = r.proyectoFolio + ' — ' + r.proyectoNombre; $('reqProyecto').disabled = true;
|
||||||
|
$('reqFilas').innerHTML = ''; r.renglones.forEach(agregarRenglonReq);
|
||||||
|
$('reqArchivo').value = '';
|
||||||
|
$('reqTitulo').textContent = '1 · Corregir ' + folio;
|
||||||
|
$('reqEditando').innerHTML = '<b>Compras la devolvió:</b> ' + esc(r.motivo);
|
||||||
|
$('reqEditando').classList.remove('oculto');
|
||||||
|
$('reqLblArchivo').textContent = 'Cotización nueva (opcional: si no adjuntas otra, se queda la anterior)';
|
||||||
|
$('btnReq').textContent = 'Reenviar requisición';
|
||||||
|
$('btnReqCancelarEdicion').classList.remove('oculto');
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
function cancelarReq(folio) {
|
||||||
|
const motivo = window.prompt('Motivo para cancelar la requisición ' + folio + ' (queda registrado; no se borra):');
|
||||||
|
if (motivo == null) return;
|
||||||
|
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
||||||
|
accionCob(null, 'cancelarRequisicion', [folio, motivo], l => { pintarMisReq(l); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> cancelada.'); });
|
||||||
|
}
|
||||||
|
function recibiMaterial(folio) {
|
||||||
|
const notas = window.prompt('¿Recibiste el material de ' + folio + '? Si algo faltó, escríbelo aquí (opcional) y acepta:', '');
|
||||||
|
if (notas == null) return;
|
||||||
|
accionCob(null, 'cerrarRequisicion', [folio, notas], l => { pintarMisReq(l); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> cerrada: material recibido.'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Compras (solo rol de compras): requisiciones → orden de compra → pago → cierre ----
|
||||||
|
let COM_PROYECTOS = [], COM = null, REQC = null, PROVEEDORES = [], ID_OC = nuevoId(), ID_PGP = nuevoId();
|
||||||
|
const CHIP_OC = { Emitida: 'aceptada', Pagada: 'firmado', Recibida: 'firmado', Cancelada: 'cancelado' };
|
||||||
|
const CAMPOS_PROV = { prvRazon: 'razonSocial', prvRfc: 'rfc', prvContacto: 'contacto', prvCorreo: 'correo', prvTel: 'telefono' };
|
||||||
|
|
||||||
|
function verListaCom() {
|
||||||
|
REQC = null;
|
||||||
|
$('comVistaReq').classList.add('oculto'); $('comVistaLista').classList.remove('oculto');
|
||||||
|
$('rcCotVisor').src = 'about:blank';
|
||||||
|
cargarRequisicionesCom();
|
||||||
|
}
|
||||||
|
function cargarRequisicionesCom() {
|
||||||
|
google.script.run.withSuccessHandler(pintarListaReqCom).withFailureHandler(mostrarError)
|
||||||
|
.listarRequisiciones($('rcBuscar').value, $('rcEstado').value);
|
||||||
|
}
|
||||||
|
$('rcBuscar').addEventListener('keydown', e => { if (e.key === 'Enter') cargarRequisicionesCom(); });
|
||||||
|
function pintarListaReqCom(lista) {
|
||||||
|
$('rcLista').innerHTML = (lista || []).length ? lista.map(r => '<tr class="fila-cat" onclick="abrirReqCom(\'' + esc(r.folio) + '\')">' +
|
||||||
|
'<td style="white-space:nowrap"><b>' + esc(r.folio) + '</b></td><td>' + esc(fechaMx(r.fecha)) + '</td><td>' + esc(r.proyectoFolio) +
|
||||||
|
'<div class="nota">' + esc(r.proyectoNombre) + '</div></td><td>' + esc(r.solicitante) + '</td><td class="num">' + esc(r.renglones) +
|
||||||
|
'</td><td>' + chipReq(r.estado) + '</td><td>' + esc(r.ocFolio) + '</td></tr>').join('')
|
||||||
|
: '<tr><td colspan="7" class="nota">No hay requisiciones con ese filtro.</td></tr>';
|
||||||
|
}
|
||||||
|
function abrirReqCom(folio) {
|
||||||
|
google.script.run.withSuccessHandler(pintarReqCom).withFailureHandler(mostrarError).getRequisicion(folio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detalle de una requisición (vistaRequisicion_): renglones y cotización lado a lado; orden, pago y cierre según su estado. */
|
||||||
|
function pintarReqCom(v) {
|
||||||
|
const rq = v.requisicion;
|
||||||
|
const cambio = !REQC || REQC.requisicion.folio !== rq.folio || REQC.requisicion.estado !== rq.estado;
|
||||||
|
REQC = v;
|
||||||
|
$('comVistaLista').classList.add('oculto'); $('comVistaReq').classList.remove('oculto');
|
||||||
|
$('rcTitulo').textContent = '1 · Requisición ' + rq.folio;
|
||||||
|
$('rcInfo').innerHTML = chipReq(rq.estado) + ' · <b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' +
|
||||||
|
esc(v.proyecto.nombre) + (v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + esc(MARCA_TXT[v.marca] || '') : '') +
|
||||||
|
'<br>Pidió: ' + esc(rq.solicitante) + ' (' + esc(rq.solicitanteEmail) + ') el ' + esc(fechaMx(rq.fecha)) +
|
||||||
|
(rq.estado === 'Solicitada' && !v.puedeAutorizar ? '<br><span class="aviso-moneda">El proyecto ya no está activo con contrato firmado: no se puede autorizar (recházala o devuélvela).</span>' : '');
|
||||||
|
$('rcRenglones').innerHTML = v.renglones.map(x => '<tr><td>' + esc(x.renglon) + '</td><td>' + esc(x.descripcion) + '</td><td class="num">' +
|
||||||
|
esc(x.cantidad) + '</td><td>' + esc(x.unidad) + '</td><td>' + esc(x.notas) + '</td></tr>').join('');
|
||||||
|
$('rcEventos').innerHTML = '<div class="sat-titulo">Historial</div><div class="nota">' + v.eventos.map(e => esc(fechaMx(e.fecha)) +
|
||||||
|
' · <b>' + esc(e.estado) + '</b> · ' + esc(e.nombre) + (e.nota ? ': ' + esc(e.nota) : '')).join('<br>') + '</div>';
|
||||||
|
$('rcRevision').classList.toggle('oculto', !v.puedeRevisar);
|
||||||
|
const visor = rq.cotizacionId ? 'https://drive.google.com/file/d/' + encodeURIComponent(rq.cotizacionId) + '/preview' : 'about:blank';
|
||||||
|
if ($('rcCotVisor').src !== visor) $('rcCotVisor').src = visor;
|
||||||
|
$('rcCotLink').href = rq.cotizacionUrl || '#';
|
||||||
|
|
||||||
|
$('comModOc').classList.toggle('oculto', !v.puedeAutorizar);
|
||||||
|
if (v.puedeAutorizar && cambio) precargarOc(v);
|
||||||
|
|
||||||
|
const o = v.orden;
|
||||||
|
$('comModPago').classList.toggle('oculto', !o);
|
||||||
|
if (o) {
|
||||||
|
$('rcOrden').innerHTML =
|
||||||
|
'<div class="tile"><span>Orden de compra</span><b>' + esc(o.folio) + '</b> ' + '<span class="chip ' + (CHIP_OC[o.estado] || '') + '">' + esc(o.estado) + '</span>' +
|
||||||
|
(o.pdfUrl ? '<div class="nota"><a href="' + esc(o.pdfUrl) + '" target="_blank">Abrir PDF</a></div>' : '') + '</div>' +
|
||||||
|
'<div class="tile"><span>Total</span><b>' + dinero(o.total, o.moneda) + '</b></div>' +
|
||||||
|
'<div class="tile"><span>Proveedor</span><b style="font-size:13px">' + esc(o.proveedor) + '</b><div class="nota">' + esc(o.condicionesPago) + '</div></div>' +
|
||||||
|
'<div class="tile"><span>Entrega</span><b style="font-size:13px">' + esc(fechaMx(o.fechaEntrega)) + '</b><div class="nota">' + esc(o.lugarEntrega) + '</div>' +
|
||||||
|
(o.estado === 'Recibida' ? '<div class="nota">Recibida el ' + esc(fechaMx(o.fechaRecepcion)) + ' · ' + esc(o.recibio) + '</div>' : '') + '</div>' +
|
||||||
|
(o.estado === 'Emitida' ? '<div class="tile"><span>¿Ya no va?</span>' + botonChico('Cancelar orden', "cancelarOcDesdeReq('" + esc(o.folio) + "')") + '</div>' : '');
|
||||||
|
}
|
||||||
|
$('rcFormPago').classList.toggle('oculto', !v.puedePagar);
|
||||||
|
if (v.puedePagar && cambio) {
|
||||||
|
$('ppFecha').value = v.hoy; $('ppFecha').max = v.hoy; $('ppFecha').min = o.fecha || '';
|
||||||
|
$('ppMonto').value = o.total; $('ppMoneda').value = o.moneda; $('ppMetodo').value = 'Transferencia';
|
||||||
|
['ppTc', 'ppRef', 'ppArchivo'].forEach(id => $(id).value = '');
|
||||||
|
ID_PGP = nuevoId();
|
||||||
|
pintarTcPago();
|
||||||
|
}
|
||||||
|
$('rcPagos').innerHTML = v.pagos.length ? v.pagos.map(p => '<tr' + (p.estado === 'Cancelado' ? ' class="fila-cancelada" title="' +
|
||||||
|
esc('Cancelado por ' + p.canceladoPor + ': ' + p.motivoCancelacion) + '"' : '') + '><td><b>' + esc(p.folio) + '</b></td><td>' + esc(fechaMx(p.fecha)) +
|
||||||
|
'</td><td class="num">' + dinero(p.monto, p.monedaPago) + (p.tipoCambio ? '<div class="nota">TC ' + esc(p.tipoCambio) + ' = ' + dinero(p.montoAplicado, p.monedaOc) + '</div>' : '') +
|
||||||
|
'</td><td>' + esc(p.metodo) + (p.referencia ? '<div class="nota">' + esc(p.referencia) + '</div>' : '') + '</td><td>' + esc(p.estado) + '</td><td>' +
|
||||||
|
(p.comprobanteUrl ? '<a href="' + esc(p.comprobanteUrl) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
|
||||||
|
(p.estado === 'Vigente' && v.puedeCancelarPago ? '<button type="button" class="quitar" title="Cancelar pago" onclick="cancelarPagoProv(\'' + esc(p.folio) + '\')">×</button>' : '') +
|
||||||
|
'</td></tr>').join('') : '<tr><td colspan="7" class="nota">Sin pagos.</td></tr>';
|
||||||
|
$('rcCierre').classList.toggle('oculto', !v.puedeCerrar);
|
||||||
|
if (v.puedeCerrar && cambio) { $('rcMotivoCierre').value = ''; $('rcNotasCierre').value = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisarReqCom(funcion) {
|
||||||
|
const folio = REQC.requisicion.folio, rechazo = funcion === 'rechazarRequisicion';
|
||||||
|
const motivo = window.prompt((rechazo ? 'Motivo para rechazar ' + folio + ' (es final)' : 'Qué debe corregir quien pidió ' + folio) +
|
||||||
|
'. Lo verá el solicitante:');
|
||||||
|
if (motivo == null) return;
|
||||||
|
if (!motivo.trim()) { mostrarError('Escribe el motivo.'); return; }
|
||||||
|
accionCob(null, funcion, [folio, motivo], v => { pintarReqCom(v); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> ' + (rechazo ? 'rechazada.' : 'devuelta para corregir.')); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proveedores (se llenan solos al emitir órdenes, como los clientes)
|
// Proveedores (se llenan solos al emitir órdenes, como los clientes)
|
||||||
@ -1371,8 +1666,9 @@
|
|||||||
$('prvRazon').addEventListener('change', autorrellenarProveedor);
|
$('prvRazon').addEventListener('change', autorrellenarProveedor);
|
||||||
$('prvRazon').addEventListener('input', () => { if (PROVEEDORES.some(x => x.etiqueta === $('prvRazon').value)) autorrellenarProveedor(); });
|
$('prvRazon').addEventListener('input', () => { if (PROVEEDORES.some(x => x.etiqueta === $('prvRazon').value)) autorrellenarProveedor(); });
|
||||||
|
|
||||||
// Renglones: del catálogo se toman clave, descripción y unidad; el costo nunca es el precio de venta
|
// Renglones: vienen de la requisición; del catálogo se toman clave, descripción y unidad; el costo nunca es el precio de venta
|
||||||
function agregarRenglonOc() {
|
function agregarRenglonOc(r) {
|
||||||
|
const d = r || {};
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
'<td><input class="clave" list="ocClaves"></td>' +
|
'<td><input class="clave" list="ocClaves"></td>' +
|
||||||
@ -1382,6 +1678,9 @@
|
|||||||
'<td><input class="costo" type="number" min="0" step="0.01" placeholder="Costo" style="text-align:right"></td>' +
|
'<td><input class="costo" type="number" min="0" step="0.01" placeholder="Costo" style="text-align:right"></td>' +
|
||||||
'<td class="num imp">$0.00</td>' +
|
'<td class="num imp">$0.00</td>' +
|
||||||
'<td><button class="quitar" title="Quitar">×</button></td>';
|
'<td><button class="quitar" title="Quitar">×</button></td>';
|
||||||
|
if (d.descripcion) tr.querySelector('.desc').value = d.descripcion;
|
||||||
|
if (d.unidad) tr.querySelector('.uni').value = d.unidad;
|
||||||
|
if (d.cantidad != null) tr.querySelector('.cant').value = d.cantidad;
|
||||||
const aplicar = p => {
|
const aplicar = p => {
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
|
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
|
||||||
@ -1423,28 +1722,32 @@
|
|||||||
|
|
||||||
/** Orden en otra moneda que el proyecto: tipo de cambio obligatorio. */
|
/** Orden en otra moneda que el proyecto: tipo de cambio obligatorio. */
|
||||||
function pintarTcOc() {
|
function pintarTcOc() {
|
||||||
const distinta = !!COM && !!COM.moneda && $('ocMoneda').value !== COM.moneda;
|
const distinta = !!REQC && !!REQC.moneda && $('ocMoneda').value !== REQC.moneda;
|
||||||
$('ocTcCaja').classList.toggle('oculto', !distinta);
|
$('ocTcCaja').classList.toggle('oculto', !distinta);
|
||||||
if (!distinta) $('ocTc').value = '';
|
if (!distinta) $('ocTc').value = '';
|
||||||
recalcularOc();
|
recalcularOc();
|
||||||
}
|
}
|
||||||
|
|
||||||
function limpiarOc() {
|
/** Formulario de la orden precargado con los renglones de la requisición (las notas van en la descripción). */
|
||||||
|
function precargarOc(v) {
|
||||||
|
recibirProveedores(v.proveedores);
|
||||||
Object.keys(CAMPOS_PROV).concat(['ocTc', 'ocEntrega', 'ocCondiciones', 'ocNotas']).forEach(id => $(id).value = '');
|
Object.keys(CAMPOS_PROV).concat(['ocTc', 'ocEntrega', 'ocCondiciones', 'ocNotas']).forEach(id => $(id).value = '');
|
||||||
$('ocFilas').innerHTML = ''; agregarRenglonOc();
|
$('ocFilas').innerHTML = '';
|
||||||
$('ocMoneda').value = (COM && COM.moneda) || 'MXN';
|
v.renglones.forEach(r => agregarRenglonOc({ descripcion: r.descripcion + (r.notas ? ' — ' + r.notas : ''), unidad: r.unidad, cantidad: r.cantidad }));
|
||||||
|
if (!v.renglones.length) agregarRenglonOc();
|
||||||
|
$('ocMoneda').value = v.moneda || 'MXN';
|
||||||
$('ocIva').value = [0.16, 0.08, 0].indexOf(IVA) !== -1 ? String(IVA) : '0.16';
|
$('ocIva').value = [0.16, 0.08, 0].indexOf(IVA) !== -1 ? String(IVA) : '0.16';
|
||||||
$('ocLugar').value = (COM && COM.proyecto.ubicacion) || '';
|
$('ocLugar').value = v.proyecto.ubicacion || '';
|
||||||
if (COM) $('ocEntrega').min = COM.hoy;
|
$('ocEntrega').min = v.hoy;
|
||||||
ID_OC = nuevoId();
|
ID_OC = nuevoId();
|
||||||
$('btnOc').disabled = false; $('btnOc').textContent = 'Emitir orden y generar PDF';
|
$('btnOc').disabled = false; $('btnOc').textContent = 'Autorizar y emitir orden (PDF)';
|
||||||
pintarTcOc();
|
pintarTcOc();
|
||||||
}
|
}
|
||||||
|
|
||||||
function guardarOc() {
|
function guardarOc() {
|
||||||
if (!COM || $('btnOc').disabled) return;
|
if (!REQC || $('btnOc').disabled) return;
|
||||||
|
const folio = REQC.requisicion.folio;
|
||||||
const payload = {
|
const payload = {
|
||||||
proyectoFolio: COM.proyecto.folio,
|
|
||||||
proveedor: { razonSocial: $('prvRazon').value, rfc: $('prvRfc').value, contacto: $('prvContacto').value,
|
proveedor: { razonSocial: $('prvRazon').value, rfc: $('prvRfc').value, contacto: $('prvContacto').value,
|
||||||
correo: $('prvCorreo').value, telefono: $('prvTel').value },
|
correo: $('prvCorreo').value, telefono: $('prvTel').value },
|
||||||
renglones: leerRenglonesOc().map(({_tr, ...r}) => r),
|
renglones: leerRenglonesOc().map(({_tr, ...r}) => r),
|
||||||
@ -1453,24 +1756,105 @@
|
|||||||
notas: $('ocNotas').value, idSolicitud: ID_OC,
|
notas: $('ocNotas').value, idSolicitud: ID_OC,
|
||||||
};
|
};
|
||||||
$('btnOc').disabled = true; $('btnOc').textContent = 'Generando PDF…';
|
$('btnOc').disabled = true; $('btnOc').textContent = 'Generando PDF…';
|
||||||
const listo = () => { $('btnOc').disabled = false; $('btnOc').textContent = 'Emitir orden y generar PDF'; };
|
const listo = () => { $('btnOc').disabled = false; $('btnOc').textContent = 'Autorizar y emitir orden (PDF)'; };
|
||||||
google.script.run
|
google.script.run
|
||||||
.withSuccessHandler(r => {
|
.withSuccessHandler(r => {
|
||||||
pintarCompras(r.vista); limpiarOc();
|
pintarReqCom(r.vista);
|
||||||
aviso('ok', 'Orden de compra <b>' + esc(r.folio) + '</b> emitida · Total ' + dinero(r.total, r.moneda) +
|
aviso('ok', 'Requisición <b>' + esc(r.folio) + '</b> autorizada · Orden de compra <b>' + esc(r.ocFolio) + '</b> · Total ' +
|
||||||
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
|
dinero(r.total, r.moneda) + ' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
|
||||||
})
|
})
|
||||||
.withFailureHandler(err => { listo(); mostrarError(err); })
|
.withFailureHandler(err => { listo(); mostrarError(err); })
|
||||||
.guardarOrdenCompra(payload);
|
.autorizarRequisicion(folio, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recibirOc(folio) {
|
/** Pago en otra moneda que la orden: tipo de cambio obligatorio. */
|
||||||
const fecha = window.prompt('Fecha en que se recibió completa la orden ' + folio + ' (AAAA-MM-DD):', COM.hoy);
|
function pintarTcPago() {
|
||||||
if (fecha == null) return;
|
const distinta = !!REQC && !!REQC.orden && $('ppMoneda').value !== REQC.orden.moneda;
|
||||||
accionCob(null, 'marcarOcRecibida', [folio, fecha.trim()], v => { pintarCompras(v); aviso('ok', 'Orden <b>' + esc(folio) + '</b> marcada como recibida.'); });
|
$('ppTcCaja').classList.toggle('oculto', !distinta);
|
||||||
|
if (!distinta) $('ppTc').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registrarPagoProv() {
|
||||||
|
if (!REQC || $('btnPagoProv').disabled) return;
|
||||||
|
const archivo = $('ppArchivo').files && $('ppArchivo').files[0];
|
||||||
|
if (!archivo) { mostrarError('Adjunta el comprobante de pago (PDF o imagen).'); return; }
|
||||||
|
if (archivo.size > 10 * 1024 * 1024) { mostrarError('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.'); return; }
|
||||||
|
const folio = REQC.requisicion.folio;
|
||||||
|
$('btnPagoProv').disabled = true; $('btnPagoProv').textContent = 'Subiendo comprobante…';
|
||||||
|
const listo = () => { $('btnPagoProv').disabled = false; $('btnPagoProv').textContent = 'Registrar pago'; };
|
||||||
|
leerArchivo(archivo).then(datos => {
|
||||||
|
google.script.run.withSuccessHandler(r => {
|
||||||
|
listo(); pintarReqCom(r.vista);
|
||||||
|
aviso('ok', 'Pago <b>' + esc(r.folio) + '</b> registrado · <a href="' + esc(r.url) + '" target="_blank">Ver comprobante</a>');
|
||||||
|
}).withFailureHandler(err => { listo(); mostrarError(err); }).registrarPagoProveedor({
|
||||||
|
reqFolio: folio, fecha: $('ppFecha').value, monto: $('ppMonto').value, monedaPago: $('ppMoneda').value,
|
||||||
|
tipoCambio: $('ppTc').value, metodo: $('ppMetodo').value, referencia: $('ppRef').value,
|
||||||
|
comprobante: { nombre: archivo.name, datos: datos }, idSolicitud: ID_PGP,
|
||||||
|
});
|
||||||
|
}, err => { listo(); mostrarError(err); });
|
||||||
|
}
|
||||||
|
function cancelarPagoProv(folio) {
|
||||||
|
const motivo = window.prompt('Motivo para cancelar el pago ' + folio + ' (no se borra; la requisición vuelve a Autorizada):');
|
||||||
|
if (motivo == null) return;
|
||||||
|
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
||||||
|
accionCob(null, 'cancelarPagoProveedor', [folio, motivo], v => { pintarReqCom(v); aviso('ok', 'Pago <b>' + esc(folio) + '</b> cancelado.'); });
|
||||||
|
}
|
||||||
|
function cerrarReqCom() {
|
||||||
|
const folio = REQC.requisicion.folio;
|
||||||
|
if (!$('rcMotivoCierre').value.trim()) { mostrarError('Escribe por qué cierras la requisición en lugar de quien la pidió.'); return; }
|
||||||
|
accionCob('btnCerrarCom', 'cerrarRequisicionCompras', [folio, $('rcMotivoCierre').value, $('rcNotasCierre').value],
|
||||||
|
v => { pintarReqCom(v); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> cerrada · orden recibida.'); });
|
||||||
|
}
|
||||||
|
function cancelarOcDesdeReq(folioOc) {
|
||||||
|
const motivo = window.prompt('Motivo para cancelar la orden ' + folioOc + ' (no se borra; la requisición vuelve a Solicitada para autorizarla de nuevo):');
|
||||||
|
if (motivo == null) return;
|
||||||
|
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
||||||
|
const folio = REQC.requisicion.folio;
|
||||||
|
accionCob(null, 'cancelarOrdenCompra', [folioOc, motivo], () => { abrirReqCom(folio); aviso('ok', 'Orden <b>' + esc(folioOc) + '</b> cancelada.'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Órdenes por proyecto (consulta)
|
||||||
|
function cargarProyectosCom() {
|
||||||
|
google.script.run.withSuccessHandler(lista => {
|
||||||
|
COM_PROYECTOS = lista || [];
|
||||||
|
$('comProyectosList').innerHTML = COM_PROYECTOS.map(p =>
|
||||||
|
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc(p.moneda + ' · ' + (MARCA_TXT[p.marca] || '')) + '">').join('');
|
||||||
|
if (!COM_PROYECTOS.length && !COM) $('comInfo').textContent = 'No hay proyectos activos con contrato firmado.';
|
||||||
|
}).withFailureHandler(mostrarError).listarProyectosCompras('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function proyectoComElegido() {
|
||||||
|
const folio = ($('comProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
|
||||||
|
return (folio && COM_PROYECTOS.find(p => p.folio === folio)) || null;
|
||||||
|
}
|
||||||
|
$('comProyecto').addEventListener('change', () => { const p = proyectoComElegido(); if (p) abrirProyectoCom(p.folio); });
|
||||||
|
|
||||||
|
function abrirProyectoCom(folio) {
|
||||||
|
$('comInfo').textContent = 'Cargando…';
|
||||||
|
google.script.run.withSuccessHandler(pintarCompras).withFailureHandler(mostrarError).getComprasProyecto(folio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Órdenes de un proyecto (vistaCompras_). Las acciones (pagar, cerrar) se hacen desde su requisición. */
|
||||||
|
function pintarCompras(v) {
|
||||||
|
COM = v;
|
||||||
|
$('comProyecto').value = etiquetaCob(v.proyecto);
|
||||||
|
$('comInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
|
||||||
|
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + esc(MARCA_TXT[v.marca] || '') : '') +
|
||||||
|
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '');
|
||||||
|
$('ocLista').innerHTML = (v.ordenes || []).length ? v.ordenes.map(o => '<tr' + (o.estado === 'Cancelada' ? ' class="fila-cancelada" title="' +
|
||||||
|
esc('Cancelada por ' + o.canceladoPor + ': ' + o.motivoCancelacion) + '"' : '') + '><td style="white-space:nowrap"><b>' + esc(o.folio) + '</b></td><td>' + esc(fechaMx(o.fecha)) +
|
||||||
|
'</td><td>' + (o.reqFolio ? '<a href="#" onclick="abrirReqCom(\'' + esc(o.reqFolio) + '\');return false">' + esc(o.reqFolio) + '</a>' : '') +
|
||||||
|
'</td><td>' + esc(o.proveedor) + '</td><td class="num">' + dinero(o.total, o.moneda) +
|
||||||
|
(o.tipoCambio ? '<div class="nota">TC ' + esc(o.tipoCambio) + '</div>' : '') + '</td><td>' + esc(fechaMx(o.fechaEntrega)) +
|
||||||
|
'<div class="nota">' + esc(o.lugarEntrega) + '</div></td><td><span class="chip ' + (CHIP_OC[o.estado] || '') + '"' +
|
||||||
|
(o.estado === 'Recibida' ? ' title="' + esc('Recibida el ' + fechaMx(o.fechaRecepcion) + ' · ' + o.recibio) + '"' : '') + '>' + esc(o.estado) + '</span></td><td>' +
|
||||||
|
(o.pdfUrl ? '<a href="' + esc(o.pdfUrl) + '" target="_blank">Ver</a>' : '') + '</td><td style="white-space:nowrap">' +
|
||||||
|
(o.estado === 'Emitida' ? '<button type="button" class="quitar" title="Cancelar orden" onclick="cancelarOc(\'' + esc(o.folio) + '\')">×</button>' : '') +
|
||||||
|
'</td></tr>').join('') : '<tr><td colspan="9" class="nota">Este proyecto no tiene órdenes de compra.</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
function cancelarOc(folio) {
|
function cancelarOc(folio) {
|
||||||
const motivo = window.prompt('Motivo para cancelar la orden ' + folio + ' (queda registrado; la orden no se borra):');
|
const motivo = window.prompt('Motivo para cancelar la orden ' + folio + ' (no se borra; su requisición vuelve a Solicitada):');
|
||||||
if (motivo == null) return;
|
if (motivo == null) return;
|
||||||
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
||||||
accionCob(null, 'cancelarOrdenCompra', [folio, motivo], v => { pintarCompras(v); aviso('ok', 'Orden <b>' + esc(folio) + '</b> cancelada.'); });
|
accionCob(null, 'cancelarOrdenCompra', [folio, motivo], v => { pintarCompras(v); aviso('ok', 'Orden <b>' + esc(folio) + '</b> cancelada.'); });
|
||||||
@ -1483,6 +1867,8 @@
|
|||||||
$('vistaCat').classList.toggle('oculto', t !== 'cat');
|
$('vistaCat').classList.toggle('oculto', t !== 'cat');
|
||||||
$('vistaCob').classList.toggle('oculto', t !== 'cob');
|
$('vistaCob').classList.toggle('oculto', t !== 'cob');
|
||||||
$('vistaCom').classList.toggle('oculto', t !== 'com');
|
$('vistaCom').classList.toggle('oculto', t !== 'com');
|
||||||
|
$('vistaReq').classList.toggle('oculto', t !== 'req');
|
||||||
|
$('tabReq').classList.toggle('activo', t === 'req');
|
||||||
$('tabCom').classList.toggle('activo', t === 'com');
|
$('tabCom').classList.toggle('activo', t === 'com');
|
||||||
$('tabCob').classList.toggle('activo', t === 'cob');
|
$('tabCob').classList.toggle('activo', t === 'cob');
|
||||||
$('tabCat').classList.toggle('activo', t === 'cat');
|
$('tabCat').classList.toggle('activo', t === 'cat');
|
||||||
@ -1491,7 +1877,8 @@
|
|||||||
if (t === 'hist') cargarHistorial();
|
if (t === 'hist') cargarHistorial();
|
||||||
if (t === 'cat') cargarCatalogo();
|
if (t === 'cat') cargarCatalogo();
|
||||||
if (t === 'cob') { cargarProyectosCob(); verResumenCob(); }
|
if (t === 'cob') { cargarProyectosCob(); verResumenCob(); }
|
||||||
if (t === 'com') cargarProyectosCom();
|
if (t === 'com') { cargarProyectosCom(); verListaCom(); }
|
||||||
|
if (t === 'req') { cargarProyectosReq(); cargarMisReq(); if (!$('reqFilas').querySelectorAll('tr').length) limpiarReq(); }
|
||||||
$('aviso').className = 'aviso';
|
$('aviso').className = 'aviso';
|
||||||
}
|
}
|
||||||
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
|
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user