@nubiia/mcp-freematica 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +20 -7
  2. package/dist/clients/fiql-builder.d.ts +32 -8
  3. package/dist/clients/fiql-builder.js +42 -14
  4. package/dist/clients/fiql-builder.js.map +1 -1
  5. package/dist/clients/freematica-client.d.ts +165 -34
  6. package/dist/clients/freematica-client.js +355 -96
  7. package/dist/clients/freematica-client.js.map +1 -1
  8. package/dist/schemas/cartera.d.ts +7 -5
  9. package/dist/schemas/cartera.js +13 -11
  10. package/dist/schemas/cartera.js.map +1 -1
  11. package/dist/schemas/clientes.d.ts +164 -0
  12. package/dist/schemas/clientes.js +205 -0
  13. package/dist/schemas/clientes.js.map +1 -0
  14. package/dist/schemas/facturas-ventas.d.ts +20 -16
  15. package/dist/schemas/facturas-ventas.js +36 -32
  16. package/dist/schemas/facturas-ventas.js.map +1 -1
  17. package/dist/schemas/localizaciones.d.ts +90 -0
  18. package/dist/schemas/localizaciones.js +145 -0
  19. package/dist/schemas/localizaciones.js.map +1 -0
  20. package/dist/schemas/pedidos-compras.d.ts +8 -6
  21. package/dist/schemas/pedidos-compras.js +8 -6
  22. package/dist/schemas/pedidos-compras.js.map +1 -1
  23. package/dist/server-instructions.js +43 -0
  24. package/dist/server-instructions.js.map +1 -1
  25. package/dist/server.js +5 -3
  26. package/dist/server.js.map +1 -1
  27. package/dist/tools/articulos.d.ts +14 -0
  28. package/dist/tools/articulos.js +183 -0
  29. package/dist/tools/articulos.js.map +1 -0
  30. package/dist/tools/cartera.js +2 -2
  31. package/dist/tools/cartera.js.map +1 -1
  32. package/dist/tools/clientes.d.ts +2 -1
  33. package/dist/tools/clientes.js +63 -2
  34. package/dist/tools/clientes.js.map +1 -1
  35. package/dist/tools/contabilidad.js +7 -7
  36. package/dist/tools/contabilidad.js.map +1 -1
  37. package/dist/tools/contactos-clientes.d.ts +2 -1
  38. package/dist/tools/contactos-clientes.js +58 -4
  39. package/dist/tools/contactos-clientes.js.map +1 -1
  40. package/dist/tools/contratos/cabecera.d.ts +2 -3
  41. package/dist/tools/contratos/cabecera.js.map +1 -1
  42. package/dist/tools/facturas-ventas.js +12 -12
  43. package/dist/tools/facturas-ventas.js.map +1 -1
  44. package/dist/tools/helpers.d.ts +4 -0
  45. package/dist/tools/helpers.js.map +1 -1
  46. package/dist/tools/localizaciones.d.ts +3 -2
  47. package/dist/tools/localizaciones.js +151 -13
  48. package/dist/tools/localizaciones.js.map +1 -1
  49. package/dist/tools/personal.js +13 -17
  50. package/dist/tools/personal.js.map +1 -1
  51. package/dist/tools/prl.js +15 -49
  52. package/dist/tools/prl.js.map +1 -1
  53. package/dist/tools/proveedores.d.ts +2 -2
  54. package/dist/tools/proveedores.js +8 -6
  55. package/dist/tools/proveedores.js.map +1 -1
  56. package/package.json +1 -1
@@ -3,8 +3,41 @@ import { buildFiql, appendRquery } from './fiql-builder.js';
3
3
  import { CATALOG_ENDPOINTS } from '../schemas/master-data.js';
4
4
  import { ESTADO_CARTERA_FIQL_MAP, } from '../schemas/cartera.js';
5
5
  import { buildEstadoPedidoFiql, } from '../schemas/pedidos-compras.js';
6
+ import { LOC_FIELD_MAP } from '../schemas/localizaciones.js';
6
7
  import { logger } from '../logger.js';
7
8
  import { loadMaxResponseSizeMb } from '../utils/size-guardrail.js';
