From c8539b243f7abd9c245bf467ce67535892f69256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Mon, 16 Feb 2026 18:37:39 +0000 Subject: [PATCH 1/9] feat(client): add POST, PUT, and DELETE methods Adds support for creating, updating, and deleting resources via FacturaScripts API. Implements form-urlencoded serialization with nested object flattening to comply with API requirements. The toFormData method handles arrays and nested objects properly, converting them to the expected field[index][key] format. --- src/fs/client.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/fs/client.ts b/src/fs/client.ts index f242aa3..bd38e32 100644 --- a/src/fs/client.ts +++ b/src/fs/client.ts @@ -83,4 +83,80 @@ export class FacturaScriptsClient { headers: new Headers(axiosResponse.headers as Record) }); } -} \ No newline at end of file + + /** + * Flatten nested objects into form-urlencoded format. + * Arrays are serialized as field[0][key]=value, field[1][key]=value, etc. + * @param data Object to flatten + * @param prefix Optional prefix for nested keys + * @returns URLSearchParams ready for POST/PUT + */ + private toFormData(data: Record, prefix?: string): URLSearchParams { + const params = new URLSearchParams(); + + const flatten = (obj: any, currentPrefix?: string) => { + for (const [key, value] of Object.entries(obj)) { + const paramKey = currentPrefix ? `${currentPrefix}[${key}]` : key; + + if (value === null || value === undefined) { + continue; + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (typeof item === 'object' && item !== null) { + flatten(item, `${paramKey}[${index}]`); + } else { + params.append(`${paramKey}[${index}]`, String(item)); + } + }); + } else if (typeof value === 'object') { + flatten(value, paramKey); + } else { + params.append(paramKey, String(value)); + } + } + }; + + flatten(data, prefix); + return params; + } + + /** + * Create a new record via POST request + * FacturaScripts API expects form-urlencoded data, not JSON. + * @param endpoint API endpoint (e.g., '/proveedores') + * @param data Data to create + * @returns Created record + */ + async post(endpoint: string, data: Record): Promise { + const formData = this.toFormData(data); + const response = await this.client.post(endpoint, formData.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return response.data; + } + + /** + * Update an existing record via PUT request + * FacturaScripts API expects form-urlencoded data, not JSON. + * @param endpoint API endpoint (e.g., '/proveedores/PROV001') + * @param data Data to update + * @returns Updated record + */ + async put(endpoint: string, data: Record): Promise { + const formData = this.toFormData(data); + const response = await this.client.put(endpoint, formData.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return response.data; + } + + /** + * Delete a record via DELETE request + * @param endpoint API endpoint (e.g., '/proveedores/PROV001') + * @returns Deletion confirmation + */ + async delete(endpoint: string): Promise { + const response = await this.client.delete(endpoint); + return response.data; + } +} From c7dc1be338311b81c9c14b929dca168db7274c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Mon, 16 Feb 2026 18:37:40 +0000 Subject: [PATCH 2/9] feat(proveedores): add create supplier tool Implements create_proveedor tool for creating new suppliers with full validation of required fields (nombre, cifnif). Supports optional contact details, payment methods, and postal address. Automatically updates the associated contact record with address fields when provided, as FacturaScripts stores addresses in the contact entity rather than the supplier entity. --- .../core-business/proveedores/index.ts | 3 +- src/modules/core-business/proveedores/tool.ts | 200 +++++++++++++++++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/modules/core-business/proveedores/index.ts b/src/modules/core-business/proveedores/index.ts index a54d787..51681e1 100644 --- a/src/modules/core-business/proveedores/index.ts +++ b/src/modules/core-business/proveedores/index.ts @@ -1,2 +1,3 @@ export { ProveedoresResource } from './resource.js'; -export { toolDefinition as proveedoresToolDefinition, toolImplementation as proveedoresToolImplementation } from './tool.js'; \ No newline at end of file +export { toolDefinition as proveedoresToolDefinition, toolImplementation as proveedoresToolImplementation } from './tool.js'; +export { createProveedorToolDefinition, createProveedorImplementation } from './tool.js'; diff --git a/src/modules/core-business/proveedores/tool.ts b/src/modules/core-business/proveedores/tool.ts index dc47fc5..4f0a0ad 100644 --- a/src/modules/core-business/proveedores/tool.ts +++ b/src/modules/core-business/proveedores/tool.ts @@ -1,3 +1,5 @@ +import { FacturaScriptsClient } from '../../../fs/client.js'; + export const toolDefinition = { name: 'get_proveedores', description: 'Obtiene la lista de proveedores con paginación y filtros avanzados', @@ -37,4 +39,200 @@ export const toolImplementation = async (resource: any, buildUri: (resourceName: }, ], }; -}; \ No newline at end of file +}; + +export const createProveedorToolDefinition = { + name: 'create_proveedor', + description: 'Crea un nuevo proveedor en FacturaScripts. Permite especificar nombre, CIF/NIF, datos de contacto, forma de pago y dirección postal completa.', + inputSchema: { + type: 'object', + properties: { + nombre: { + type: 'string', + description: 'Nombre del proveedor (requerido)', + }, + razonsocial: { + type: 'string', + description: 'Razón social del proveedor. Si no se indica, se usa el nombre.', + }, + cifnif: { + type: 'string', + description: 'CIF/NIF del proveedor (requerido por FacturaScripts)', + }, + email: { + type: 'string', + description: 'Email de contacto del proveedor', + }, + telefono1: { + type: 'string', + description: 'Teléfono principal del proveedor', + }, + telefono2: { + type: 'string', + description: 'Teléfono secundario del proveedor', + }, + web: { + type: 'string', + description: 'Página web del proveedor', + }, + codpago: { + type: 'string', + description: 'Código de forma de pago (ej: TRANS, CONT, TAR)', + }, + observaciones: { + type: 'string', + description: 'Observaciones o notas sobre el proveedor', + }, + tipoidfiscal: { + type: 'string', + description: 'Tipo de identificador fiscal (ej: CIF, NIF, NIE)', + }, + regimeniva: { + type: 'string', + description: 'Régimen de IVA del proveedor', + }, + direccion: { + type: 'string', + description: 'Dirección postal del proveedor (calle, número, piso, etc.)', + }, + codpostal: { + type: 'string', + description: 'Código postal', + }, + ciudad: { + type: 'string', + description: 'Ciudad', + }, + provincia: { + type: 'string', + description: 'Provincia', + }, + codpais: { + type: 'string', + description: 'Código de país ISO (ej: ESP, FRA, DEU). Por defecto: ESP', + }, + apartado: { + type: 'string', + description: 'Apartado de correos', + }, + }, + required: ['nombre', 'cifnif'], + }, +}; + +export async function createProveedorImplementation( + args: Record, + client: FacturaScriptsClient +) { + try { + if (!args.nombre || typeof args.nombre !== 'string' || args.nombre.trim() === '') { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'El nombre del proveedor es obligatorio y no puede estar vacío.', + }, null, 2), + }], + isError: true, + }; + } + + if (!args.cifnif || typeof args.cifnif !== 'string' || args.cifnif.trim() === '') { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'El CIF/NIF del proveedor es obligatorio y no puede estar vacío.', + }, null, 2), + }], + isError: true, + }; + } + + const proveedorData: Record = { + nombre: args.nombre.trim(), + cifnif: args.cifnif.trim(), + razonsocial: args.razonsocial?.trim() || args.nombre.trim(), + }; + + if (args.email) proveedorData.email = args.email.trim(); + if (args.telefono1) proveedorData.telefono1 = args.telefono1.trim(); + if (args.telefono2) proveedorData.telefono2 = args.telefono2.trim(); + if (args.web) proveedorData.web = args.web.trim(); + if (args.codpago) proveedorData.codpago = args.codpago.trim(); + if (args.observaciones) proveedorData.observaciones = args.observaciones.trim(); + if (args.tipoidfiscal) proveedorData.tipoidfiscal = args.tipoidfiscal.trim(); + if (args.regimeniva) proveedorData.regimeniva = args.regimeniva.trim(); + + const result = await client.post('/proveedores', proveedorData); + + // If address fields are provided, update the auto-generated contact + // FacturaScripts creates a contact (contacto) automatically when a provider is created. + // Address fields live in the contact, not the provider. + const addressFields: Record = {}; + if (args.direccion) addressFields.direccion = args.direccion.trim(); + if (args.codpostal) addressFields.codpostal = args.codpostal.trim(); + if (args.ciudad) addressFields.ciudad = args.ciudad.trim(); + if (args.provincia) addressFields.provincia = args.provincia.trim(); + if (args.codpais) addressFields.codpais = args.codpais.trim(); + if (args.apartado) addressFields.apartado = args.apartado.trim(); + + let contactoInfo: any = null; + if (Object.keys(addressFields).length > 0) { + const proveedorEntity = result?.data ?? result; + const idcontacto = proveedorEntity?.idcontacto; + + if (idcontacto) { + try { + const contactoResult = await client.put( + `/contactos/${idcontacto}`, + addressFields + ); + contactoInfo = { + success: true, + idcontacto, + direccion_actualizada: addressFields, + }; + } catch (contactoError) { + const msg = contactoError instanceof Error ? contactoError.message : 'Error desconocido'; + contactoInfo = { + warning: `Proveedor creado pero no se pudo actualizar la dirección del contacto: ${msg}`, + idcontacto, + }; + } + } else { + contactoInfo = { + warning: 'Proveedor creado pero no se encontró idcontacto para actualizar la dirección.', + }; + } + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + message: `Proveedor "${(result?.data ?? result).nombre}" creado correctamente.`, + ...(contactoInfo ? { contacto: contactoInfo } : {}), + data: result?.data ?? result, + }, null, 2), + }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + const axiosError = error as any; + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Error al crear proveedor', + message: errorMessage, + details: axiosError?.response?.data || null, + }, null, 2), + }], + isError: true, + }; + } +} \ No newline at end of file From a8523887d6abba97bc940183d710b2eafd26333c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Mon, 16 Feb 2026 18:37:40 +0000 Subject: [PATCH 3/9] feat(facturaproveedores): add create invoice tool Implements create_factura_proveedor tool for creating supplier invoices with line items. Uses dedicated /crearFacturaProveedor endpoint to ensure totals are calculated atomically. Supports marking invoices as paid via /pagarFacturaProveedor endpoint. Validates all line items before submission to prevent partial failures. --- .../purchasing/facturaproveedores/index.ts | 3 +- .../purchasing/facturaproveedores/tool.ts | 279 ++++++++++++++++++ 2 files changed, 281 insertions(+), 1 deletion(-) diff --git a/src/modules/purchasing/facturaproveedores/index.ts b/src/modules/purchasing/facturaproveedores/index.ts index 32a39a4..b3d9467 100644 --- a/src/modules/purchasing/facturaproveedores/index.ts +++ b/src/modules/purchasing/facturaproveedores/index.ts @@ -1,2 +1,3 @@ export { FacturaproveedoresResource } from './resource.js'; -export { facturaProveedoresTool, handleFacturaProveedoresCall } from './tool.js'; \ No newline at end of file +export { facturaProveedoresTool, handleFacturaProveedoresCall } from './tool.js'; +export { createFacturaProveedorToolDefinition, createFacturaProveedorImplementation } from './tool.js'; diff --git a/src/modules/purchasing/facturaproveedores/tool.ts b/src/modules/purchasing/facturaproveedores/tool.ts index 75939b1..dcb076b 100644 --- a/src/modules/purchasing/facturaproveedores/tool.ts +++ b/src/modules/purchasing/facturaproveedores/tool.ts @@ -76,4 +76,283 @@ export async function handleFacturaProveedoresCall( isError: true, }; } +} + +interface LineaFactura { + descripcion: string; + cantidad: number; + pvpunitario: number; + referencia?: string; + codimpuesto?: string; + dtopor?: number; +} + +export const createFacturaProveedorToolDefinition = { + name: 'create_factura_proveedor', + description: 'Crea una nueva factura de proveedor en FacturaScripts. Permite especificar el proveedor, fecha, número de factura del proveedor y líneas de detalle con productos/servicios.', + inputSchema: { + type: 'object', + properties: { + codproveedor: { + type: 'string', + description: 'Código del proveedor (requerido). Usar get_proveedores para obtener códigos.', + }, + fecha: { + type: 'string', + description: 'Fecha de la factura en formato YYYY-MM-DD (por defecto: hoy)', + }, + numproveedor: { + type: 'string', + description: 'Número de factura asignado por el proveedor', + }, + codalmacen: { + type: 'string', + description: 'Código del almacén (ej: ALG)', + }, + codpago: { + type: 'string', + description: 'Código de forma de pago (ej: TRANS, CONT)', + }, + codserie: { + type: 'string', + description: 'Código de serie de la factura (ej: A, B, R). Usar get_series para ver las series disponibles.', + }, + observaciones: { + type: 'string', + description: 'Observaciones o notas sobre la factura', + }, + pagada: { + type: 'boolean', + description: 'Marcar la factura como pagada al crearla (por defecto: false). Busca el recibo generado automáticamente y lo marca como pagado.', + }, + lineas: { + type: 'array', + description: 'Líneas de la factura (requerido, al menos una)', + items: { + type: 'object', + properties: { + descripcion: { + type: 'string', + description: 'Descripción del producto/servicio (requerido)', + }, + cantidad: { + type: 'number', + description: 'Cantidad (requerido)', + }, + pvpunitario: { + type: 'number', + description: 'Precio unitario sin IVA (requerido)', + }, + referencia: { + type: 'string', + description: 'Referencia o código del producto', + }, + codimpuesto: { + type: 'string', + description: 'Código del impuesto (ej: IVA21, IVA10, IVA4). Por defecto: IVA21', + }, + dtopor: { + type: 'number', + description: 'Porcentaje de descuento (0-100)', + }, + }, + required: ['descripcion', 'cantidad', 'pvpunitario'], + }, + }, + }, + required: ['codproveedor', 'lineas'], + }, +}; + +export async function createFacturaProveedorImplementation( + args: Record, + client: FacturaScriptsClient +) { + try { + if (!args.codproveedor || typeof args.codproveedor !== 'string' || args.codproveedor.trim() === '') { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'El código del proveedor (codproveedor) es obligatorio.', + }, null, 2), + }], + isError: true, + }; + } + + if (!args.lineas || !Array.isArray(args.lineas) || args.lineas.length === 0) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'Se requiere al menos una línea en la factura.', + }, null, 2), + }], + isError: true, + }; + } + + // Validate each line + for (let i = 0; i < args.lineas.length; i++) { + const linea = args.lineas[i]; + if (!linea.descripcion || typeof linea.descripcion !== 'string') { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Error de validación', + message: `Línea ${i + 1}: la descripción es obligatoria.`, + }, null, 2), + }], + isError: true, + }; + } + if (typeof linea.cantidad !== 'number' || linea.cantidad <= 0) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Error de validación', + message: `Línea ${i + 1}: la cantidad debe ser un número mayor que 0.`, + }, null, 2), + }], + isError: true, + }; + } + if (typeof linea.pvpunitario !== 'number' || linea.pvpunitario < 0) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Error de validación', + message: `Línea ${i + 1}: el precio unitario debe ser un número no negativo.`, + }, null, 2), + }], + isError: true, + }; + } + } + + const codproveedor = args.codproveedor.trim(); + + // Build lines array for the JSON parameter + const lineas: LineaFactura[] = args.lineas.map((l: any) => ({ + descripcion: l.descripcion.trim(), + cantidad: l.cantidad, + pvpunitario: l.pvpunitario, + ...(l.referencia?.trim() ? { referencia: l.referencia.trim() } : {}), + ...(l.codimpuesto?.trim() ? { codimpuesto: l.codimpuesto.trim() } : {}), + ...(l.dtopor ? { dtopor: l.dtopor } : {}), + })); + + // Step 1: Create invoice with lines via dedicated endpoint + // Uses /crearFacturaProveedor which creates the document, saves lines, + // and calls Calculator::calculate() to compute neto/totaliva/total atomically. + const facturaData: Record = { + codproveedor, + lineas: JSON.stringify(lineas), + fecha: args.fecha || new Date().toISOString().split('T')[0], + }; + + if (args.numproveedor) facturaData.numproveedor = args.numproveedor.trim(); + if (args.codalmacen) facturaData.codalmacen = args.codalmacen.trim(); + if (args.codpago) facturaData.codpago = args.codpago.trim(); + if (args.codserie) facturaData.codserie = args.codserie.trim(); + if (args.observaciones) facturaData.observaciones = args.observaciones.trim(); + + const crearResult = await client.post('/crearFacturaProveedor', facturaData); + + // Response format: { doc: { ... }, lines: [ ... ] } + const facturaEntity = crearResult?.doc ?? crearResult?.data?.doc ?? crearResult; + const lineasCreadas = crearResult?.lines ?? crearResult?.data?.lines ?? []; + const rawId = facturaEntity?.idfactura; + const idfactura = rawId ? Number(rawId) : null; + + if (!idfactura) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Error al crear factura', + message: 'La factura se creó pero no se obtuvo el idfactura del servidor.', + data: crearResult, + }, null, 2), + }], + isError: true, + }; + } + + // Step 2: If pagada=true, use the dedicated pagarFacturaProveedor endpoint + let facturaFinal: any = facturaEntity; + let pagoInfo: any = null; + if (args.pagada === true) { + try { + const pagoData: Record = { + fechapago: facturaData.fecha, + codpago: facturaData.codpago || '', + pagada: 1, + }; + await client.post( + `/pagarFacturaProveedor/${idfactura}`, + pagoData + ); + + pagoInfo = { + success: true, + message: 'Factura marcada como pagada correctamente.', + }; + + // Re-fetch invoice to reflect updated payment status + try { + facturaFinal = await client.get(`/facturaproveedores/${idfactura}`); + } catch { + // Keep previous facturaFinal + } + } catch (pagoError) { + const msg = pagoError instanceof Error ? pagoError.message : 'Error desconocido'; + pagoInfo = { error: `Error al marcar factura como pagada: ${msg}` }; + } + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + message: `Factura de proveedor creada correctamente con ${lineasCreadas.length} línea(s).`, + resumen: { + codigo: facturaFinal.codigo || facturaFinal.numero || 'N/A', + idfactura, + codproveedor, + fecha: facturaData.fecha, + num_lineas: lineasCreadas.length, + neto: facturaFinal.neto ?? 0, + totaliva: facturaFinal.totaliva ?? 0, + total: facturaFinal.total ?? 0, + pagada: facturaFinal.pagada ?? false, + }, + ...(pagoInfo ? { pago: pagoInfo } : {}), + lineas: lineasCreadas, + data: facturaFinal, + }, null, 2), + }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + const axiosError = error as any; + return { + content: [{ + type: 'text', + text: JSON.stringify({ + error: 'Error al crear factura de proveedor', + message: errorMessage, + details: axiosError?.response?.data || null, + }, null, 2), + }], + isError: true, + }; + } } \ No newline at end of file From 91a06f733b5ab7f5000669e260c394e0bc3ef889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Mon, 16 Feb 2026 18:37:41 +0000 Subject: [PATCH 4/9] feat(server): register supplier and invoice creation tools Registers create_proveedor and create_factura_proveedor tools in the MCP server. Adds tool definitions to the list handler and implementation cases to the call handler. Enables clients to discover and invoke the new creation tools through the standard MCP protocol. --- src/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/index.ts b/src/index.ts index 70b60a5..21ea9d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,6 +110,8 @@ import { tarifasToolDefinition, tarifasToolImplementation } from './modules/conf import { totalModelesToolDefinition, totalModelesToolImplementation } from './modules/system/totalmodeles/index.js'; import { variantesToolDefinition, variantesToolImplementation } from './modules/core-business/variantes/index.js'; import { workEventesToolDefinition, workEventesToolImplementation } from './modules/system/workeventes/index.js'; +import { createProveedorToolDefinition, createProveedorImplementation } from './modules/core-business/proveedores/index.js'; +import { createFacturaProveedorToolDefinition, createFacturaProveedorImplementation } from './modules/purchasing/facturaproveedores/index.js'; const server = new Server( { @@ -2095,6 +2097,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, }, }, + createProveedorToolDefinition, + createFacturaProveedorToolDefinition, ], }; }); @@ -3836,6 +3840,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'create_proveedor': { + return await createProveedorImplementation(request.params.arguments as any, fsClient); + } + + case 'create_factura_proveedor': { + return await createFacturaProveedorImplementation(request.params.arguments as any, fsClient); + } + default: throw new Error(`Unknown tool: ${name}`); } From 4efd0ad1dc5858745e493485ab26f3b785515552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Mon, 16 Feb 2026 18:53:36 +0000 Subject: [PATCH 5/9] feat (sales): add create customer invoice tool Implements a new MCP tool for creating customer invoices (facturas de cliente) with full line item support. Enables atomic invoice creation through a dedicated endpoint that handles document creation, line items, and total calculations in a single operation. Includes comprehensive validation for required fields (customer code, line items) and line-level data (quantities, prices, descriptions). Supports optional parameters for payment terms, series, warehouse, address details, observations, and marking invoices as paid upon creation. Mirrors existing supplier invoice functionality to provide feature parity for customer-facing documents. --- src/index.ts | 7 +- .../sales-orders/facturaclientes/index.ts | 4 +- .../sales-orders/facturaclientes/tool.ts | 244 ++++++++++++++++++ 3 files changed, 253 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 21ea9d0..eb358d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,7 +87,7 @@ import { SeriesResource } from './modules/configuration/series/resource.js'; import { SubcuentasResource } from './modules/accounting/subcuentas/resource.js'; import { TarifasResource } from './modules/configuration/tarifas/resource.js'; // Import new tool functions -import { toolByCifnifImplementation, toolClientesMorososImplementation, toolClientesTopFacturacionImplementation, toolClientesSinComprasImplementation, toolExportarFacturaImplementation, toolClientesFrecuenciaComprasImplementation, toolFacturasConErroresImplementation, toolClientesPerdidosImplementation } from './modules/sales-orders/facturaclientes/tool.js'; +import { toolByCifnifImplementation, toolClientesMorososImplementation, toolClientesTopFacturacionImplementation, toolClientesSinComprasImplementation, toolExportarFacturaImplementation, toolClientesFrecuenciaComprasImplementation, toolFacturasConErroresImplementation, toolClientesPerdidosImplementation, createFacturaClienteToolDefinition, createFacturaClienteImplementation } from './modules/sales-orders/facturaclientes/tool.js'; import { toolTiempoBeneficiosImplementation } from './modules/sales-orders/facturaclientes/tool-tiempo-beneficios.js'; import { toolTiempoBeneficiosBulkImplementation } from './modules/sales-orders/facturaclientes/tool-tiempo-beneficios-bulk.js'; import { lowStockToolImplementation } from './modules/core-business/stocks/tool.js'; @@ -2099,6 +2099,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, createProveedorToolDefinition, createFacturaProveedorToolDefinition, + createFacturaClienteToolDefinition, ], }; }); @@ -3848,6 +3849,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await createFacturaProveedorImplementation(request.params.arguments as any, fsClient); } + case 'create_factura_cliente': { + return await createFacturaClienteImplementation(request.params.arguments as any, fsClient); + } + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/modules/sales-orders/facturaclientes/index.ts b/src/modules/sales-orders/facturaclientes/index.ts index 6606526..f51b247 100644 --- a/src/modules/sales-orders/facturaclientes/index.ts +++ b/src/modules/sales-orders/facturaclientes/index.ts @@ -9,7 +9,9 @@ export { toolClientesTopFacturacionDefinition as facturaclientesTopFacturacionToolDefinition, toolClientesTopFacturacionImplementation as facturaclientesTopFacturacionToolImplementation, toolClientesPerdidosDefinition as facturaclientesPerdidosToolDefinition, - toolClientesPerdidosImplementation as facturaclientesPerdidosToolImplementation + toolClientesPerdidosImplementation as facturaclientesPerdidosToolImplementation, + createFacturaClienteToolDefinition, + createFacturaClienteImplementation } from './tool.js'; export { toolTiempoBeneficiosDefinition as facturaclientesTiempoBeneficiosToolDefinition, diff --git a/src/modules/sales-orders/facturaclientes/tool.ts b/src/modules/sales-orders/facturaclientes/tool.ts index efea9c9..1a9b440 100644 --- a/src/modules/sales-orders/facturaclientes/tool.ts +++ b/src/modules/sales-orders/facturaclientes/tool.ts @@ -1346,6 +1346,250 @@ export async function toolFacturasConErroresImplementation( } } +// Tool definition for creating customer invoices +interface LineaFacturaCliente { + descripcion?: string; + referencia?: string; + cantidad: number; + pvpunitario: number; + dtopor?: number; + dtopor2?: number; + codimpuesto?: string; + irpf?: number; +} + +export const createFacturaClienteToolDefinition = { + name: 'create_factura_cliente', + description: 'Crea una nueva factura de cliente en FacturaScripts. Permite especificar el cliente, fecha, líneas de detalle con productos/servicios, y opcionalmente marcarla como pagada. Usa el endpoint dedicado crearFacturaCliente que crea el documento, añade líneas y calcula totales atómicamente.', + inputSchema: { + type: 'object', + properties: { + codcliente: { + type: 'string', + description: 'Código del cliente (requerido). Usar get_clientes para obtener códigos.', + }, + fecha: { + type: 'string', + description: 'Fecha de la factura en formato YYYY-MM-DD (por defecto: hoy)', + }, + hora: { + type: 'string', + description: 'Hora de la factura en formato HH:MM:SS', + }, + codpago: { + type: 'string', + description: 'Código de forma de pago (ej: TRANS, CONT). Usar get_formapagos para ver las disponibles.', + }, + codserie: { + type: 'string', + description: 'Código de serie de la factura (ej: A, B, R). Usar get_series para ver las series disponibles.', + }, + codalmacen: { + type: 'string', + description: 'Código del almacén (ej: ALG)', + }, + direccion: { + type: 'string', + description: 'Dirección del cliente para esta factura', + }, + ciudad: { + type: 'string', + description: 'Ciudad del cliente para esta factura', + }, + provincia: { + type: 'string', + description: 'Provincia del cliente para esta factura', + }, + observaciones: { + type: 'string', + description: 'Observaciones o notas sobre la factura', + }, + pagada: { + type: 'boolean', + description: 'Marcar la factura como pagada al crearla (por defecto: false)', + }, + lineas: { + type: 'array', + description: 'Líneas de la factura (requerido, al menos una)', + items: { + type: 'object', + properties: { + descripcion: { + type: 'string', + description: 'Descripción del producto/servicio', + }, + referencia: { + type: 'string', + description: 'Referencia o código del producto', + }, + cantidad: { + type: 'number', + description: 'Cantidad (requerido)', + }, + pvpunitario: { + type: 'number', + description: 'Precio unitario sin IVA (requerido)', + }, + dtopor: { + type: 'number', + description: 'Porcentaje de descuento (0-100)', + }, + dtopor2: { + type: 'number', + description: 'Segundo porcentaje de descuento (0-100)', + }, + codimpuesto: { + type: 'string', + description: 'Código del impuesto (ej: IVA21, IVA10, IVA4). Por defecto: IVA21', + }, + irpf: { + type: 'number', + description: 'Porcentaje de retención IRPF', + }, + }, + required: ['cantidad', 'pvpunitario'], + }, + }, + }, + required: ['codcliente', 'lineas'], + }, +}; + +export async function createFacturaClienteImplementation( + args: Record, + client: FacturaScriptsClient +) { + try { + // Validate codcliente + if (!args.codcliente || typeof args.codcliente !== 'string' || args.codcliente.trim() === '') { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'El código del cliente (codcliente) es obligatorio.', + }, null, 2), + }], + isError: true, + }; + } + + // Validate lineas + if (!args.lineas || !Array.isArray(args.lineas) || args.lineas.length === 0) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'Se requiere al menos una línea en la factura.', + }, null, 2), + }], + isError: true, + }; + } + + // Validate each line + for (let i = 0; i < args.lineas.length; i++) { + const linea = args.lineas[i]; + if (!linea.descripcion && !linea.referencia) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Error de validación', + message: `Línea ${i + 1}: se requiere al menos una descripción o referencia.`, + }, null, 2), + }], + isError: true, + }; + } + if (typeof linea.cantidad !== 'number' || linea.cantidad <= 0) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Error de validación', + message: `Línea ${i + 1}: la cantidad debe ser un número mayor que 0.`, + }, null, 2), + }], + isError: true, + }; + } + if (typeof linea.pvpunitario !== 'number' || linea.pvpunitario < 0) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Error de validación', + message: `Línea ${i + 1}: el precio unitario debe ser un número no negativo.`, + }, null, 2), + }], + isError: true, + }; + } + } + + const codcliente = args.codcliente.trim(); + + // Build lines array + const lineas: LineaFacturaCliente[] = args.lineas.map((l: any) => ({ + ...(l.descripcion?.trim() ? { descripcion: l.descripcion.trim() } : {}), + ...(l.referencia?.trim() ? { referencia: l.referencia.trim() } : {}), + cantidad: l.cantidad, + pvpunitario: l.pvpunitario, + ...(l.dtopor ? { dtopor: l.dtopor } : {}), + ...(l.dtopor2 ? { dtopor2: l.dtopor2 } : {}), + ...(l.codimpuesto?.trim() ? { codimpuesto: l.codimpuesto.trim() } : {}), + ...(l.irpf ? { irpf: l.irpf } : {}), + })); + + // Build invoice data — clean, no workarounds + const facturaData: Record = { + codcliente, + lineas: JSON.stringify(lineas), + }; + + if (args.fecha) facturaData.fecha = args.fecha.trim(); + if (args.hora) facturaData.hora = args.hora.trim(); + if (args.codpago) facturaData.codpago = args.codpago.trim(); + if (args.codserie) facturaData.codserie = args.codserie.trim(); + if (args.codalmacen) facturaData.codalmacen = args.codalmacen.trim(); + if (args.direccion) facturaData.direccion = args.direccion.trim(); + if (args.ciudad) facturaData.ciudad = args.ciudad.trim(); + if (args.provincia) facturaData.provincia = args.provincia.trim(); + if (args.observaciones) facturaData.observaciones = args.observaciones.trim(); + if (args.pagada === true) facturaData.pagada = 1; + + // POST directly to the dedicated endpoint + const result = await client.post('/crearFacturaCliente', facturaData); + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: true, + message: `Factura de cliente creada correctamente.`, + data: result, + }, null, 2), + }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + const axiosError = error as any; + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Error al crear factura de cliente', + message: errorMessage, + details: axiosError?.response?.data || null, + }, null, 2), + }], + isError: true, + }; + } +} + // Tool definition for lost clients (clients who had invoices but none within specified date range) export const toolClientesPerdidosDefinition = { name: 'get_clientes_perdidos', From 8d27253ea4101fcb0ee3900d2876bc4f597efc13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Mon, 16 Feb 2026 22:35:36 +0000 Subject: [PATCH 6/9] feat (productos): add create product tool Adds a new tool that enables creating products in FacturaScripts with comprehensive configuration options. The implementation supports specifying product reference, description, pricing, family, manufacturer, tax code, and purchase/sale/stock settings. Exports the new tool definition and implementation from the productos module and registers both the tool definition in the tools list and the handler in the request dispatcher to make the functionality available through the API. --- src/index.ts | 7 +- src/modules/core-business/productos/index.ts | 4 +- src/modules/core-business/productos/tool.ts | 147 +++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index eb358d0..a97e9c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,7 +92,7 @@ import { toolTiempoBeneficiosImplementation } from './modules/sales-orders/factu import { toolTiempoBeneficiosBulkImplementation } from './modules/sales-orders/facturaclientes/tool-tiempo-beneficios-bulk.js'; import { lowStockToolImplementation } from './modules/core-business/stocks/tool.js'; import { toolProductosMasVendidosImplementation } from './modules/sales-orders/line-items/lineafacturaclientes/tool.js'; -import { productosNoVendidosToolDefinition, productosNoVendidosToolImplementation } from './modules/core-business/productos/index.js'; +import { productosNoVendidosToolDefinition, productosNoVendidosToolImplementation, createProductoToolDefinition, createProductoImplementation } from './modules/core-business/productos/index.js'; import { partidasToolDefinition, partidasToolImplementation } from './modules/accounting/partidas/index.js'; import { pedidoproveedoresToolDefinition, pedidoproveedoresToolImplementation } from './modules/purchasing/pedidoproveedores/index.js'; import { presupuestoproveedoresToolDefinition, presupuestoproveedoresToolImplementation } from './modules/purchasing/presupuestoproveedores/index.js'; @@ -2100,6 +2100,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { createProveedorToolDefinition, createFacturaProveedorToolDefinition, createFacturaClienteToolDefinition, + createProductoToolDefinition, ], }; }); @@ -3853,6 +3854,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await createFacturaClienteImplementation(request.params.arguments as any, fsClient); } + case 'create_producto': { + return await createProductoImplementation(request.params.arguments as any, fsClient); + } + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/modules/core-business/productos/index.ts b/src/modules/core-business/productos/index.ts index b99f442..dec96d3 100644 --- a/src/modules/core-business/productos/index.ts +++ b/src/modules/core-business/productos/index.ts @@ -3,5 +3,7 @@ export { toolDefinition as productosToolDefinition, toolImplementation as productosToolImplementation, noVendidosToolDefinition as productosNoVendidosToolDefinition, - noVendidosToolImplementation as productosNoVendidosToolImplementation + noVendidosToolImplementation as productosNoVendidosToolImplementation, + createProductoToolDefinition, + createProductoImplementation } from './tool.js'; \ No newline at end of file diff --git a/src/modules/core-business/productos/tool.ts b/src/modules/core-business/productos/tool.ts index e9f5c09..bb8e2df 100644 --- a/src/modules/core-business/productos/tool.ts +++ b/src/modules/core-business/productos/tool.ts @@ -1,3 +1,5 @@ +import { FacturaScriptsClient } from '../../../fs/client.js'; + export const toolDefinition = { name: 'get_productos', description: 'Obtiene la lista de productos con paginación y filtros avanzados', @@ -39,6 +41,151 @@ export const toolImplementation = async (resource: any, buildUri: (resourceName: }; }; +// Tool definition for creating products +export const createProductoToolDefinition = { + name: 'create_producto', + description: 'Crea un nuevo producto en FacturaScripts. Permite especificar referencia, descripción, precio, familia, fabricante, impuesto y opciones de compra/venta/stock.', + inputSchema: { + type: 'object', + properties: { + referencia: { + type: 'string', + description: 'Código/referencia del producto (requerido)', + }, + descripcion: { + type: 'string', + description: 'Descripción del producto (requerido)', + }, + precio: { + type: 'number', + description: 'Precio de venta sin IVA', + }, + codfamilia: { + type: 'string', + description: 'Código de familia/categoría. Usar get_familias para ver las disponibles.', + }, + codfabricante: { + type: 'string', + description: 'Código del fabricante. Usar get_fabricantes para ver los disponibles.', + }, + codimpuesto: { + type: 'string', + description: 'Código del impuesto (ej: IVA21, IVA10, IVA4). Usar get_impuestos para ver los disponibles.', + }, + secompra: { + type: 'boolean', + description: 'El producto se puede comprar (por defecto: true)', + }, + sevende: { + type: 'boolean', + description: 'El producto se puede vender (por defecto: true)', + }, + nostock: { + type: 'boolean', + description: 'No controlar stock para este producto (por defecto: false)', + }, + publico: { + type: 'boolean', + description: 'Producto visible públicamente (por defecto: false)', + }, + ventasinstock: { + type: 'boolean', + description: 'Permitir venta sin stock (por defecto: false)', + }, + tipo: { + type: 'string', + description: 'Tipo de producto', + }, + observaciones: { + type: 'string', + description: 'Observaciones o notas sobre el producto', + }, + }, + required: ['referencia', 'descripcion'], + }, +}; + +export async function createProductoImplementation( + args: Record, + client: FacturaScriptsClient +) { + try { + // Validate referencia + if (!args.referencia || typeof args.referencia !== 'string' || args.referencia.trim() === '') { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'La referencia del producto es obligatoria.', + }, null, 2), + }], + isError: true, + }; + } + + // Validate descripcion + if (!args.descripcion || typeof args.descripcion !== 'string' || args.descripcion.trim() === '') { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Parámetro requerido', + message: 'La descripción del producto es obligatoria.', + }, null, 2), + }], + isError: true, + }; + } + + // Build product data — clean, no workarounds + const productoData: Record = { + referencia: args.referencia.trim(), + descripcion: args.descripcion.trim(), + }; + + if (args.precio !== undefined) productoData.precio = args.precio; + if (args.codfamilia) productoData.codfamilia = args.codfamilia.trim(); + if (args.codfabricante) productoData.codfabricante = args.codfabricante.trim(); + if (args.codimpuesto) productoData.codimpuesto = args.codimpuesto.trim(); + if (args.tipo) productoData.tipo = args.tipo.trim(); + if (args.observaciones) productoData.observaciones = args.observaciones.trim(); + if (args.secompra !== undefined) productoData.secompra = args.secompra ? 1 : 0; + if (args.sevende !== undefined) productoData.sevende = args.sevende ? 1 : 0; + if (args.nostock !== undefined) productoData.nostock = args.nostock ? 1 : 0; + if (args.publico !== undefined) productoData.publico = args.publico ? 1 : 0; + if (args.ventasinstock !== undefined) productoData.ventasinstock = args.ventasinstock ? 1 : 0; + + // POST directly to the generic endpoint + const result = await client.post('/productos', productoData); + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + success: true, + message: 'Producto creado correctamente.', + data: result, + }, null, 2), + }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + const axiosError = error as any; + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + error: 'Error al crear producto', + message: errorMessage, + details: axiosError?.response?.data || null, + }, null, 2), + }], + isError: true, + }; + } +} + export const noVendidosToolDefinition = { name: 'get_productos_no_vendidos', description: 'Obtiene productos que no han sido vendidos (no aparecen en líneas de facturas de clientes) en un período específico', From 196baebf19af0ae7112588e936e691aa670089a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Sun, 22 Mar 2026 01:17:07 +0000 Subject: [PATCH 7/9] test: add unit tests for POST/PUT/DELETE client methods and creation tools --- tests/unit/fs/client.test.ts | 157 ++++++++- .../modules/core-business/productos.test.ts | 160 +++++++++- .../modules/core-business/proveedores.test.ts | 234 ++++++++++++++ .../purchasing/facturaproveedores.test.ts | 301 ++++++++++++++++++ .../sales-orders/facturaclientes.test.ts | 211 +++++++++++- 5 files changed, 1057 insertions(+), 6 deletions(-) diff --git a/tests/unit/fs/client.test.ts b/tests/unit/fs/client.test.ts index 6bdb38e..e13d94b 100644 --- a/tests/unit/fs/client.test.ts +++ b/tests/unit/fs/client.test.ts @@ -19,9 +19,12 @@ describe('FacturaScriptsClient', () => { beforeEach(() => { mockAxiosInstance = { - get: vi.fn() + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), }; - + mockedAxios.create.mockReturnValue(mockAxiosInstance); client = new FacturaScriptsClient(); }); @@ -149,7 +152,7 @@ describe('FacturaScriptsClient', () => { }); it('should handle non-array response', async () => { - mockAxiosInstance.get.mockResolvedValue({ + mockAxiosInstance.get.mockResolvedValue({ data: null, headers: {} }); @@ -160,4 +163,152 @@ describe('FacturaScriptsClient', () => { expect(result.meta.total).toBe(0); }); }); + + describe('post', () => { + it('should make POST request with form-urlencoded content type', async () => { + const mockResponse = { idproducto: 1, referencia: 'REF001' }; + mockAxiosInstance.post.mockResolvedValue({ data: mockResponse }); + + await client.post('/productos', { referencia: 'REF001', descripcion: 'Test' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/productos', + expect.any(String), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ); + }); + + it('should return response data', async () => { + const mockResponse = { idproducto: 1, referencia: 'REF001' }; + mockAxiosInstance.post.mockResolvedValue({ data: mockResponse }); + + const result = await client.post('/productos', { referencia: 'REF001' }); + + expect(result).toEqual(mockResponse); + }); + + it('should serialize flat object to form-urlencoded string', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: {} }); + + await client.post('/test', { nombre: 'Test', cifnif: 'B12345' }); + + const sentBody = mockAxiosInstance.post.mock.calls[0][1]; + expect(sentBody).toContain('nombre=Test'); + expect(sentBody).toContain('cifnif=B12345'); + }); + + it('should skip null and undefined values', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: {} }); + + await client.post('/test', { a: 'ok', b: null, c: undefined }); + + const sentBody = mockAxiosInstance.post.mock.calls[0][1]; + expect(sentBody).toContain('a=ok'); + expect(sentBody).not.toContain('b='); + expect(sentBody).not.toContain('c='); + }); + + it('should serialize nested objects with bracket notation', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: {} }); + + await client.post('/test', { address: { city: 'Madrid', zip: '28001' } }); + + const sentBody = mockAxiosInstance.post.mock.calls[0][1]; + const params = new URLSearchParams(sentBody); + expect(params.get('address[city]')).toBe('Madrid'); + expect(params.get('address[zip]')).toBe('28001'); + }); + + it('should serialize arrays with index bracket notation', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: {} }); + + await client.post('/test', { tags: ['a', 'b'] }); + + const sentBody = mockAxiosInstance.post.mock.calls[0][1]; + const params = new URLSearchParams(sentBody); + expect(params.get('tags[0]')).toBe('a'); + expect(params.get('tags[1]')).toBe('b'); + }); + + it('should serialize arrays of objects with nested bracket notation', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: {} }); + + await client.post('/test', { lineas: [{ desc: 'Item1', qty: 1 }] }); + + const sentBody = mockAxiosInstance.post.mock.calls[0][1]; + const params = new URLSearchParams(sentBody); + expect(params.get('lineas[0][desc]')).toBe('Item1'); + expect(params.get('lineas[0][qty]')).toBe('1'); + }); + + it('should convert booleans and numbers to strings', async () => { + mockAxiosInstance.post.mockResolvedValue({ data: {} }); + + await client.post('/test', { active: true, count: 42 }); + + const sentBody = mockAxiosInstance.post.mock.calls[0][1]; + expect(sentBody).toContain('active=true'); + expect(sentBody).toContain('count=42'); + }); + + it('should propagate errors', async () => { + mockAxiosInstance.post.mockRejectedValue(new Error('Network error')); + + await expect(client.post('/test', { a: 1 })).rejects.toThrow('Network error'); + }); + }); + + describe('put', () => { + it('should make PUT request with form-urlencoded content type', async () => { + mockAxiosInstance.put.mockResolvedValue({ data: { updated: true } }); + + await client.put('/contactos/42', { direccion: 'Calle Test 1' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith( + '/contactos/42', + expect.any(String), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ); + }); + + it('should return response data', async () => { + const mockResponse = { idcontacto: 42, direccion: 'Calle Test 1' }; + mockAxiosInstance.put.mockResolvedValue({ data: mockResponse }); + + const result = await client.put('/contactos/42', { direccion: 'Calle Test 1' }); + + expect(result).toEqual(mockResponse); + }); + + it('should propagate errors', async () => { + mockAxiosInstance.put.mockRejectedValue(new Error('Not found')); + + await expect(client.put('/contactos/999', { a: 1 })).rejects.toThrow('Not found'); + }); + }); + + describe('delete', () => { + it('should make DELETE request to the endpoint', async () => { + mockAxiosInstance.delete.mockResolvedValue({ data: { success: true } }); + + await client.delete('/proveedores/PROV001'); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/proveedores/PROV001'); + }); + + it('should return response data', async () => { + const mockResponse = { success: true }; + mockAxiosInstance.delete.mockResolvedValue({ data: mockResponse }); + + const result = await client.delete('/proveedores/PROV001'); + + expect(result).toEqual(mockResponse); + }); + + it('should propagate errors', async () => { + mockAxiosInstance.delete.mockRejectedValue(new Error('Forbidden')); + + await expect(client.delete('/proveedores/PROV001')).rejects.toThrow('Forbidden'); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/modules/core-business/productos.test.ts b/tests/unit/modules/core-business/productos.test.ts index c6c02ac..c7ecb38 100644 --- a/tests/unit/modules/core-business/productos.test.ts +++ b/tests/unit/modules/core-business/productos.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ProductosResource, Producto } from '../../../../src/modules/core-business/productos/resource.js'; import { FacturaScriptsClient } from '../../../../src/fs/client.js'; -import { noVendidosToolImplementation } from '../../../../src/modules/core-business/productos/tool.js'; +import { noVendidosToolImplementation, createProductoImplementation } from '../../../../src/modules/core-business/productos/tool.js'; vi.mock('../../../../src/fs/client.js'); @@ -432,7 +432,7 @@ describe('ProductosNoVendidosTool', () => { expect(response.periodo.descripcion).toBe('Análisis de productos no vendidos desde 2024-01-01'); }); - it('should filter out products without reference', async () => { + it('should filter out products without referencia', async () => { const productsWithMissingRefs = [ { referencia: 'PROD001', @@ -467,4 +467,160 @@ describe('ProductosNoVendidosTool', () => { expect(response.data[0].referencia).toBe('PROD001'); }); }); +}); + +describe('createProductoImplementation', () => { + let mockClient: any; + + beforeEach(() => { + mockClient = { + post: vi.fn(), + }; + }); + + describe('validation', () => { + it('should return error when referencia is missing', async () => { + const result = await createProductoImplementation({ descripcion: 'Test' }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('referencia'); + }); + + it('should return error when referencia is empty string', async () => { + const result = await createProductoImplementation({ referencia: '', descripcion: 'Test' }, mockClient); + + expect(result.isError).toBe(true); + }); + + it('should return error when referencia is whitespace only', async () => { + const result = await createProductoImplementation({ referencia: ' ', descripcion: 'Test' }, mockClient); + + expect(result.isError).toBe(true); + }); + + it('should return error when descripcion is missing', async () => { + const result = await createProductoImplementation({ referencia: 'REF001' }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('descripción'); + }); + + it('should return error when descripcion is empty string', async () => { + const result = await createProductoImplementation({ referencia: 'REF001', descripcion: '' }, mockClient); + + expect(result.isError).toBe(true); + }); + }); + + describe('success', () => { + it('should create product with required fields only', async () => { + const apiResponse = { idproducto: 1, referencia: 'REF001', descripcion: 'Test Product' }; + mockClient.post.mockResolvedValue(apiResponse); + + const result = await createProductoImplementation( + { referencia: 'REF001', descripcion: 'Test Product' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBeUndefined(); + expect(response.success).toBe(true); + expect(response.data).toEqual(apiResponse); + expect(mockClient.post).toHaveBeenCalledWith('/productos', { + referencia: 'REF001', + descripcion: 'Test Product', + }); + }); + + it('should create product with all optional fields', async () => { + mockClient.post.mockResolvedValue({ idproducto: 1 }); + + await createProductoImplementation({ + referencia: 'REF001', + descripcion: 'Test', + precio: 19.99, + codfamilia: 'FAM01', + codfabricante: 'FAB01', + codimpuesto: 'IVA21', + tipo: 'producto', + observaciones: 'Notas', + secompra: true, + sevende: false, + nostock: true, + publico: false, + ventasinstock: true, + }, mockClient); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.precio).toBe(19.99); + expect(sentData.codfamilia).toBe('FAM01'); + expect(sentData.codfabricante).toBe('FAB01'); + expect(sentData.codimpuesto).toBe('IVA21'); + expect(sentData.tipo).toBe('producto'); + expect(sentData.observaciones).toBe('Notas'); + expect(sentData.secompra).toBe(1); + expect(sentData.sevende).toBe(0); + expect(sentData.nostock).toBe(1); + expect(sentData.publico).toBe(0); + expect(sentData.ventasinstock).toBe(1); + }); + + it('should trim string fields', async () => { + mockClient.post.mockResolvedValue({ idproducto: 1 }); + + await createProductoImplementation( + { referencia: ' REF001 ', descripcion: ' Test ' }, + mockClient + ); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.referencia).toBe('REF001'); + expect(sentData.descripcion).toBe('Test'); + }); + + it('should convert boolean fields to 0/1', async () => { + mockClient.post.mockResolvedValue({ idproducto: 1 }); + + await createProductoImplementation( + { referencia: 'REF001', descripcion: 'Test', secompra: true, sevende: false }, + mockClient + ); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.secompra).toBe(1); + expect(sentData.sevende).toBe(0); + }); + }); + + describe('error handling', () => { + it('should handle API errors gracefully', async () => { + mockClient.post.mockRejectedValue(new Error('API connection failed')); + + const result = await createProductoImplementation( + { referencia: 'REF001', descripcion: 'Test' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.error).toBe('Error al crear producto'); + expect(response.message).toBe('API connection failed'); + }); + + it('should include API response details in error', async () => { + const axiosError: any = new Error('Bad Request'); + axiosError.response = { data: { code: 400, message: 'Duplicate referencia' } }; + mockClient.post.mockRejectedValue(axiosError); + + const result = await createProductoImplementation( + { referencia: 'REF001', descripcion: 'Test' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(response.details).toEqual({ code: 400, message: 'Duplicate referencia' }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/modules/core-business/proveedores.test.ts b/tests/unit/modules/core-business/proveedores.test.ts index 7a0b2d2..a5bb57d 100644 --- a/tests/unit/modules/core-business/proveedores.test.ts +++ b/tests/unit/modules/core-business/proveedores.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ProveedoresResource } from '../../../../src/modules/core-business/proveedores/resource.js'; +import { createProveedorImplementation } from '../../../../src/modules/core-business/proveedores/tool.js'; import type { Proveedor } from '../../../../src/types/facturascripts.js'; describe('ProveedoresResource', () => { @@ -129,4 +130,237 @@ describe('ProveedoresResource', () => { expect(result.contents[0].text).toContain('Unknown error'); }); }); +}); + +describe('createProveedorImplementation', () => { + let mockClient: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = { + post: vi.fn(), + put: vi.fn(), + }; + }); + + describe('validation', () => { + it('should return error when nombre is missing', async () => { + const result = await createProveedorImplementation({ cifnif: 'B12345' }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('nombre'); + }); + + it('should return error when nombre is empty/whitespace', async () => { + const result = await createProveedorImplementation({ nombre: ' ', cifnif: 'B12345' }, mockClient); + + expect(result.isError).toBe(true); + }); + + it('should return error when cifnif is missing', async () => { + const result = await createProveedorImplementation({ nombre: 'Test' }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('CIF/NIF'); + }); + + it('should return error when cifnif is empty/whitespace', async () => { + const result = await createProveedorImplementation({ nombre: 'Test', cifnif: ' ' }, mockClient); + + expect(result.isError).toBe(true); + }); + }); + + describe('success without address', () => { + it('should create supplier with required fields only', async () => { + const apiResponse = { codproveedor: 'PROV001', nombre: 'Test', cifnif: 'B12345' }; + mockClient.post.mockResolvedValue(apiResponse); + + const result = await createProveedorImplementation( + { nombre: 'Test', cifnif: 'B12345' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBeUndefined(); + expect(response.success).toBe(true); + expect(response.message).toContain('Test'); + expect(mockClient.post).toHaveBeenCalledWith('/proveedores', { + nombre: 'Test', + cifnif: 'B12345', + razonsocial: 'Test', + }); + expect(mockClient.put).not.toHaveBeenCalled(); + }); + + it('should use razonsocial if provided', async () => { + mockClient.post.mockResolvedValue({ codproveedor: 'PROV001', nombre: 'Test' }); + + await createProveedorImplementation( + { nombre: 'Test', cifnif: 'B12345', razonsocial: 'Razon Social S.L.' }, + mockClient + ); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.razonsocial).toBe('Razon Social S.L.'); + }); + + it('should fall back razonsocial to nombre when not provided', async () => { + mockClient.post.mockResolvedValue({ codproveedor: 'PROV001', nombre: 'Test' }); + + await createProveedorImplementation( + { nombre: 'Test', cifnif: 'B12345' }, + mockClient + ); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.razonsocial).toBe('Test'); + }); + + it('should include all optional contact fields', async () => { + mockClient.post.mockResolvedValue({ codproveedor: 'PROV001', nombre: 'Test' }); + + await createProveedorImplementation({ + nombre: 'Test', + cifnif: 'B12345', + email: 'test@example.com', + telefono1: '600111222', + telefono2: '600333444', + web: 'https://example.com', + codpago: 'TRANS', + observaciones: 'Notes', + tipoidfiscal: 'CIF', + regimeniva: 'General', + }, mockClient); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.email).toBe('test@example.com'); + expect(sentData.telefono1).toBe('600111222'); + expect(sentData.telefono2).toBe('600333444'); + expect(sentData.web).toBe('https://example.com'); + expect(sentData.codpago).toBe('TRANS'); + expect(sentData.observaciones).toBe('Notes'); + expect(sentData.tipoidfiscal).toBe('CIF'); + expect(sentData.regimeniva).toBe('General'); + }); + }); + + describe('success with address (PUT contact flow)', () => { + it('should update contact address when idcontacto is present', async () => { + mockClient.post.mockResolvedValue({ + codproveedor: 'PROV001', + nombre: 'Test', + cifnif: 'B12345', + idcontacto: 42, + }); + mockClient.put.mockResolvedValue({ idcontacto: 42, direccion: 'Calle Test 1' }); + + const result = await createProveedorImplementation({ + nombre: 'Test', + cifnif: 'B12345', + direccion: 'Calle Test 1', + codpostal: '28001', + ciudad: 'Madrid', + }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(mockClient.put).toHaveBeenCalledWith('/contactos/42', { + direccion: 'Calle Test 1', + codpostal: '28001', + ciudad: 'Madrid', + }); + expect(response.contacto.success).toBe(true); + expect(response.contacto.idcontacto).toBe(42); + }); + + it('should handle missing idcontacto in response', async () => { + mockClient.post.mockResolvedValue({ + codproveedor: 'PROV001', + nombre: 'Test', + cifnif: 'B12345', + }); + + const result = await createProveedorImplementation({ + nombre: 'Test', + cifnif: 'B12345', + direccion: 'Calle Test 1', + }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(response.success).toBe(true); + expect(response.contacto.warning).toContain('idcontacto'); + expect(mockClient.put).not.toHaveBeenCalled(); + }); + + it('should handle contact update failure gracefully', async () => { + mockClient.post.mockResolvedValue({ + codproveedor: 'PROV001', + nombre: 'Test', + cifnif: 'B12345', + idcontacto: 42, + }); + mockClient.put.mockRejectedValue(new Error('Contact update failed')); + + const result = await createProveedorImplementation({ + nombre: 'Test', + cifnif: 'B12345', + direccion: 'Calle Test 1', + }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(response.success).toBe(true); + expect(response.contacto.warning).toContain('Contact update failed'); + expect(response.contacto.idcontacto).toBe(42); + }); + + it('should not call PUT when no address fields are provided', async () => { + mockClient.post.mockResolvedValue({ + codproveedor: 'PROV001', + nombre: 'Test', + cifnif: 'B12345', + idcontacto: 42, + }); + + const result = await createProveedorImplementation( + { nombre: 'Test', cifnif: 'B12345' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(mockClient.put).not.toHaveBeenCalled(); + expect(response.contacto).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should handle API errors during supplier creation', async () => { + mockClient.post.mockRejectedValue(new Error('API connection failed')); + + const result = await createProveedorImplementation( + { nombre: 'Test', cifnif: 'B12345' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.error).toBe('Error al crear proveedor'); + expect(response.message).toBe('API connection failed'); + }); + + it('should include API response details in error', async () => { + const axiosError: any = new Error('Bad Request'); + axiosError.response = { data: { code: 400, message: 'Duplicate CIF/NIF' } }; + mockClient.post.mockRejectedValue(axiosError); + + const result = await createProveedorImplementation( + { nombre: 'Test', cifnif: 'B12345' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(response.details).toEqual({ code: 400, message: 'Duplicate CIF/NIF' }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/modules/purchasing/facturaproveedores.test.ts b/tests/unit/modules/purchasing/facturaproveedores.test.ts index c6029f1..8c338c6 100644 --- a/tests/unit/modules/purchasing/facturaproveedores.test.ts +++ b/tests/unit/modules/purchasing/facturaproveedores.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { FacturaproveedoresResource } from '../../../../src/modules/purchasing/facturaproveedores/resource.js'; +import { createFacturaProveedorImplementation } from '../../../../src/modules/purchasing/facturaproveedores/tool.js'; import type { FacturaProveedor } from '../../../../src/types/facturascripts.js'; describe('FacturaproveedoresResource', () => { @@ -134,4 +135,304 @@ describe('FacturaproveedoresResource', () => { expect(result.contents[0].text).toContain(errorMessage); }); }); +}); + +describe('createFacturaProveedorImplementation', () => { + let mockClient: any; + + const validArgs = { + codproveedor: 'PROV001', + lineas: [{ descripcion: 'Servicio consultoria', cantidad: 1, pvpunitario: 100 }], + }; + + const mockCrearResponse = { + doc: { + idfactura: '14', + codigo: 'FPROV001', + codproveedor: 'PROV001', + fecha: '2026-03-22', + neto: 100, + totaliva: 21, + total: 121, + pagada: false, + }, + lines: [{ idlinea: 1, descripcion: 'Servicio consultoria', cantidad: 1, pvpunitario: 100 }], + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = { + post: vi.fn(), + get: vi.fn(), + }; + }); + + describe('validation', () => { + it('should return error when codproveedor is missing', async () => { + const result = await createFacturaProveedorImplementation( + { lineas: [{ descripcion: 'Test', cantidad: 1, pvpunitario: 10 }] }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('codproveedor'); + }); + + it('should return error when codproveedor is empty/whitespace', async () => { + const result = await createFacturaProveedorImplementation( + { codproveedor: ' ', lineas: [{ descripcion: 'Test', cantidad: 1, pvpunitario: 10 }] }, + mockClient + ); + + expect(result.isError).toBe(true); + }); + + it('should return error when lineas is missing', async () => { + const result = await createFacturaProveedorImplementation( + { codproveedor: 'PROV001' }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('línea'); + }); + + it('should return error when lineas is empty array', async () => { + const result = await createFacturaProveedorImplementation( + { codproveedor: 'PROV001', lineas: [] }, + mockClient + ); + + expect(result.isError).toBe(true); + }); + + it('should return error when lineas is not an array', async () => { + const result = await createFacturaProveedorImplementation( + { codproveedor: 'PROV001', lineas: 'not-an-array' }, + mockClient + ); + + expect(result.isError).toBe(true); + }); + + it('should return error when line has no descripcion', async () => { + const result = await createFacturaProveedorImplementation( + { codproveedor: 'PROV001', lineas: [{ cantidad: 1, pvpunitario: 10 }] }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('Línea 1'); + expect(response.message).toContain('descripción'); + }); + + it('should return error when line has cantidad <= 0', async () => { + const result = await createFacturaProveedorImplementation( + { codproveedor: 'PROV001', lineas: [{ descripcion: 'Test', cantidad: 0, pvpunitario: 10 }] }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('Línea 1'); + expect(response.message).toContain('cantidad'); + }); + + it('should return error when line has negative pvpunitario', async () => { + const result = await createFacturaProveedorImplementation( + { codproveedor: 'PROV001', lineas: [{ descripcion: 'Test', cantidad: 1, pvpunitario: -5 }] }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('Línea 1'); + expect(response.message).toContain('precio unitario'); + }); + }); + + describe('success without payment', () => { + it('should create invoice with minimal required fields', async () => { + mockClient.post.mockResolvedValue(mockCrearResponse); + + const result = await createFacturaProveedorImplementation(validArgs, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBeUndefined(); + expect(response.success).toBe(true); + expect(response.resumen.idfactura).toBe(14); + expect(response.resumen.codproveedor).toBe('PROV001'); + expect(response.resumen.num_lineas).toBe(1); + expect(response.lineas).toHaveLength(1); + + // Verify POST was called with correct endpoint and serialized lineas + expect(mockClient.post).toHaveBeenCalledWith( + '/crearFacturaProveedor', + expect.objectContaining({ + codproveedor: 'PROV001', + lineas: expect.any(String), + }) + ); + + // Verify lineas was JSON-stringified + const sentData = mockClient.post.mock.calls[0][1]; + const parsedLineas = JSON.parse(sentData.lineas); + expect(parsedLineas[0].descripcion).toBe('Servicio consultoria'); + }); + + it('should create invoice with all optional header fields', async () => { + mockClient.post.mockResolvedValue(mockCrearResponse); + + await createFacturaProveedorImplementation({ + ...validArgs, + fecha: '2026-01-15', + numproveedor: 'FP-2026-001', + codalmacen: 'ALG', + codpago: 'TRANS', + codserie: 'A', + observaciones: 'Test invoice', + }, mockClient); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.fecha).toBe('2026-01-15'); + expect(sentData.numproveedor).toBe('FP-2026-001'); + expect(sentData.codalmacen).toBe('ALG'); + expect(sentData.codpago).toBe('TRANS'); + expect(sentData.codserie).toBe('A'); + expect(sentData.observaciones).toBe('Test invoice'); + }); + + it('should create invoice with multiple lines', async () => { + mockClient.post.mockResolvedValue({ + doc: { ...mockCrearResponse.doc, neto: 250, totaliva: 52.5, total: 302.5 }, + lines: [ + { idlinea: 1, descripcion: 'Item 1', cantidad: 2, pvpunitario: 50 }, + { idlinea: 2, descripcion: 'Item 2', cantidad: 1, pvpunitario: 150, referencia: 'REF002' }, + ], + }); + + const result = await createFacturaProveedorImplementation({ + codproveedor: 'PROV001', + lineas: [ + { descripcion: 'Item 1', cantidad: 2, pvpunitario: 50 }, + { descripcion: 'Item 2', cantidad: 1, pvpunitario: 150, referencia: 'REF002', codimpuesto: 'IVA21', dtopor: 10 }, + ], + }, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(response.resumen.num_lineas).toBe(2); + }); + + it('should use today date when fecha is not provided', async () => { + mockClient.post.mockResolvedValue(mockCrearResponse); + + await createFacturaProveedorImplementation(validArgs, mockClient); + + const sentData = mockClient.post.mock.calls[0][1]; + const today = new Date().toISOString().split('T')[0]; + expect(sentData.fecha).toBe(today); + }); + + it('should return error when idfactura is missing in response', async () => { + mockClient.post.mockResolvedValue({ doc: {} }); + + const result = await createFacturaProveedorImplementation(validArgs, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('idfactura'); + }); + }); + + describe('success with pagada=true', () => { + it('should mark invoice as paid via dedicated endpoint', async () => { + mockClient.post + .mockResolvedValueOnce(mockCrearResponse) // crearFacturaProveedor + .mockResolvedValueOnce({ success: true }); // pagarFacturaProveedor + mockClient.get.mockResolvedValue({ ...mockCrearResponse.doc, pagada: true }); + + const result = await createFacturaProveedorImplementation( + { ...validArgs, pagada: true }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(mockClient.post).toHaveBeenCalledTimes(2); + expect(mockClient.post.mock.calls[1][0]).toBe('/pagarFacturaProveedor/14'); + expect(response.pago.success).toBe(true); + expect(mockClient.get).toHaveBeenCalledWith('/facturaproveedores/14'); + }); + + it('should handle payment endpoint failure gracefully', async () => { + mockClient.post + .mockResolvedValueOnce(mockCrearResponse) // crearFacturaProveedor + .mockRejectedValueOnce(new Error('Payment failed')); // pagarFacturaProveedor + + const result = await createFacturaProveedorImplementation( + { ...validArgs, pagada: true }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(response.success).toBe(true); + expect(response.pago.error).toContain('Payment failed'); + }); + + it('should handle re-fetch failure after payment', async () => { + mockClient.post + .mockResolvedValueOnce(mockCrearResponse) + .mockResolvedValueOnce({ success: true }); + mockClient.get.mockRejectedValue(new Error('Re-fetch failed')); + + const result = await createFacturaProveedorImplementation( + { ...validArgs, pagada: true }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + // Should still succeed, using original entity data + expect(response.success).toBe(true); + expect(response.pago.success).toBe(true); + expect(response.resumen.idfactura).toBe(14); + }); + + it('should not attempt payment when pagada is false', async () => { + mockClient.post.mockResolvedValue(mockCrearResponse); + + await createFacturaProveedorImplementation( + { ...validArgs, pagada: false }, + mockClient + ); + + expect(mockClient.post).toHaveBeenCalledTimes(1); + expect(mockClient.get).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should handle API errors during invoice creation', async () => { + mockClient.post.mockRejectedValue(new Error('API timeout')); + + const result = await createFacturaProveedorImplementation(validArgs, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.error).toBe('Error al crear factura de proveedor'); + expect(response.message).toBe('API timeout'); + }); + + it('should include API response details in error', async () => { + const axiosError: any = new Error('Bad Request'); + axiosError.response = { data: { message: 'Proveedor no encontrado' } }; + mockClient.post.mockRejectedValue(axiosError); + + const result = await createFacturaProveedorImplementation(validArgs, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(response.details).toEqual({ message: 'Proveedor no encontrado' }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/modules/sales-orders/facturaclientes.test.ts b/tests/unit/modules/sales-orders/facturaclientes.test.ts index 2b896d3..b2d2617 100644 --- a/tests/unit/modules/sales-orders/facturaclientes.test.ts +++ b/tests/unit/modules/sales-orders/facturaclientes.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { FacturaclientesResource, FacturaCliente } from '../../../../src/modules/sales-orders/facturaclientes/resource.js'; -import { toolByCifnifImplementation, toolClientesMorososImplementation, toolClientesTopFacturacionImplementation, toolClientesSinComprasImplementation, toolClientesFrecuenciaComprasImplementation, toolClientesPerdidosImplementation } from '../../../../src/modules/sales-orders/facturaclientes/tool.js'; +import { toolByCifnifImplementation, toolClientesMorososImplementation, toolClientesTopFacturacionImplementation, toolClientesSinComprasImplementation, toolClientesFrecuenciaComprasImplementation, toolClientesPerdidosImplementation, createFacturaClienteImplementation } from '../../../../src/modules/sales-orders/facturaclientes/tool.js'; import { FacturaScriptsClient } from '../../../../src/fs/client.js'; vi.mock('../../../../src/fs/client.js'); @@ -2536,4 +2536,213 @@ describe('toolClientesPerdidosImplementation', () => { expect(parsedResult.data.some(c => c.codcliente === 'CLI001')).toBe(false); }); }); +}); + +describe('createFacturaClienteImplementation', () => { + let mockClient: any; + + const validArgs = { + codcliente: 'CLI001', + lineas: [{ descripcion: 'Servicio web', cantidad: 1, pvpunitario: 500 }], + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = { + post: vi.fn(), + }; + }); + + describe('validation', () => { + it('should return error when codcliente is missing', async () => { + const result = await createFacturaClienteImplementation( + { lineas: [{ descripcion: 'Test', cantidad: 1, pvpunitario: 10 }] }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('codcliente'); + }); + + it('should return error when codcliente is empty/whitespace', async () => { + const result = await createFacturaClienteImplementation( + { codcliente: ' ', lineas: [{ descripcion: 'Test', cantidad: 1, pvpunitario: 10 }] }, + mockClient + ); + + expect(result.isError).toBe(true); + }); + + it('should return error when lineas is missing', async () => { + const result = await createFacturaClienteImplementation( + { codcliente: 'CLI001' }, + mockClient + ); + + expect(result.isError).toBe(true); + }); + + it('should return error when lineas is empty array', async () => { + const result = await createFacturaClienteImplementation( + { codcliente: 'CLI001', lineas: [] }, + mockClient + ); + + expect(result.isError).toBe(true); + }); + + it('should return error when line has no descripcion and no referencia', async () => { + const result = await createFacturaClienteImplementation( + { codcliente: 'CLI001', lineas: [{ cantidad: 1, pvpunitario: 10 }] }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('Línea 1'); + expect(response.message).toContain('descripción o referencia'); + }); + + it('should accept line with referencia but no descripcion', async () => { + mockClient.post.mockResolvedValue({ doc: { idfactura: 1 } }); + + const result = await createFacturaClienteImplementation( + { codcliente: 'CLI001', lineas: [{ referencia: 'REF001', cantidad: 1, pvpunitario: 10 }] }, + mockClient + ); + + expect(result.isError).toBeUndefined(); + }); + + it('should return error when line has cantidad <= 0', async () => { + const result = await createFacturaClienteImplementation( + { codcliente: 'CLI001', lineas: [{ descripcion: 'Test', cantidad: 0, pvpunitario: 10 }] }, + mockClient + ); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.message).toContain('cantidad'); + }); + + it('should return error when line has negative pvpunitario', async () => { + const result = await createFacturaClienteImplementation( + { codcliente: 'CLI001', lineas: [{ descripcion: 'Test', cantidad: 1, pvpunitario: -5 }] }, + mockClient + ); + + expect(result.isError).toBe(true); + }); + }); + + describe('success', () => { + it('should create customer invoice with minimal required fields', async () => { + const apiResponse = { doc: { idfactura: 7, codigo: 'FCLI001', total: 605 }, lines: [{ idlinea: 1 }] }; + mockClient.post.mockResolvedValue(apiResponse); + + const result = await createFacturaClienteImplementation(validArgs, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBeUndefined(); + expect(response.success).toBe(true); + expect(mockClient.post).toHaveBeenCalledWith( + '/crearFacturaCliente', + expect.objectContaining({ + codcliente: 'CLI001', + lineas: expect.any(String), + }) + ); + + // Verify lineas was JSON-stringified + const sentData = mockClient.post.mock.calls[0][1]; + const parsedLineas = JSON.parse(sentData.lineas); + expect(parsedLineas[0].descripcion).toBe('Servicio web'); + }); + + it('should create invoice with all optional header fields', async () => { + mockClient.post.mockResolvedValue({ success: true }); + + await createFacturaClienteImplementation({ + ...validArgs, + fecha: '2026-03-22', + hora: '10:30:00', + codpago: 'TRANS', + codserie: 'A', + codalmacen: 'ALG', + direccion: 'Calle Test 1', + ciudad: 'Madrid', + provincia: 'Madrid', + observaciones: 'Notas de prueba', + }, mockClient); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.fecha).toBe('2026-03-22'); + expect(sentData.hora).toBe('10:30:00'); + expect(sentData.codpago).toBe('TRANS'); + expect(sentData.codserie).toBe('A'); + expect(sentData.codalmacen).toBe('ALG'); + expect(sentData.direccion).toBe('Calle Test 1'); + expect(sentData.ciudad).toBe('Madrid'); + expect(sentData.provincia).toBe('Madrid'); + expect(sentData.observaciones).toBe('Notas de prueba'); + }); + + it('should include pagada flag as integer', async () => { + mockClient.post.mockResolvedValue({ success: true }); + + await createFacturaClienteImplementation( + { ...validArgs, pagada: true }, + mockClient + ); + + const sentData = mockClient.post.mock.calls[0][1]; + expect(sentData.pagada).toBe(1); + }); + + it('should create invoice with multiple lines and all line options', async () => { + mockClient.post.mockResolvedValue({ success: true }); + + await createFacturaClienteImplementation({ + codcliente: 'CLI001', + lineas: [ + { descripcion: 'Item 1', cantidad: 2, pvpunitario: 50, dtopor: 10, codimpuesto: 'IVA21' }, + { referencia: 'REF002', descripcion: 'Item 2', cantidad: 1, pvpunitario: 150, dtopor2: 5, irpf: 15 }, + ], + }, mockClient); + + const sentData = mockClient.post.mock.calls[0][1]; + const parsedLineas = JSON.parse(sentData.lineas); + expect(parsedLineas).toHaveLength(2); + expect(parsedLineas[0].dtopor).toBe(10); + expect(parsedLineas[0].codimpuesto).toBe('IVA21'); + expect(parsedLineas[1].referencia).toBe('REF002'); + expect(parsedLineas[1].dtopor2).toBe(5); + expect(parsedLineas[1].irpf).toBe(15); + }); + }); + + describe('error handling', () => { + it('should handle API errors during invoice creation', async () => { + mockClient.post.mockRejectedValue(new Error('API timeout')); + + const result = await createFacturaClienteImplementation(validArgs, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.error).toBe('Error al crear factura de cliente'); + expect(response.message).toBe('API timeout'); + }); + + it('should include API response details in error', async () => { + const axiosError: any = new Error('Bad Request'); + axiosError.response = { data: { message: 'Cliente no encontrado' } }; + mockClient.post.mockRejectedValue(axiosError); + + const result = await createFacturaClienteImplementation(validArgs, mockClient); + const response = JSON.parse(result.content[0].text); + + expect(response.details).toEqual({ message: 'Cliente no encontrado' }); + }); + }); }); \ No newline at end of file From 9b8e6720409a9405fd2a59779be15581341900d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Sun, 22 Mar 2026 01:29:14 +0000 Subject: [PATCH 8/9] docs: update CLAUDE.md with creation tools and write methods documentation --- CLAUDE.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2197237..f72a54b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,10 +494,34 @@ export async function toolByCifnifImplementation(args, client) { - **Comprehensive Testing**: Unit tests for all scenarios + integration tests with real APIs - **Proper Registration**: Export from module index and register in main server +### Entity Creation Tools Pattern + +The server supports write operations (POST) via dedicated creation tools. The FacturaScripts API requires `application/x-www-form-urlencoded` encoding (not JSON). + +**Client Write Methods (`src/fs/client.ts`):** +- `post(endpoint, data)` - Create records, serializes via `toFormData()` to form-urlencoded +- `put(endpoint, data)` - Update records, same serialization +- `delete(endpoint)` - Delete records + +**Available Creation Tools:** + +| Tool | Location | Description | +|------|----------|-------------| +| `create_producto` | `src/modules/core-business/productos/tool.ts` | Create product with reference, description, pricing, stock options. Converts booleans to 0/1. | +| `create_proveedor` | `src/modules/core-business/proveedores/tool.ts` | Create supplier. Two-step: POST proveedor, then PUT contacto with address fields (FacturaScripts auto-creates a contact). | +| `create_factura_proveedor` | `src/modules/purchasing/facturaproveedores/tool.ts` | Create supplier invoice via `/crearFacturaProveedor` dedicated endpoint. Supports optional payment marking via `/pagarFacturaProveedor/{id}`. Lines sent as JSON string within form data. | +| `create_factura_cliente` | `src/modules/sales-orders/facturaclientes/tool.ts` | Create customer invoice via `/crearFacturaCliente` dedicated endpoint. Lines sent as JSON string within form data. | + +**Key Implementation Details:** +- `toFormData()` flattens nested objects/arrays into bracket notation (`field[0][key]=value`) +- Invoice tools use dedicated FacturaScripts endpoints (not generic POST to collection) +- Line items are JSON-stringified within the form-urlencoded payload +- Multi-step flows handle partial failures gracefully (e.g., invoice created but payment failed) + ### Quality Checks Before completing any task, run: - `npm run build` - Ensure TypeScript compiles (currently: ✅ passing) -- `npm run test` - Run all tests to ensure nothing is broken (currently: ✅ 358 tests passing) +- `npm run test` - Run all tests to ensure nothing is broken (currently: ✅ 732 tests passing) - Test the resource manually if possible with live FacturaScripts API ### TDD Workflow @@ -553,8 +577,8 @@ All 28 resources return consistent pagination format: ### Current Project Status (v1.0.2) - ✅ **59 MCP Resources** - Complete FacturaScripts API coverage including OpenAPI part16 implementation -- ✅ **66 Interactive Tools** - Full Claude Desktop integration with advanced filtering including specialized business analytics and customer retention tools -- ✅ **567+ Tests Passing** - Comprehensive unit & integration testing with modular organization including specialized business tools and customer retention analytics +- ✅ **70 Interactive Tools** - Full Claude Desktop integration with advanced filtering, specialized business analytics, customer retention tools, and entity creation (POST) tools +- ✅ **732 Tests Passing** - Comprehensive unit & integration testing with modular organization including specialized business tools, customer retention analytics, and write operation tools - ✅ **Live API Integration** - Working with real FacturaScripts instances - ✅ **Advanced API Support** - Full FacturaScripts filtering, sorting, and pagination - ✅ **TypeScript Strict Mode** - Full type safety and IntelliSense From 87c684e379f3ab884d68e6d957dd21a765f5a5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hern=C3=A1ndez=20Cazorla?= Date: Sun, 22 Mar 2026 01:34:22 +0000 Subject: [PATCH 9/9] docs: add entity creation tools to README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 9a40e19..3b6d977 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,15 @@ All resources have corresponding interactive tools for Claude Desktop: - `get_fabricantes`, `get_familias`, `get_contactos`, `get_agentes` - `get_almacenes`, `get_atributos`, and 19 more tools covering all resources +### ✏️ Entity Creation Tools + +Tools for creating new records via POST. The FacturaScripts API requires `application/x-www-form-urlencoded` encoding. + +- **`create_producto`**: Create product with reference, description, pricing, and stock options +- **`create_proveedor`**: Create supplier with contact info and address (two-step: creates supplier, then updates auto-generated contact with address) +- **`create_factura_proveedor`**: Create supplier invoice via dedicated endpoint with line items and optional payment marking +- **`create_factura_cliente`**: Create customer invoice via dedicated endpoint with line items + ### 🎯 Specialized Business Tools **Advanced Customer Invoice Search**: