# Poner al día una cartera de clientes

Caso completo con la herramienta crm_clients: completar datos, escribir fichas en lote, fusionar duplicados con revisión humana y mantener un espejo al día.

Esta guía recorre de principio a fin un caso real con la herramienta `crm_clients` del servidor `utilia-crm`: una cartera importada a medias que hay que completar, limpiar y mantener sincronizada con otro sistema.

El punto de partida son 65 fichas de cliente que llegaron de una hoja de cálculo con poco más que el nombre y el correo. Faltan la razón social, la dirección, el sector y el logotipo; dos fichas son la misma empresa con el NIF mal escrito en una; y, al terminar, hay que mantener al día una copia de la cartera en otro sistema sin releerla entera cada noche. Necesitas un token OAuth o una clave personal del servidor del CRM.

**Poner al día una cartera en cuatro pasos**

1. **Buscar los datos que faltan** (enrich y enrich_status): Propone razón social, dirección y sector. No toca ninguna ficha.
2. **Escribir las fichas en lote** (create_many y update_many): Hasta 50 por llamada, con el logotipo en la misma llamada.
3. **Fusionar los duplicados** (merge y deduplicate): Una persona revisa la previa; la fusión no se deshace. Si no: sin confirm, solo devuelve la previa con dryRun: true.
4. **Mantener la copia al día** (list con cursor): useCursor y updatedSince traen solo lo cambiado, sin saltos ni repeticiones.

## Qué necesitas

| Paso | Permiso | Scope |
| - | - | - |
| Buscar datos de empresa (`enrich`, `enrich_status`) | `CLIENTS_CREATE` | `crm:clients:create` |
| Alta en lote (`create_many`) | `CLIENTS_CREATE` | `crm:clients:create` |
| Edición en lote (`update_many`) | `CLIENTS_EDIT` | `crm:clients:update` |
| Previa por reglas (`deduplicate_preview`) | `CLIENTS_VIEW` | `crm:clients:view` |
| Fusionar (`deduplicate`, `merge` y su previa) | `CLIENTS_MERGE` | `crm:clients:merge` |
| Recorrer y sincronizar (`list`) | `CLIENTS_VIEW` | `crm:clients:view` |