9
+ /**
10
+ * Día siguiente a una fecha ISO (YYYY-MM-DD), en formato ISO.
11
+ *
12
+ * Se usa para emular "hasta X inclusive" como `=lt='X+1'` en los endpoints
13
+ * donde el operador `=le=` responde 400/500 (facturas-cabecera, cartera,
14
+ * export-asientos — verificado contra el API real).
15
+ */
16
+ function nextDayIso(isoDate) {
17
+ const fmt = (d) => {
18
+ // Formateo manual: toISOString() cambia de formato con años de 5+ dígitos
19
+ // (p.ej. 9999-12-31 + 1 día → '+010000-01-01…').
20
+ const y = String(d.getUTCFullYear()).padStart(4, '0');
21
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
22
+ const day = String(d.getUTCDate()).padStart(2, '0');
23
+ return `${y}-${m}-${day}`;
24
+ };
25
+ const d = new Date(`${isoDate}T00:00:00Z`);
26
+ // Round-trip: V8 "arrastra" días fuera de rango (2026-02-30 → 2026-03-02)
27
+ // en vez de fallar; solo el round-trip detecta ambos casos (NaN y arrastre).
28
+ if (Number.isNaN(d.getTime()) || fmt(d) !== isoDate) {
29
+ throw new Error(`Fecha inválida: ${isoDate}. Usa una fecha de calendario real en formato YYYY-MM-DD.`);
30
+ }
31
+ d.setUTCDate(d.getUTCDate() + 1);
32
+ return fmt(d);
33
+ }
34
+ /**
35
+ * Mensaje de error para los endpoints donde el API de Freemática devuelve
36
+ * 0 filas si se combinan dos condiciones de rango sobre el MISMO campo
37
+ * (verificado con ge+lt, ge+le, orden inverso y paréntesis).
38
+ */
39
+ const RANGO_NO_SOPORTADO = 'El API de Freemática no soporta combinar fecha-desde y fecha-hasta a la vez ' +
40
+ 'en este endpoint (devuelve 0 resultados). Usa solo uno de los dos límites por consulta.';
8
41
  /**
9
42
  * Typed client for the Freemática REST API.
10
43
  *
@@ -31,6 +64,96 @@ export class FreematicaClient extends BaseClient {
31
64
  const data = await this.get('/pvss/v2/contratos-servicios-material');
32
65
  return { items: data.items, total: Number(data.total) };
33
66
  }
67
+ // ---------------------------------------------------------------------------
68
+ // Artículos — catálogo de inventario (v0.9.0)
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * Lista paginada de artículos del catálogo de inventario.
72
+ *
73
+ * Endpoint: GET /part/v1/articulos
74
+ *
75
+ * Todos los filtros son de coincidencia EXACTA (el endpoint no soporta
76
+ * búsqueda parcial: =lk= responde 400 y los wildcards % devuelven 0
77
+ * resultados — verificado contra el API real). OJO: COD_ARTICULO puede
78
+ * llevar espacios iniciales en Freemática (ej. " QQ10615") que forman
79
+ * parte del código.
80
+ *
81
+ * El filtro `activo` mapea a MOTIVO_BAJA (columna de texto — las columnas
82
+ * de fecha no permiten comparar con vacío):
83
+ * - `true` → MOTIVO_BAJA=='' (sin motivo de baja).
84
+ * - `false` → MOTIVO_BAJA!='' (con motivo de baja).
85
+ * Los artículos con MOTIVO_BAJA nulo (≈36 de 6304 en producción) no
86
+ * matchean ninguno de los dos valores.
87
+ *
88
+ * @param opts - Paginación y filtros opcionales.
89
+ * @returns Lista paginada de artículos (cada item incluye `idReg`).
90
+ */
91
+ async listArticulos(opts) {
92
+ const url = new URL('placeholder://x/part/v1/articulos');
93
+ if (opts.items !== undefined)
94
+ url.searchParams.set('items', String(opts.items));
95
+ if (opts.page !== undefined)
96
+ url.searchParams.set('page', String(opts.page));
97
+ const fiqlGroup = {};
98
+ if (opts.codArticulo !== undefined)
99
+ fiqlGroup['COD_ARTICULO'] = opts.codArticulo;
100
+ if (opts.tipoCodigo !== undefined)
101
+ fiqlGroup['TIPO_CODIGO'] = opts.tipoCodigo;
102
+ if (opts.codProveedor !== undefined)
103
+ fiqlGroup['COD_PROVEEDOR'] = opts.codProveedor;
104
+ if (opts.linea !== undefined)
105
+ fiqlGroup['COD_LIN_ART'] = opts.linea;
106
+ if (opts.familia !== undefined)
107
+ fiqlGroup['COD_FAMILIA'] = opts.familia;
108
+ if (opts.subfamilia !== undefined)
109
+ fiqlGroup['COD_SUBFAM'] = opts.subfamilia;
110
+ if (opts.descripcion !== undefined)
111
+ fiqlGroup['DESC_ART'] = opts.descripcion;
112
+ if (opts.codigoBarras !== undefined)
113
+ fiqlGroup['CODIGO_BARRAS'] = opts.codigoBarras;
114
+ if (opts.activo === true)
115
+ fiqlGroup['MOTIVO_BAJA'] = '';
116
+ if (opts.activo === false)
117
+ fiqlGroup['MOTIVO_BAJA'] = { op: 'ne', value: '' };
118
+ appendRquery(url, buildFiql(fiqlGroup));
119
+ const qs = url.searchParams.toString();
120
+ const path = qs ? `/part/v1/articulos?${qs}` : '/part/v1/articulos';
121
+ const data = await this.get(path);
122
+ return { items: data.items, total: Number(data.total) };
123
+ }
124
+ /**
125
+ * Detalle de un artículo por `idReg` opaco.
126
+ *
127
+ * Endpoint: GET /part/v1/articulos/{idreg}
128
+ *
129
+ * El API devuelve un envelope de LISTA con un único item (verificado contra
130
+ * el API real), no un objeto detalle; este método lo desenvuelve.
131
+ *
132
+ * @param idReg - Identificador opaco (base64) del artículo.
133
+ * @returns Objeto con todos los campos del artículo.
134
+ */
135
+ async getArticulo(idReg) {
136
+ const data = await this.get(`/part/v1/articulos/${encodeURIComponent(idReg)}`);
137
+ const item = data.items?.[0];
138
+ if (item === undefined) {
139
+ throw new FreematicaError('not_found', `Artículo no encontrado: ${idReg}`);
140
+ }
141
+ return item;
142
+ }
143
+ /**
144
+ * Precios de venta de un artículo por `idReg` opaco.
145
+ *
146
+ * Endpoint: GET /pgrl/v1/precio-articulo/{idreg}
147
+ *
148
+ * Devuelve un objeto con PRECIO_VENTA, DESCUENTO y FACTURABLE.
149
+ *
150
+ * @param idReg - Identificador opaco (base64) del artículo (el mismo que
151
+ * devuelve freematica_list_articulos).
152
+ * @returns Objeto de precios del artículo.
153
+ */
154
+ async getPrecioArticulo(idReg) {
155
+ return this.get(`/pgrl/v1/precio-articulo/${encodeURIComponent(idReg)}`);
156
+ }
34
157
  /**
35
158
  * Obtener un catálogo de datos maestros.
36
159
  */
