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 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**: 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; + } +} diff --git a/src/index.ts b/src/index.ts index 70b60a5..a97e9c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,12 +87,12 @@ 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'; 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'; @@ -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,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, }, }, + createProveedorToolDefinition, + createFacturaProveedorToolDefinition, + createFacturaClienteToolDefinition, + createProductoToolDefinition, ], }; }); @@ -3836,6 +3842,22 @@ 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); + } + + case 'create_factura_cliente': { + 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', 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 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 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', 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