> **Atención: El scope de fusión se pide por su nombre**
>
> `crm:clients:merge` no está dentro de `crm:read`, `crm:write` ni `crm:full`. Fusionar archiva fichas y reescribe sus referencias sin vuelta atrás, así que ninguna autorización antigua lo concede sin que el titular lo apruebe. Añádelo a los scopes que pide tu aplicación. El catálogo completo está en [Scopes y recursos de OAuth](https://os.utilia.ai/docs/developers/oauth/scopes-and-resources).

Los SDK cubren todo lo de esta guía en `sdk.mcp.crm`, con una sesión OAuth autorizada para el servidor `utilia-crm` ([cómo se configura](https://os.utilia.ai/docs/developers/mcp/quickstart#llamar-a-las-herramientas-desde-tu-código)). Los ejemplos usan la variante asíncrona; en Python existe la síncrona con los mismos nombres.

## Paso 1: buscar los datos que faltan

`enrich` encarga una búsqueda en internet de los datos de una empresa a partir de su nombre y devuelve un identificador de trabajo; la propuesta se lee después con `enrich_status`.

`enrich` no crea ni modifica ninguna ficha: devuelve una propuesta con su confianza y sus fuentes, y guardarla es una llamada aparte a `create` o `update` con los campos que aceptes. Pide el permiso de crear clientes, y no el de verlos, porque consume bolsa de IA y sale a internet. Los resultados caducan al cabo de una hora y solo se sirven a quien lanzó la búsqueda.

En `existingData` van los datos que ya conoces y que la búsqueda debe respetar: si la hoja de cálculo traía el correo bueno, pásalo ahí y no te propondrá otro.

### JavaScript / TypeScript

```typescript
import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  oauth: {
    clientId: process.env.UTILIA_CLIENT_ID!,
    redirectUri: 'https://mi-app.example.com/callback',
  },
});

// Una búsqueda por empresa. Devuelve el identificador del trabajo, no los datos.
const trabajos = [];
for (const fila of cartera) {
  const { data } = await sdk.mcp.crm.clients.enrich({
    name: fila.nombre,
    existingData: { email: fila.email, website: fila.web },
  });
  trabajos.push({ fila, jobId: data.jobId });
}

// La propuesta se lee después. Solo con jobState 'completed' hay result.
const propuestas = [];
for (const t of trabajos) {
  const { data } = await sdk.mcp.crm.clients.enrichStatus(t.jobId);
  if (data.jobState === 'completed' && data.result) {
    propuestas.push({ fila: t.fila, datos: data.result });
  }
}
```

### Python

```python
from utilia_sdk import UtiliaSDK

async with UtiliaSDK(
    base_url="https://os.utilia.ai/api",
    oauth={
        "client_id": "tu-client-id",
        "redirect_uri": "https://mi-app.example.com/callback",
    },
) as sdk:
    # Una búsqueda por empresa. Devuelve el identificador del trabajo.
    trabajos = []
    for fila in cartera:
        job = await sdk.mcp.crm.clients.enrich(
            fila["nombre"],
            existing_data={"email": fila["email"], "website": fila["web"]},
        )
        trabajos.append((fila, job.job_id))

    # La propuesta se lee después. Solo con job_state 'completed' hay result.
    propuestas = []
    for fila, job_id in trabajos:
        estado = await sdk.mcp.crm.clients.enrich_status(job_id)
        if estado.job_state == "completed" and estado.result:
            propuestas.append((fila, estado.result))
```

Antes de dar de alta cada ficha, `check_duplicate` dice si ya existe un cliente parecido (mismo NIF, mismo correo, mismo teléfono o nombre parecido en la misma ciudad). Es una lectura con `crm:clients:view`. Si el parecido existe pero queda fuera de tu alcance, la respuesta es `{ hasDuplicates: true, reason: 'EXISTS_OUT_OF_SCOPE' }`, sin datos de esa ficha: compruébalo con quien lleva esa cuenta antes de dar de alta otra.

## Paso 2: escribir las 65 fichas y sus logotipos

Caben 50 fichas por llamada, así que la cartera va en dos lotes, de 50 y de 15. El lote viaja en `items` y cada elemento tiene la misma forma que los argumentos de `create`.

El logotipo va en la misma llamada, por una de tres vías que no se pueden combinar: `logoUrl`, una dirección `https` pública que descarga el servidor; `logoBase64` con `logoMimeType`, la imagen pegada, hasta 512 KB por elemento del lote; o `logoFileId`, una imagen ya subida al gestor de archivos.

### JavaScript / TypeScript

```typescript
const primeros = await sdk.mcp.crm.clients.createMany(
  propuestas.slice(0, 50).map((p, i) => ({
    name: p.fila.nombre,
    legalName: p.datos.legalName,
    taxId: p.datos.taxId,
    email: p.fila.email,
    city: p.datos.city,
    industry: p.datos.industry,
    logoUrl: p.datos.logoUrl,        // https pública; la descarga el servidor
    // Clave distinta por elemento: evita repetirlo dentro de esta petición.
    clientOperationId: `alta-2026-09-03-${i}`,
  })),
);

console.log(primeros.data.summary);   // { total: 50, ok: 48, failed: 2 }
console.log(primeros.data.failed);    // los dos, con su index, su code y su motivo

// OJO: las fichas cuyo LOGOTIPO no entró están en `succeeded`, no en `failed`.
const sinLogo = primeros.data.succeeded.filter((s) => s.data.logo?.ok === false);

// Se reintenta solo la imagen, sin volver a tocar la ficha.
if (sinLogo.length > 0) {
  await sdk.mcp.crm.clients.updateMany(
    sinLogo.map((s, i) => ({
      id: s.data.id,
      logoUrl: otraFuenteDeLogotipo(s.data.name),
      clientOperationId: `logo-2026-09-03-${i}`,
    })),
  );
}
```

### Python

```python
from utilia_sdk.mcp.types import CreateCrmClientBatchItem, UpdateCrmClientBatchItem

primeros = await sdk.mcp.crm.clients.create_many([
    CreateCrmClientBatchItem(
        name=p["nombre"],
        legal_name=datos.get("legalName"),
        tax_id=datos.get("taxId"),
        email=p["email"],
        city=datos.get("city"),
        industry=datos.get("industry"),
        logo_url=datos.get("logoUrl"),   # https pública; la descarga el servidor
        # Clave distinta por elemento.
        client_operation_id=f"alta-2026-09-03-{i}",
    )
    for i, (p, datos) in enumerate(propuestas[:50])
])

print(primeros.summary)   # total, ok, failed
print(primeros.failed)    # cada fallo con su index, su code y su motivo

# OJO: las fichas cuyo LOGOTIPO no entró están en `succeeded`, no en `failed`.
sin_logo = [s for s in primeros.succeeded if s.data.logo and not s.data.logo.ok]

if sin_logo:
    await sdk.mcp.crm.clients.update_many([
        UpdateCrmClientBatchItem(
            id=s.data.id,
            logo_url=otra_fuente_de_logotipo(s.data.name),
            client_operation_id=f"logo-2026-09-03-{i}",
        )
        for i, s in enumerate(sin_logo)
    ])
```

### Lo que conviene saber del lote

- **Un elemento inválido no detiene a los demás.** La respuesta trae `succeeded`, con la posición original de cada ficha que entró; `failed`, con la posición, el código y el motivo de cada fallo; y `summary`, con los totales. Solo corta la llamada entera un lote mal formado: que `items` no sea una lista, esté vacía, pase de 50 elementos o alguno no sea un objeto.
- **La ficha vuelve en vista compacta**: identidad, estado, contacto básico y el resultado del logotipo, sin contactos, direcciones ni personas asignadas. Para la ficha completa, `get`. `create` y `update` sueltos también devuelven la vista compacta.
- **`clientOperationId` solo vale dentro de la misma petición.** Si dos elementos llevan la misma clave y el mismo contenido, se escribe una sola vez; con contenido distinto, el segundo falla con `CONFLICT`. Pero si reenvías el lote en otra llamada, las fichas que ya entraron se vuelven a crear: reenvía solo los elementos de `failed`. Usa una clave distinta por elemento.
- **`parentClientId` no se admite en un elemento del lote**: es un cambio de jerarquía con permiso propio (`CLIENTS_MANAGE_HIERARCHY`) y el elemento falla con su motivo. El vínculo con la matriz se hace después con `crm_clients_info(operation: "link_subsidiary")`.
- **En `update_many`, un `id` que no es de tu organización** sale como `NOT_FOUND` en ese elemento, sin frenar a los demás.
- **El nombre del lote cambia según la herramienta**: `items` en clientes, `contacts` en contactos y `leads` en leads. Con otro nombre, la llamada da un error de validación.

> **Importante: Un logotipo que falla no da error**
>
> La ficha se crea igual, y la herramienta responde con `isError` en falso, así que un `try`/`catch` no se entera. En un lote, el elemento sale en `succeeded` con `logo: { ok: false, error }`; en `create` y `update` sueltos, la respuesta trae `logoWarning` con el motivo. Revisa siempre ese campo antes de dar el logotipo por puesto. En el SDK de Python, `create` y `update` devuelven el modelo `CrmClient`, que no lleva `logoWarning`: en un alta suelta, comprueba `logo_file_id`.

## Paso 3: fusionar las dos fichas repetidas

`deduplicate` agrupa por NIF normalizado cuando lo hay y, si no, por correo, dominio o nombre y ciudad. Nunca junta dos NIF distintos, así que dos fichas de la misma empresa con el NIF mal escrito en una no forman grupo: la salida es `merge`, que fusiona las fichas que señalas a mano.

### Enseña la previa antes de tocar nada

Hay dos previas, y no son lo mismo:

| Previa | Qué muestra | Permiso y scope |
| - | - | - |
| `deduplicate_preview` | Los grupos que detectan las reglas, con la ficha que sobreviviría y lo que se movería. Es una lectura. | `CLIENTS_VIEW` y `crm:clients:view` |
| `merge` sin `confirm` | El simulacro de una fusión concreta que eliges tú. | `CLIENTS_MERGE` y `crm:clients:merge` |

`merge` sin `confirm` no escribe nada, pero es la misma operación destructiva: exige el permiso y el scope de fusionar. Y la respuesta de la previa tiene la misma forma que la de la fusión aplicada; lo único que las distingue es `dryRun`. Míralo siempre antes de dar algo por fusionado.

#### JavaScript / TypeScript

```typescript
// 1. Qué pasaría. No se escribe nada: dryRun llega en true.
const previa = await sdk.mcp.crm.clients.mergePreview({
  survivorId: 'cli_canesa',
  duplicateIds: ['cli_caneisa'],
  // Los NIF son distintos porque uno está mal tecleado: hay que afirmarlo.
  allowDifferentTaxId: true,
});

console.log(previa.data.dryRun);                 // true
console.log(previa.data.survivor.invoices);      // 128 facturas ya tiene
console.log(previa.data.duplicates[0].invoices); // 34 se le re-colgarían
console.log(previa.data.reassigned);             // el total de lo que se movería
console.log(previa.data.stripeCustomerConflict); // conflicto que NO se resuelve solo

// 2. Una persona lo mira y lo aprueba. Y entonces, y solo entonces:
const hecho = await sdk.mcp.crm.clients.merge({
  survivorId: 'cli_canesa',
  duplicateIds: ['cli_caneisa'],
  allowDifferentTaxId: true,
});

console.log(hecho.data.dryRun);              // false: esto sí ha ocurrido
console.log(hecho.data.archived);            // 1 ficha archivada
console.log(hecho.data.invoicesReassigned);  // 34
```

#### Python

```python
# 1. Qué pasaría. No se escribe nada: dry_run llega en True.
previa = await sdk.mcp.crm.clients.merge_preview(
    survivor_id="cli_canesa",
    duplicate_ids=["cli_caneisa"],
    # Los NIF son distintos porque uno está mal tecleado: hay que afirmarlo.
    allow_different_tax_id=True,
)

print(previa.dry_run)                      # True
print(previa.survivor.invoices)            # 128 facturas ya tiene
print(previa.duplicates[0].invoices)       # 34 se le re-colgarían
print(previa.reassigned)                   # el total de lo que se movería
print(previa.stripe_customer_conflict)     # conflicto que NO se resuelve solo

# 2. Una persona lo mira y lo aprueba. Y entonces, y solo entonces:
hecho = await sdk.mcp.crm.clients.merge(
    survivor_id="cli_canesa",
    duplicate_ids=["cli_caneisa"],
    allow_different_tax_id=True,
)

print(hecho.dry_run)               # False: esto sí ha ocurrido
print(hecho.archived)              # 1 ficha archivada
print(hecho.invoices_reassigned)   # 34
```

### Qué recibe la ficha que sobrevive

`survivorId` se conserva y recibe los contactos, oportunidades, proyectos, presupuestos, tareas, facturas, autorizaciones de cobro, accesos al portal, archivos, carpetas e interacciones de las fichas duplicadas, y rellena sus campos vacíos con los datos de ellas. Las fichas duplicadas quedan archivadas, no borradas. `duplicateIds` admite de 1 a 20 fichas por llamada, sin repetir y sin incluir la que sobrevive.

Un conflicto no se resuelve solo y la respuesta lo nombra: `stripeCustomerConflict`, cuando las dos fichas tenían su propio cliente en Stripe y el de la duplicada no se traslada.

`allowDifferentTaxId` y `allowPortalAccessMerge` son confirmaciones expresas de un riesgo, y exigen alcance total sobre el permiso de fusionar clientes. Sin ellas, esos dos casos se rechazan:

- `allowDifferentTaxId` afirma que dos NIF distintos son la misma empresa. No se admite si las dos fichas tienen facturas emitidas, porque mezclaría las declaraciones de operaciones con terceros.
- `allowPortalAccessMerge` acepta que los interlocutores externos de una empresa pasen a ver los documentos, las facturas y los tickets de la otra. Eso no se deshace desde la aplicación.

### Cuando los duplicados los detectan las reglas

Para el resto de la cartera, el camino es la previa por reglas y después la fusión acotada a los grupos que una persona apruebe.

#### JavaScript / TypeScript

```typescript
// Lectura pura bajo crm:clients:view: se puede enseñar sin pedir el de fusionar.
const grupos = await sdk.mcp.crm.clients.deduplicatePreview();

console.log(grupos.data.totalDuplicates);
grupos.data.groups.forEach((g) => {
  console.log(g.groupId, g.matchType, g.primary.name, g.duplicates.length);
});

// Una persona marca los que acepta. Los demás se quedan como están.
const aceptados = grupos.data.groups.filter(esEvidente).map((g) => g.groupId);

const resultado = await sdk.mcp.crm.clients.deduplicate({
  groupIds: aceptados,
  // Opcional: quién sobrevive en cada grupo, en vez de la regla por defecto.
  survivorIds: ['cli_la_que_queremos'],
});

console.log(resultado.data.groups);           // grupos fusionados
console.log(resultado.data.ignoredGroupIds);  // los que ya no eran duplicados
```

#### Python

```python
# Lectura pura bajo crm:clients:view: se puede enseñar sin pedir el de fusionar.
grupos = await sdk.mcp.crm.clients.deduplicate_preview()

print(grupos.total_duplicates)
for g in grupos.groups:
    print(g.group_id, g.match_type, g.primary.name, len(g.duplicates))

# Una persona marca los que acepta. Los demás se quedan como están.
aceptados = [g.group_id for g in grupos.groups if es_evidente(g)]

resultado = await sdk.mcp.crm.clients.deduplicate(
    group_ids=aceptados,
    # Opcional: quién sobrevive en cada grupo, en vez de la regla por defecto.
    survivor_ids=["cli_la_que_queremos"],
)

print(resultado.groups)             # grupos fusionados
print(resultado.ignored_group_ids)  # los que ya no eran duplicados
```

- **`survivorIds` es todo o nada.** Un identificador que no corresponde a ningún grupo detectado rechaza la llamada entera sin fusionar nada. Admite como mucho uno por grupo.
- **`groupIds` acota la fusión a los grupos aprobados.** Sin él se fusionan todos los detectados.
- **`ignoredGroupIds` no es un error**: son grupos que ya no existen porque alguien los fusionó entre la previa y la confirmación.
- **La previa omite el grupo entero** si alguna de sus fichas queda fuera de tu alcance de fusión. Los recuentos de cada ficha viajan siempre; el NIF, el NIF-IVA, el límite de crédito y el descuento, solo con `CLIENTS_VIEW_SENSITIVE` y el scope `crm:clients:view-sensitive`.

## Paso 4: mantener la copia al día

Los listados del CRM paginan por desplazamiento (`limit` y `offset`), y en un recorrido largo con escrituras a la vez eso se descuadra: cuando entra una fila en una página ya servida, las demás se desplazan y el lector se salta unas y repite otras. Para mantener una copia, eso es perder datos sin enterarse.

El cursor lo resuelve: guarda los valores de ordenación de la última fila servida y pide la página siguiente por comparación, así que las filas que entran o salen antes del cursor no mueven nada.

- **`useCursor: true` abre el recorrido.** Sin cursor, la llamada va por desplazamiento y no devuelve ninguno. Con `useCursor: true`, la primera página sale ordenada por fecha de última modificación y trae `pageInfo.nextCursor`; después basta con reenviar el cursor recibido.
- **En el recorrido por cursor el total no se conoce** (`totalKnown: false`). Lo que dice si has terminado es `pageInfo.hasMore`.
- **Un cursor de otro listado, o ilegible, se rechaza**, en lugar de volver a la primera página sin avisar.

### JavaScript / TypeScript

```typescript
// Marca del último refresco, guardada por tu lado. ISO 8601 CON zona.
const desde = '2026-09-01T00:00:00Z';

let cursor: string | undefined;
let primera = true;

do {
  const { data } = await sdk.mcp.crm.clients.list({
    updatedSince: desde,
    ...(primera ? { useCursor: true } : { cursor }),
    limit: 100,
  });

  for (const cliente of data.items) {
    await espejo.guardar(cliente);
  }

  cursor = data.pageInfo.nextCursor ?? undefined;
  primera = false;

  // `total` no sirve aquí: en el recorrido por cursor llega con totalKnown en falso.
} while (cursor);
```

### Python

```python
from utilia_sdk.mcp.types import CrmClientListFilters

# Marca del último refresco, guardada por tu lado. ISO 8601 CON zona.
desde = "2026-09-01T00:00:00Z"

cursor = None
primera = True

while True:
    filtros = CrmClientListFilters(
        updated_since=desde,
        use_cursor=True if primera else None,
        cursor=None if primera else cursor,
        limit=100,
    )
    pagina = await sdk.mcp.crm.clients.list(filtros)

    for cliente in pagina.items:
        await espejo.guardar(cliente)

    cursor = pagina.page_info.next_cursor if pagina.page_info else None
    primera = False

    # `total` no sirve aquí: llega con total_known en falso.
    if not cursor:
        break
```

`updatedSince` solo acepta ISO 8601 con zona horaria explícita (`2026-09-01T00:00:00Z` o `2026-09-01T02:00:00+02:00`). Una fecha sin zona se rechaza con un error, en lugar de ignorarse y devolver la cartera entera. No lo confundas con `createdAtFrom` y `lastContactedFrom`, que admiten `YYYY-MM-DD` porque son fechas del calendario de tu organización.

Las fichas archivadas por una fusión no salen en el listado. Si tu copia aún guarda el identificador de una, `includeMerged: true` las incluye para que puedas cerrarla o apuntarla a la que sobrevivió; las filas siguen pasando tu permiso y tu alcance.

`cursor`, `useCursor` y `updatedSince` funcionan igual en `crm_contacts` y en `crm_leads`.

## Resumen

| Dónde | Lo que parece | Lo que es |
| - | - | - |
| `create_many` / `update_many` | Reenviar el lote es seguro | `clientOperationId` solo deduplica dentro de la misma petición: reenvía solo lo que falló |
| `create_many` / `update_many` | Un fallo tumba el lote | El elemento inválido sale en `failed` y los demás entran |
| Logotipo | Si no hay excepción, entró | Un fallo de la imagen vuelve en `logoWarning` o en `logo.ok: false`, con `isError` en falso |
| `merge` | La previa es una lectura | Es la misma operación destructiva y exige el scope de fusionar |
| `merge` | La respuesta dice si se fusionó | Solo lo dice `dryRun` |
| `deduplicate` | Un `survivorIds` que sobra se descarta | Rechaza la llamada entera |
| `list` | Basta con enviar `cursor` | La primera página necesita `useCursor: true` |
| `updatedSince` | Vale una fecha suelta | Sin zona horaria se rechaza |

## Siguiente paso

Con la cartera limpia y sincronizada, desde la ficha del cliente se abren estos caminos:

- **Sus contactos**, con `crm_contacts`, que tiene los mismos lotes, previa y fusión, con su scope `crm:contacts:merge`. Dos fichas con usuario del portal no se pueden unir, y si alguna no puede recibir correos, la que sobrevive tampoco podrá.
- **El acceso de esos contactos al portal del cliente**, con `portal_access`, que exige `crm:portal:view` y `crm:portal:manage`.
- **La venta**, con `crm_opportunities` y `crm_budgets`, y de ahí a la factura y el cobro en el servidor de [Finanzas](https://os.utilia.ai/docs/developers/mcp/reference#finanzas-servidor-utilia-finance).
- **La empresa como proveedor**: `crm_clients(operation: "get_counterpart")` dice si la misma empresa está también dada de alta como proveedor. Exige los aplicativos CRM y Finanzas y los permisos de ver clientes, ver proveedores y sus datos sensibles.