@@ -163,13 +286,15 @@ export class FreematicaClient extends BaseClient {
163
286
  *
164
287
  * Endpoint: GET /pprl/v1/vigilancia-salud
165
288
  *
166
- * `idRegPersona` se envía como query param nativo. El resto de filtros se
167
- * codifican en FIQL y se envían como `rquery`.
168
- *
169
- * @param opts - Opciones de paginación y filtrado.
289
+ * @param opts - Paginación y filtro opcional idRegPersona.
170
290
  * @returns Lista paginada de registros VS.
171
291
  */
172
292
  async listVigilanciaSalud(opts) {
293
+ // El endpoint IGNORA el parámetro `rquery` por completo: cualquier FIQL
294
+ // (incluso con campos inexistentes) responde 200 con el dataset completo
295
+ // sin filtrar (verificado contra el API real). El único filtro operativo
296
+ // es el query param nativo `idRegPersona` (el idReg de la persona en
297
+ // freematica_list_personal), que sí filtra correctamente.
173
298
  const url = new URL('placeholder://x/pprl/v1/vigilancia-salud');
174
299
  if (opts.items !== undefined)
175
300
  url.searchParams.set('items', String(opts.items));
@@ -177,28 +302,6 @@ export class FreematicaClient extends BaseClient {
177
302
  url.searchParams.set('page', String(opts.page));
178
303
  if (opts.idRegPersona !== undefined)
179
304
  url.searchParams.set('idRegPersona', opts.idRegPersona);
180
- // Filtros FIQL
181
- const fiqlParts = {};
182
- if (opts.empresa !== undefined)
183
- fiqlParts['PERVS_EMP'] = opts.empresa;
184
- if (opts.delegacion !== undefined)
185
- fiqlParts['PERVS_DELEG'] = opts.delegacion;
186
- if (opts.codPersona !== undefined)
187
- fiqlParts['PERVS_PERSO'] = opts.codPersona;
188
- if (opts.tipoRevision !== undefined)
189
- fiqlParts['PERVS_TIPO_REVISION'] = opts.tipoRevision;
190
- if (opts.resultado !== undefined)
191
- fiqlParts['PERVS_RESULTADO'] = opts.resultado;
192
- if (opts.fechaCitaDesde !== undefined)
193
- fiqlParts['PERVS_FCH_CITA'] = { op: 'ge', value: opts.fechaCitaDesde };
194
- // If both desde and hasta, we need to combine with AND
195
- const fiqlParts2 = {};
196
- if (opts.fechaCitaHasta !== undefined)
197
- fiqlParts2['PERVS_FCH_CITA'] = { op: 'le', value: opts.fechaCitaHasta };
198
- const fiql1 = buildFiql(fiqlParts);
199
- const fiql2 = buildFiql(fiqlParts2);
200
- const combinedFiql = [fiql1, fiql2].filter(Boolean).join(';');
201
- appendRquery(url, combinedFiql);
202
305
  const qs = url.searchParams.toString();
203
306
  const path = qs ? `/pprl/v1/vigilancia-salud?${qs}` : '/pprl/v1/vigilancia-salud';
204
307
  const data = await this.get(path);
@@ -263,18 +366,23 @@ export class FreematicaClient extends BaseClient {
263
366
  /**
264
367
  * Lista paginada de personas (empleados) con filtros FIQL opcionales.
265
368
  *
266
- * Endpoint: GET /pers/v2/personal
369
+ * Endpoint: GET /pers/v1/personal
267
370
  *
268
- * El filtro `activo` (boolean) se traduce a `VSSPER_ACTIVO==S` (true) o
269
- * `VSSPER_ACTIVO==N` (false). Las búsquedas por `nombre` y `apellido` usan
270
- * el operador de igualdad estándar FIQL (el servidor aplica LIKE internamente
271
- * para estos campos).
371
+ * Todos los filtros son de coincidencia EXACTA: el endpoint no soporta
372
+ * búsqueda parcial (=lk= responde 400 y los wildcards % / * devuelven 0
373
+ * resultados verificado contra el API real).
272
374
  *
273
375
  * @param opts - Opciones de paginación y filtrado.
274
376
  * @returns Lista paginada de personas.
275
377
  */
276
378
  async listPersonal(opts) {
277
- const url = new URL('placeholder://x/pers/v2/personal');
379
+ // v1, no v2: /pers/v2/personal es un endpoint de sincronización incremental
380
+ // que exige el parámetro nativo `fchmodificacion` (sin él → 400 "Parámetro
381
+ // [fchmodificacion] obligatorio") y devuelve un subconjunto del dataset.
382
+ // /pers/v1/personal devuelve el dataset completo con idReg. Verificado
383
+ // contra el API real (v1: 5482 personas; v2 con fchmodificacion=1900-01-01:
384
+ // 3984). El campo VSSPER_ACTIVO no existe en v1 (filtrarlo → 400).
385
+ const url = new URL('placeholder://x/pers/v1/personal');
278
386
  if (opts.items !== undefined)
279
387
  url.searchParams.set('items', String(opts.items));
280
388
  if (opts.page !== undefined)
@@ -298,24 +406,32 @@ export class FreematicaClient extends BaseClient {
298
406
  fiqlGroup['VSSPER_DPTO'] = opts.departamento;
299
407
  if (opts.seccion !== undefined)
300
408
  fiqlGroup['VSSPER_SECCION'] = opts.seccion;
301
- if (opts.activo !== undefined)
302
- fiqlGroup['VSSPER_ACTIVO'] = opts.activo ? 'S' : 'N';
303
409
  appendRquery(url, buildFiql(fiqlGroup));
304
410
  const qs = url.searchParams.toString();
305
- const path = qs ? `/pers/v2/personal?${qs}` : '/pers/v2/personal';
411
+ const path = qs ? `/pers/v1/personal?${qs}` : '/pers/v1/personal';
306
412
  const data = await this.get(path);
307
413
  return { items: data.items, total: Number(data.total) };
308
414
  }
309
415
  /**
310
- * Detalle de una persona por `idReg` opaco.
416
+ * Detalle de una persona por su `idReg` opaco.
311
417
  *
312
- * Endpoint: GET /pers/v2/personal/{idreg}
418
+ * Endpoint: GET /pers/v1/personal/{idreg}
313
419
  *
314
- * @param idReg - Identificador opaco de la persona.
420
+ * v1, igual que el listado: garantiza que todo idReg devuelto por
421
+ * freematica_list_personal funciona aquí (la vista v2 es un subconjunto
422
+ * de sincronización incremental). El API devuelve un envelope de LISTA
423
+ * con un único item (verificado contra el API real); se desenvuelve.
424
+ *
425
+ * @param idReg - Identificador opaco (base64) de la persona.
315
426
  * @returns Objeto con todos los campos de la persona.
316
427
  */
317
428
  async getPersona(idReg) {
318
- return this.get(`/pers/v2/personal/${encodeURIComponent(idReg)}`);
429
+ const data = await this.get(`/pers/v1/personal/${encodeURIComponent(idReg)}`);
430
+ const item = data.items?.[0];
431
+ if (item === undefined) {
432
+ throw new FreematicaError('not_found', `Persona no encontrada: ${idReg}`);
433
+ }
434
+ return item;
319
435
  }
320
436
  // ---------------------------------------------------------------------------
321
437
  // Calendarios laborales (v0.5.0)
@@ -371,13 +487,19 @@ export class FreematicaClient extends BaseClient {
371
487
  url.searchParams.set('cal', opts.cal);
372
488
  if (opts.periodo !== undefined)
373
489
  url.searchParams.set('periodo', opts.periodo);
374
- // Construir FIQL para filtros adicionales
490
+ // Construir FIQL para filtros adicionales.
491
+ // ASI_FCHASI: `=ge=` funciona; `=le=` responde 400 (se emula con `=lt=`
492
+ // del día siguiente); combinar ambos límites devuelve 0 filas (bug del
493
+ // API verificado en producción) → se rechaza.
494
+ if (opts.fechaDesde !== undefined && opts.fechaHasta !== undefined) {
495
+ throw new Error(RANGO_NO_SOPORTADO);
496
+ }
375
497
  const andGroups = [];
376
498
  if (opts.fechaDesde !== undefined) {
377
499
  andGroups.push({ ASI_FCHASI: { op: 'ge', value: opts.fechaDesde } });
378
500
  }
379
501
  if (opts.fechaHasta !== undefined) {
380
- andGroups.push({ ASI_FCHASI: { op: 'le', value: opts.fechaHasta } });
502
+ andGroups.push({ ASI_FCHASI: { op: 'lt', value: nextDayIso(opts.fechaHasta) } });
381
503
  }
382
504
  if (opts.diario !== undefined) {
383
505
  andGroups.push({ ASI_DIARIO: opts.diario });
@@ -495,12 +617,11 @@ export class FreematicaClient extends BaseClient {
495
617
  * Construye la FIQL a partir de los filtros tipados y la añade como `rquery`
496
618
  * usando `appendRquery()` (patrón canónico del foundation TD-117).
497
619
  *
498
- * El filtro `soloImpagados=true` genera `CARCL_FECIMPAG!=null`, donde el
499
- * valor `'null'` es la convención centinela de Freemática para indicar "sin
500
- * valor" en campos de fecha opcionales (assumption empírica: observado en
501
- * datos reales de la API; documentado para futuras implementaciones).
502
- * El operador `ne` se pasa vía `buildFiql` para garantizar escaping
503
- * consistente con el resto de filtros.
620
+ * El filtro `soloImpagados=true` genera `CARCL_FECIMPAG=ge='1900-01-01'`
621
+ * (cualquier fecha de impago con valor). El FIQL de Freemática no tiene
622
+ * IS NOT NULL: `!=null` devuelve 0 resultados silenciosamente y `!='null'`
623
+ * responde 500 (verificado contra el API real; con este rango devuelve
624
+ * 382 impagados de 72054 documentos).
504
625
  *
505
626
  * El filtro `estado` mapea a los valores numéricos de `CARCL_SITCAR`
506
627
  * (1=pendiente, 2=cancelado, 3=derivado).
@@ -539,20 +660,27 @@ export class FreematicaClient extends BaseClient {
539
660
  const eqFiql = buildFiql(eqGroup);
540
661
  if (eqFiql)
541
662
  parts.push(eqFiql);
542
- // Range filters: each gets its own buildFiql call to preserve both endpoints
663
+ // Range filters. CARCL_FECDOC: `=ge=` funciona; `=le=` responde 500
664
+ // (hasta se emula con `=lt=` del día siguiente); combinar desde+hasta
665
+ // devuelve 0 filas (bug del API verificado en producción) → se rechaza.
666
+ // CARCL_FECVCTO: solo `=ge=` funciona (le/lt se ignoran silenciosamente
667
+ // devolviendo el dataset completo), así que fechaVencimientoHasta no
668
+ // existe como filtro.
669
+ if (opts.fechaDocDesde !== undefined && opts.fechaDocHasta !== undefined) {
670
+ throw new Error(RANGO_NO_SOPORTADO);
671
+ }
543
672
  if (opts.fechaDocDesde !== undefined)
544
673
  parts.push(buildFiql({ CARCL_FECDOC: { op: 'ge', value: opts.fechaDocDesde } }));
545
674
  if (opts.fechaDocHasta !== undefined)
546
- parts.push(buildFiql({ CARCL_FECDOC: { op: 'le', value: opts.fechaDocHasta } }));
675
+ parts.push(buildFiql({ CARCL_FECDOC: { op: 'lt', value: nextDayIso(opts.fechaDocHasta) } }));
547
676
  if (opts.fechaVencimientoDesde !== undefined)
548
677
  parts.push(buildFiql({ CARCL_FECVCTO: { op: 'ge', value: opts.fechaVencimientoDesde } }));
549
- if (opts.fechaVencimientoHasta !== undefined)
550
- parts.push(buildFiql({ CARCL_FECVCTO: { op: 'le', value: opts.fechaVencimientoHasta } }));
551
- // soloImpagados: 'null' es el centinela de Freemática para fecha sin valor.
552
- // Se pasa por buildFiql (op 'ne') igual que el resto de filtros para
553
- // garantizar escaping uniforme y legibilidad del código.
678
+ // soloImpagados: el FIQL de Freemática no soporta IS NOT NULL (`!=null`
679
+ // devuelve 0 resultados y `!='null'` responde 500 verificado contra el
680
+ // API real). El rango de fecha equivale: cualquier CARCL_FECIMPAG con
681
+ // valor es >= 1900-01-01.
554
682
  if (opts.soloImpagados === true) {
555
- parts.push(buildFiql({ CARCL_FECIMPAG: { op: 'ne', value: 'null' } }));
683
+ parts.push(buildFiql({ CARCL_FECIMPAG: { op: 'ge', value: '1900-01-01' } }));
556
684
  }
557
685
  appendRquery(url, parts.join(';'));
558
686
  const path = url.pathname + (url.search ? url.search : '');
@@ -691,13 +819,16 @@ export class FreematicaClient extends BaseClient {
691
819
  /**
692
820
  * Lista paginada de proveedores con filtros FIQL.
693
821
  *
694
- * El filtro `activo` mapea a FECHA_BAJA:
695
- * - `true` → FECHA_BAJA es nula (proveedor activo). Se usa el operador `=is-null=true` si el
696
- * API lo soporta; como fallback se omite el filtro y se filtra post-process.
697
- * En Freemática el convenio estándar es: activo=true → `FECHA_BAJA==null` (FIQL `==null`).
698
- * - `false` FECHA_BAJA tiene valor (proveedor de baja).
822
+ * El filtro `activo` mapea a FECHA_BAJA. El API no soporta IS NULL en FIQL
823
+ * (`==null` devuelve 0 resultados y `=='null'` responde 500 verificado
824
+ * contra el API real), así que:
825
+ * - `false` → `FECHA_BAJA=ge='1900-01-01'` (cualquier fecha de baja).
826
+ * - `true` sin filtro server-side; las bajas se descartan post-process
827
+ * sobre la página recibida (FECHA_BAJA con valor). El `total`
828
+ * devuelto es el del dataset sin filtrar.
699
829
  *
700
- * El filtro `nombre` usa operador FIQL `=lk=` para búsqueda parcial (LIKE).
830
+ * El filtro `nombre` es coincidencia EXACTA: el operador `=lk=` responde
831
+ * 400 en este endpoint y los wildcards % devuelven 0 resultados.
701
832
  *
702
833
  * Endpoint: GET /pgrl/v2/proveedores
703
834
  */
@@ -717,21 +848,25 @@ export class FreematicaClient extends BaseClient {
717
848
  COD_PAIS: opts.codPais,
718
849
  };
719
850
  if (opts.nombre !== undefined) {
720
- filters['NOMBRE_PRO'] = { op: 'lk', value: opts.nombre };
721
- }
722
- if (opts.activo === true) {
723
- // Activos: FECHA_BAJA nula. FIQL: FECHA_BAJA==null
724
- filters['FECHA_BAJA'] = 'null';
851
+ filters['NOMBRE_PRO'] = opts.nombre;
725
852
  }
726
- else if (opts.activo === false) {
727
- // Dados de baja: FECHA_BAJA tiene valor (not null). FIQL: FECHA_BAJA!=null
728
- filters['FECHA_BAJA'] = { op: 'ne', value: 'null' };
853
+ if (opts.activo === false) {
854
+ // Dados de baja: cualquier FECHA_BAJA con valor. IS NOT NULL no existe
855
+ // en el FIQL de Freemática; el rango de fecha equivale.
856
+ filters['FECHA_BAJA'] = { op: 'ge', value: '1900-01-01' };
729
857
  }
730
858
  const fiql = buildFiql(filters);
731
859
  appendRquery(url, fiql);
732
860
  const path = url.pathname + (url.search ? url.search : '');
733
861
  const data = await this.get(path);
734
- return { items: data.items, total: Number(data.total) };
862
+ // activo=true: descartar bajas de la página (no expresable en FIQL).
863
+ const items = opts.activo === true
864
+ ? data.items.filter((p) => {
865
+ const fb = p['FECHA_BAJA'];
866
+ return fb === undefined || fb === null || String(fb).trim() === '';
867
+ })
868
+ : data.items;
869
+ return { items, total: Number(data.total) };
735
870
  }
736
871
  // ---------------------------------------------------------------------------
737
872
  // Facturas de ventas (v0.5.0)
@@ -751,33 +886,43 @@ export class FreematicaClient extends BaseClient {
751
886
  if (opts.page !== undefined)
752
887
  url.searchParams.set('page', String(opts.page));
753
888
  const fiqlParts = [];
754
- // Standard equality filters
889
+ // Standard equality filters. Nombres de columna verificados contra el API
890
+ // real: la vista de facturas-cabecera expone FVC_CODEMP, FVC_CODCLI,
891
+ // FVC_CODREPRES, FVC_SERIEFRA, FVC_NUMFRA, FVC_FPAGO, FVC_FCHFAC y
892
+ // FVC_TRASP_CONTAB ('1'/'0'). Los nombres anteriores (FVC_EMP, FVC_CODAUX,
893
+ // FVC_CODREP, FVC_SERFAC, FVC_NUMFAC, FVC_CODFPAG, FVC_FECFAC,
894
+ // FVC_TRSCONT) no existen y provocaban 500 en cada consulta con filtro.
755
895
  const eqGroup = {};
756
896
  if (opts.empresa !== undefined)
757
- eqGroup['FVC_EMP'] = opts.empresa;
897
+ eqGroup['FVC_CODEMP'] = opts.empresa;
758
898
  if (opts.codCliente !== undefined)
759
- eqGroup['FVC_CODAUX'] = opts.codCliente;
899
+ eqGroup['FVC_CODCLI'] = opts.codCliente;
760
900
  if (opts.representante !== undefined)
761
- eqGroup['FVC_CODREP'] = opts.representante;
901
+ eqGroup['FVC_CODREPRES'] = opts.representante;
762
902
  if (opts.serie !== undefined)
763
- eqGroup['FVC_SERFAC'] = opts.serie;
903
+ eqGroup['FVC_SERIEFRA'] = opts.serie;
764
904
  if (opts.numFactura !== undefined)
765
- eqGroup['FVC_NUMFAC'] = opts.numFactura;
905
+ eqGroup['FVC_NUMFRA'] = opts.numFactura;
766
906
  if (opts.formaPago !== undefined)
767
- eqGroup['FVC_CODFPAG'] = opts.formaPago;
907
+ eqGroup['FVC_FPAGO'] = opts.formaPago;
768
908
  if (opts.delegacion !== undefined)
769
909
  eqGroup['FVC_DELEG'] = opts.delegacion;
770
910
  const eqFiql = buildFiql(eqGroup);
771
911
  if (eqFiql)
772
912
  fiqlParts.push(eqFiql);
773
- // Date range filters
913
+ // Date range filters. FVC_FCHFAC: `=ge=` funciona; `=le=`/`=gt=` responden
914
+ // 500 (hasta se emula con `=lt=` del día siguiente); combinar desde+hasta
915
+ // devuelve 0 filas (bug del API verificado en producción) → se rechaza.
916
+ if (opts.fechaFacturaDesde !== undefined && opts.fechaFacturaHasta !== undefined) {
917
+ throw new Error(RANGO_NO_SOPORTADO);
918
+ }
774
919
  if (opts.fechaFacturaDesde !== undefined)
775
- fiqlParts.push(buildFiql({ FVC_FECFAC: { op: 'ge', value: opts.fechaFacturaDesde } }));
920
+ fiqlParts.push(buildFiql({ FVC_FCHFAC: { op: 'ge', value: opts.fechaFacturaDesde } }));
776
921
  if (opts.fechaFacturaHasta !== undefined)
777
- fiqlParts.push(buildFiql({ FVC_FECFAC: { op: 'le', value: opts.fechaFacturaHasta } }));
778
- // Boolean: traspasadoContabilidad
922
+ fiqlParts.push(buildFiql({ FVC_FCHFAC: { op: 'lt', value: nextDayIso(opts.fechaFacturaHasta) } }));
923
+ // Boolean: traspasadoContabilidad — la columna real usa '1'/'0'
779
924
  if (opts.traspasadoContabilidad !== undefined) {
780
- fiqlParts.push(buildFiql({ FVC_TRSCONT: opts.traspasadoContabilidad ? 'S' : 'N' }));
925
+ fiqlParts.push(buildFiql({ FVC_TRASP_CONTAB: opts.traspasadoContabilidad ? '1' : '0' }));
781
926
  }
782
927
  appendRquery(url, fiqlParts.join(';'));
783
928
  const path = url.pathname + (url.search ? url.search : '');
@@ -827,7 +972,11 @@ export class FreematicaClient extends BaseClient {
827
972
  * Lista paginada de localizaciones de servicio de clientes.
828
973
  *
829
974
  * Filtros soportados: codCliente (COD_CLI), grupoCliente (GRUPO_CLI), codPais (COD_PAIS),
830
- * codProvincia (COD_PROVINCIA), representante (COD_REPRES), activo (FECHA_BAJA nulo/no nulo).
975
+ * codProvincia (COD_PROVINCIA), representante (COD_REPRES).
976
+ *
977
+ * La vista NO tiene columna FECHA_BAJA (filtrar por ella responde 400
978
+ * "Error en rquery" — verificado contra el API real), así que no existe
979
+ * filtro activo/baja en este recurso.
831
980
  *
832
981
  * Endpoint: GET /pgrl/v2/localizaciones-servicio-clientes
833
982
  */
@@ -839,12 +988,6 @@ export class FreematicaClient extends BaseClient {
839
988
  COD_PROVINCIA: opts.codProvincia,
840
989
  COD_REPRES: opts.representante,
841
990
  };
842
- if (opts.activo === true) {
843
- fiqlFilters['FECHA_BAJA'] = 'null';
844
- }
845
- else if (opts.activo === false) {
846
- fiqlFilters['FECHA_BAJA'] = { op: 'ne', value: 'null' };
847
- }
848
991
  return this.listResourceWithFiql('/pgrl/v2/localizaciones-servicio-clientes', opts, fiqlFilters);
849
992
  }
850
993
  /**
@@ -873,13 +1016,15 @@ export class FreematicaClient extends BaseClient {
873
1016
  url.searchParams.set('items', String(opts.items));
874
1017
  if (opts.page !== undefined)
875
1018
  url.searchParams.set('page', String(opts.page));
1019
+ // Nombres de columna verificados contra el API real (las líneas exponen
1020
+ // FVL_CODARTIC, FVL_COD_FAMILIA, FVL_COD_SUBFAM y FVL_DELEG).
876
1021
  const fiqlGroup = {};
877
1022
  if (opts.codArticulo !== undefined)
878
- fiqlGroup['FVL_CODART'] = opts.codArticulo;
1023
+ fiqlGroup['FVL_CODARTIC'] = opts.codArticulo;
879
1024
  if (opts.codFamilia !== undefined)
880
- fiqlGroup['FVL_CODFAM'] = opts.codFamilia;
1025
+ fiqlGroup['FVL_COD_FAMILIA'] = opts.codFamilia;
881
1026
  if (opts.codSubfamilia !== undefined)
882
- fiqlGroup['FVL_CODSFAM'] = opts.codSubfamilia;
1027
+ fiqlGroup['FVL_COD_SUBFAM'] = opts.codSubfamilia;
883
1028
  if (opts.delegacion !== undefined)
884
1029
  fiqlGroup['FVL_DELEG'] = opts.delegacion;
885
1030
  appendRquery(url, buildFiql(fiqlGroup));
@@ -902,9 +1047,10 @@ export class FreematicaClient extends BaseClient {
902
1047
  url.searchParams.set('items', String(opts.items));
903
1048
  if (opts.page !== undefined)
904
1049
  url.searchParams.set('page', String(opts.page));
1050
+ // Columna real: FVI_TIPO_IVA (verificado contra el API; FVI_TIPIVA no existe).
905
1051
  const fiqlGroup = {};
906
1052
  if (opts.tipoIva !== undefined)
907
- fiqlGroup['FVI_TIPIVA'] = opts.tipoIva;
1053
+ fiqlGroup['FVI_TIPO_IVA'] = opts.tipoIva;
908
1054
  appendRquery(url, buildFiql(fiqlGroup));
909
1055
  const path = url.pathname + (url.search ? url.search : '');
910
1056
  const data = await this.get(path);
@@ -925,13 +1071,18 @@ export class FreematicaClient extends BaseClient {
925
1071
  url.searchParams.set('items', String(opts.items));
926
1072
  if (opts.page !== undefined)
927
1073
  url.searchParams.set('page', String(opts.page));
1074
+ // Columnas reales: FVV_MODOPAGO y FVV_FCH_VTO (verificado contra el API;
1075
+ // FVV_CODMPAG y FVV_FECVCTO no existen). A diferencia de FVC_FCHFAC en la
1076
+ // cabecera, en este sub-recurso `=le=` responde 200 y ge+le combinados
1077
+ // devuelven el registro esperado (verificado en producción con una factura
1078
+ // de 1 vencimiento), así que se mantienen el =le= y el rango.
928
1079
  const fiqlParts = [];
929
1080
  if (opts.modoPago !== undefined)
930
- fiqlParts.push(buildFiql({ FVV_CODMPAG: opts.modoPago }));
1081
+ fiqlParts.push(buildFiql({ FVV_MODOPAGO: opts.modoPago }));
931
1082
  if (opts.fechaVencimientoDesde !== undefined)
932
- fiqlParts.push(buildFiql({ FVV_FECVCTO: { op: 'ge', value: opts.fechaVencimientoDesde } }));
1083
+ fiqlParts.push(buildFiql({ FVV_FCH_VTO: { op: 'ge', value: opts.fechaVencimientoDesde } }));
933
1084
  if (opts.fechaVencimientoHasta !== undefined)
934
- fiqlParts.push(buildFiql({ FVV_FECVCTO: { op: 'le', value: opts.fechaVencimientoHasta } }));
1085
+ fiqlParts.push(buildFiql({ FVV_FCH_VTO: { op: 'le', value: opts.fechaVencimientoHasta } }));
935
1086
  appendRquery(url, fiqlParts.join(';'));
936
1087
  const path = url.pathname + (url.search ? url.search : '');
937
1088
  const data = await this.get(path);
@@ -1519,6 +1670,114 @@ export class FreematicaClient extends BaseClient {
1519
1670
  logger.debug({ operation, endpoint, body }, 'freematica write body');
1520
1671
  }
1521
1672
  // ---------------------------------------------------------------------------
1673
+ // Clientes, Contactos y Localizaciones — escritura (v0.8.0, sin delete)
1674
+ // ---------------------------------------------------------------------------
1675
+ /**
1676
+ * Alta de cliente.
1677
+ *
1678
+ * Endpoint: POST /pgrl/v2/clientes — body VoClientes.
1679
+ * Requeridos por el API: idReg, COD_GRUPO_CLI, COD_CLI, NOMBRE_CLI, NIF,
1680
+ * TIPO_IMPTO, COD_DIVISA, TIPO_FACT.
1681
+ */
1682
+ async createCliente(body) {
1683
+ this.logWrite('createCliente', 'POST /pgrl/v2/clientes', body);
1684
+ return this.post('/pgrl/v2/clientes', body);
1685
+ }
1686
+ /**
1687
+ * Actualización de cliente.
1688
+ *
1689
+ * Endpoint: PUT /pgrl/v2/clientes/{idReg} — body VoClientes completo
1690
+ * (el caller hace fetch+merge antes de llamar).
1691
+ */
1692
+ async updateCliente(idReg, body) {
1693
+ this.logWrite('updateCliente', `PUT /pgrl/v2/clientes/${idReg}`, body);
1694
+ return this.put(`/pgrl/v2/clientes/${encodeURIComponent(idReg)}`, body);
1695
+ }
1696
+ /**
1697
+ * Detalle de un contacto de cliente por `idReg` opaco.
1698
+ *
1699
+ * Endpoint: GET /pgrl/v1/contactos-clientes/{idreg}
1700
+ * (v2 solo expone el listado; el singular es v1 pero devuelve las mismas
1701
+ * columnas CC_*). Se usa en el fetch+merge del update.
1702
+ */
1703
+ async getContactoCliente(idReg) {
1704
+ return this.get(`/pgrl/v1/contactos-clientes/${encodeURIComponent(idReg)}`);
1705
+ }
1706
+ /**
1707
+ * Alta de contacto de cliente.
1708
+ *
1709
+ * Endpoint: POST /pgrl/v2/contactos-clientes — body VoContactosClientes.
1710
+ * Requeridos por el API: CC_GRUPO_CLI, CC_CLI.
1711
+ */
1712
+ async createContactoCliente(body) {
1713
+ this.logWrite('createContactoCliente', 'POST /pgrl/v2/contactos-clientes', body);
1714
+ return this.post('/pgrl/v2/contactos-clientes', body);
1715
+ }
1716
+ /**
1717
+ * Actualización de contacto de cliente.
1718
+ *
1719
+ * Endpoint: PUT /pgrl/v2/contactos-clientes/{idReg}.
1720
+ */
1721
+ async updateContactoCliente(idReg, body) {
1722
+ this.logWrite('updateContactoCliente', `PUT /pgrl/v2/contactos-clientes/${idReg}`, body);
1723
+ return this.put(`/pgrl/v2/contactos-clientes/${encodeURIComponent(idReg)}`, body);
1724
+ }
1725
+ /**
1726
+ * Detalle de una localización de cliente por tipo e `idReg` opaco.
1727
+ *
1728
+ * Usa el GET singular v2 del tipo (v1 para factura, que no tiene v2; las
1729
+ * columnas son las mismas). Se emplea en el fetch+merge de los updates.
1730
+ */
1731
+ async getLocalizacionCliente(tipo, idReg) {
1732
+ const base = LOC_FIELD_MAP[tipo].getPath;
1733
+ return this.get(`${base}/${encodeURIComponent(idReg)}`);
1734
+ }
1735
+ /**
1736
+ * Lista paginada de localizaciones de envío de clientes.
1737
+ *
1738
+ * Endpoint: GET /pgrl/v2/localizaciones-envio-clientes
1739
+ */
1740
+ async listLocalizacionesEnvioClientes(opts = {}) {
1741
+ return this.listResourceWithFiql('/pgrl/v2/localizaciones-envio-clientes', opts, {
1742
+ COD_CLI: opts.codCliente,
1743
+ COD_GRUPO_CLI: opts.grupoCliente,
1744
+ });
1745
+ }
1746
+ /**
1747
+ * Lista paginada de localizaciones de factura de clientes.
1748
+ *
1749
+ * Endpoint: GET /pgrl/v1/localizaciones-factura-clientes
1750
+ * (la lista solo existe en v1; el v2 de este recurso es solo POST/PUT).
1751
+ */
1752
+ async listLocalizacionesFacturaClientes(opts = {}) {
1753
+ // v1, no v2: la LISTA solo existe en /pgrl/v1 (v2 responde 404 — solo
1754
+ // tiene POST/PUT). Verificado contra el API real y el Postman.
1755
+ return this.listResourceWithFiql('/pgrl/v1/localizaciones-factura-clientes', opts, {
1756
+ COD_CLI: opts.codCliente,
1757
+ COD_GRUPO_CLI: opts.grupoCliente,
1758
+ });
1759
+ }
1760
+ /**
1761
+ * Alta de localización de cliente (cobro, envío, factura o servicio).
1762
+ *
1763
+ * Endpoint: POST /pgrl/v2/localizaciones-{tipo}-clientes — body Vo del tipo.
1764
+ */
1765
+ async createLocalizacionCliente(tipo, body) {
1766
+ const path = LOC_FIELD_MAP[tipo].path;
1767
+ this.logWrite('createLocalizacionCliente', `POST ${path}`, body);
1768
+ return this.post(path, body);
1769
+ }
1770
+ /**
1771
+ * Actualización de localización de cliente (cobro, envío, factura o servicio).
1772
+ *
1773
+ * Endpoint: PUT /pgrl/v2/localizaciones-{tipo}-clientes/{idReg}.
1774
+ */
1775
+ async updateLocalizacionCliente(tipo, idReg, body) {
1776
+ const path = `${LOC_FIELD_MAP[tipo].path}/${encodeURIComponent(idReg)}`;
1777
+ this.logWrite('updateLocalizacionCliente', `PUT ${path}`, body);
1778
+ return this.put(path, body);
1779
+ }
1780
+ // ---------------------------------------------------------------------------
1522
1781
  // Internal helpers
1523
1782
  // ---------------------------------------------------------------------------
1524
1783
  async listResource(path, opts) {