{
  "openapi": "3.1.0",
  "info": {
    "title": "Dipromotions — API para agentes",
    "version": "1.3.0",
    "summary": "Catálogo de artículos promocionales personalizados y cálculo de precios para agentes de IA.",
    "description": "API pública y sin autenticación para que agentes de IA (asistentes, buscadores, tool-calling) consulten el catálogo B2B de Dipromotions, calculen un precio orientativo y soliciten un presupuesto real. Precios en EUR sin IVA. Respuesta de presupuesto en menos de 24 h laborables. v1.1: campo `categorias` renombrado a `categories` y búsquedas sin resultados devuelven sugerencias en vez de un array vacío. v1.2: estos mismos tres pasos están disponibles como servidor MCP (Streamable HTTP) en https://dipromotions.es/api/mcp, con tarjeta en /api/mcp/server-card. v1.3: `?seccion=` para las secciones del catálogo, entre ellas `outlet` (ofertas), y el cálculo de precio acepta el `masterCode` de cualquier proveedor, no solo los códigos que coinciden con el slug.",
    "contact": {
      "name": "Dipromotions",
      "email": "serviciocliente@dipromotions.com",
      "url": "https://dipromotions.es"
    },
    "license": { "name": "Uso libre para consulta y presupuesto" }
  },
  "servers": [{ "url": "https://dipromotions.es" }],
  "tags": [
    { "name": "catalogo", "description": "Descubrimiento de productos" },
    { "name": "precio", "description": "Precio orientativo sin crear solicitud" },
    { "name": "presupuesto", "description": "Solicitud de presupuesto real" }
  ],
  "paths": {
    "/api/agent/catalog": {
      "get": {
        "tags": ["catalogo"],
        "operationId": "listarCatalogo",
        "summary": "Categorías, productos por categoría o búsqueda",
        "description": "Sin parámetros devuelve las categorías con su recuento real de productos (punto de entrada recomendado; los recuentos usan la misma regla que las páginas públicas). Con `categoria` lista los productos de esa categoría (404 con `categoriasValidas` si el slug no existe). Con `q` busca en todo el catálogo por nombre, familia, descripción, color o código; si no hay coincidencias no devuelve un array vacío: responde con `fallback: true`, categorías sugeridas (`intencion` si la reconoce: merchandising, ropa corporativa, ferias, navidad…) y best sellers en `destacados`.",
        "parameters": [
          { "name": "q", "in": "query", "description": "Texto a buscar (mínimo 2 caracteres). Con 0 resultados devuelve la respuesta de intención/fallback descrita en BusquedaSinResultados.", "schema": { "type": "string" }, "example": "lanyard" },
          { "name": "categoria", "in": "query", "description": "Slug de categoría, tal como lo devuelve la llamada sin parámetros.", "schema": { "type": "string" }, "example": "lanyards" },
          { "name": "seccion", "in": "query", "description": "Sección del catálogo, con la misma selección que su página pública. `outlet` son las ofertas y los artículos rebajados.", "schema": { "type": "string", "enum": ["outlet", "bestseller", "novedades", "reciclado", "congresos"] }, "example": "outlet" },
          { "name": "page", "in": "query", "schema": { "type": "integer", "minimum": 1, "default": 1 } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 20 } }
        ],
        "responses": {
          "200": {
            "description": "Categorías (sin filtros), página de productos, o intención/fallback si la búsqueda no dio resultados.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/Categories" },
                    { "$ref": "#/components/schemas/PaginaProductos" },
                    { "$ref": "#/components/schemas/BusquedaSinResultados" }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Categoría desconocida; incluye los slugs válidos.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["error", "categoriasValidas"],
                  "properties": {
                    "error": { "type": "string" },
                    "categoriasValidas": { "type": "array", "items": { "type": "string" } }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/DemasiadasPeticiones" },
          "503": { "$ref": "#/components/responses/CatalogoNoDisponible" }
        }
      }
    },
    "/api/agent/estimate": {
      "post": {
        "tags": ["precio"],
        "operationId": "calcularPrecio",
        "summary": "Precio orientativo de un producto",
        "description": "Calcula el precio a una cantidad dada con la misma fórmula que la tabla de precios de la ficha pública. Devuelve el precio sin personalizar (`unitPrice`) y personalizado (`unitPricePrinted`, por defecto a 1 posición) y, de propina, los cinco tramos publicados en `priceBreaks`. NO crea ninguna solicitud, no guarda datos y no envía correos: es seguro llamarlo varias veces. Para cerrar el precio hace falta `/api/quotes`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/PeticionPrecio" },
              "examples": {
                "botella500": {
                  "summary": "500 botellas, precio sin personalizar y con 1 posición",
                  "value": { "masterCode": "MO2562", "quantity": 500 }
                },
                "lanyardMilDosPosiciones": {
                  "summary": "1.000 lanyards con 2 posiciones de marcaje",
                  "value": { "masterCode": "MO1001", "quantity": 1000, "positions": 2 }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "$ref": "#/components/responses/Precio" },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "404": { "$ref": "#/components/responses/ProductoNoEncontrado" },
          "429": { "$ref": "#/components/responses/DemasiadasPeticiones" },
          "503": { "$ref": "#/components/responses/CatalogoNoDisponible" }
        }
      }
    },
    "/api/quotes": {
      "post": {
        "tags": ["presupuesto"],
        "operationId": "solicitarPresupuesto",
        "summary": "Solicitud de presupuesto real",
        "description": "Registra la solicitud y avisa al equipo comercial. Acepta JSON o multipart/form-data (multipart si se adjunta el logo en el campo `logo`). Con `origin: \"agent\"` la solicitud se marca como venida de un agente y NO se envía copia al cliente: solo la recibe ventas. Requiere un email con dominio que acepte correo. Devuelve el número de presupuesto.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/PeticionPresupuesto" },
              "examples": {
                "agente": {
                  "summary": "Solicitud enviada por un agente",
                  "value": {
                    "masterCode": "MO2562",
                    "quantity": 500,
                    "technique": "serigrafia",
                    "inkColors": 1,
                    "positions": 1,
                    "name": "Ana Pérez",
                    "company": "Ejemplo S.L.",
                    "email": "ana@ejemplo.es",
                    "comments": "Necesitamos entrega en Valencia antes del 15 de diciembre.",
                    "origin": "agent"
                  }
                }
              }
            },
            "multipart/form-data": {
              "schema": { "$ref": "#/components/schemas/PeticionPresupuesto" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Solicitud registrada.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["ok", "quoteId"],
                  "properties": {
                    "ok": { "type": "boolean", "const": true },
                    "quoteId": { "type": "integer", "description": "Número de presupuesto." }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/PeticionInvalida" },
          "429": { "$ref": "#/components/responses/DemasiadasPeticiones" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Categories": {
        "type": "object",
        "required": ["categories"],
        "properties": {
          "categories": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Categoria" }
          },
          "uso": { "type": "string" },
          "estimateEndpoint": { "type": "string" },
          "openapi": { "type": "string", "format": "uri" },
          "llmsFull": { "type": "string", "format": "uri", "description": "Catálogo completo para agentes en texto plano." }
        }
      },
      "Categoria": {
        "type": "object",
        "required": ["slug", "nombre", "productos", "url"],
        "properties": {
          "slug": { "type": "string", "example": "lanyards" },
          "nombre": { "type": "string", "example": "Lanyards" },
          "productos": { "type": "integer", "example": 181 },
          "url": { "type": "string", "format": "uri" }
        }
      },
      "BusquedaSinResultados": {
        "type": "object",
        "description": "La búsqueda no encontró coincidencias literales. Nunca es un array seco: incluye dónde seguir mirando.",
        "required": ["q", "total", "results", "fallback", "categories", "destacados"],
        "properties": {
          "q": { "type": "string" },
          "intencion": { "type": "string", "description": "Intención reconocida en la consulta (merchandising, ropa-corporativa, regalos-empleados, ferias-eventos, oficina, bebidas, tecnologia, lanyard, bolsas, navidad). Ausente si no se reconoció ninguna." },
          "total": { "type": "integer", "const": 0 },
          "results": { "type": "array", "maxItems": 0 },
          "fallback": { "type": "boolean", "const": true },
          "categories": { "type": "array", "description": "Categorías de la intención reconocida, o las de mayor volumen.", "items": { "$ref": "#/components/schemas/Categoria" } },
          "destacados": { "type": "array", "items": { "$ref": "#/components/schemas/Producto" } },
          "siguiente": { "type": "string", "description": "Cómo continuar: ?categoria=<slug>, /api/agent/estimate o /api/quotes." },
          "presupuesto": { "type": "string", "format": "uri" }
        }
      },
      "PaginaProductos": {
        "type": "object",
        "required": ["total", "page", "limit", "results"],
        "properties": {
          "total": { "type": "integer", "description": "Coincidencias totales; la búsqueda devuelve como máximo 200." },
          "page": { "type": "integer" },
          "limit": { "type": "integer" },
          "totalPages": { "type": "integer" },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/Producto" } }
        }
      },
      "Producto": {
        "type": "object",
        "required": ["masterCode", "name", "url", "fromPrice"],
        "properties": {
          "masterCode": { "type": "string", "description": "Código que hay que enviar a /api/agent/estimate.", "example": "MO2562" },
          "name": { "type": "string", "example": "FLORENCE SING" },
          "url": { "type": "string", "format": "uri", "description": "Ficha pública con fotos y tabla de precios." },
          "categorySlug": { "type": "string" },
          "family": { "type": ["string", "null"] },
          "fromPrice": { "type": "number", "description": "Precio mínimo por unidad sin IVA, sin personalizar." },
          "currency": { "type": "string", "const": "EUR" },
          "minOrderQuantity": { "type": ["integer", "null"] },
          "colorCount": { "type": "integer" },
          "imageUrl": { "type": ["string", "null"], "format": "uri" },
          "estimateEndpoint": { "type": "string" }
        }
      },
      "PeticionPrecio": {
        "type": "object",
        "required": ["masterCode", "quantity"],
        "properties": {
          "masterCode": { "type": "string", "example": "MO2562" },
          "quantity": { "type": "integer", "minimum": 1, "maximum": 1000000, "description": "Unidades. Por debajo del mínimo de pedido se tarifica el mínimo (25 ud)." },
          "positions": { "type": "integer", "minimum": 1, "maximum": 10, "default": 1, "description": "Posiciones de marcaje para el precio personalizado. El sin personalizar no depende de esto." }
        }
      },
      "PeticionPresupuesto": {
        "type": "object",
        "required": ["name", "email"],
        "properties": {
          "masterCode": { "type": "string", "description": "Código del producto. Omitir solo para una consulta general." },
          "quantity": { "type": "integer", "minimum": 1 },
          "technique": { "type": ["string", "null"] },
          "inkColors": { "type": "integer", "minimum": 1, "maximum": 8 },
          "positions": { "type": "integer", "minimum": 1, "maximum": 10 },
          "accessories": { "type": "array", "items": { "type": "string" } },
          "name": { "type": "string", "description": "Persona de contacto." },
          "company": { "type": ["string", "null"] },
          "email": { "type": "string", "format": "email" },
          "phone": { "type": ["string", "null"] },
          "comments": { "type": ["string", "null"], "description": "Plazos, destino, detalles del marcaje." },
          "origin": { "type": "string", "default": "web", "description": "Origen de la solicitud. Un agente debe enviar `agent`." }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": { "error": { "type": "string" } }
      }
    },
    "responses": {
      "Precio": {
        "description": "Precio orientativo calculado.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": ["masterCode", "unitPrice", "totalPrice"],
              "properties": {
                "masterCode": { "type": "string" },
                "name": { "type": "string" },
                "url": { "type": "string", "format": "uri" },
                "currency": { "type": "string" },
                "quantity": { "type": "integer", "description": "Unidades realmente tarificadas (mínimo 25)." },
                "requestedQuantity": { "type": "integer" },
                "minOrderQuantity": { "type": "integer" },
                "unitPrice": { "type": "number", "description": "EUR por unidad sin personalizar, sin IVA. Igual que la columna «Sin personalizar» de la tabla." },
                "totalPrice": { "type": "number", "description": "unitPrice × quantity." },
                "unitPricePrinted": { "type": ["number", "null"], "description": "EUR por unidad personalizada a `positions` posiciones. Igual que la columna «Personalizado (desde)»." },
                "totalPricePrinted": { "type": ["number", "null"] },
                "positions": { "type": "integer" },
                "priceBreaks": {
                  "type": "array",
                  "description": "Los cinco tramos publicados en la ficha: sirve para no repetir la llamada por cada cantidad.",
                  "items": {
                    "type": "object",
                    "properties": {
                      "quantity": { "type": "integer" },
                      "unitPrice": { "type": ["number", "null"] },
                      "unitPricePrinted": { "type": ["number", "null"] }
                    }
                  }
                },
                "includesVAT": { "type": "boolean", "const": false },
                "estimate": { "type": "boolean", "const": true },
                "disclaimer": { "type": "string" },
                "quoteUrl": { "type": "string", "format": "uri" },
                "quoteRequired": { "type": "boolean", "description": "true si el producto no tiene precio de catálogo y hay que cerrarlo en presupuesto." },
                "reason": { "type": "string" }
              }
            }
          }
        }
      },
      "PeticionInvalida": {
        "description": "Falta un campo obligatorio o un valor está fuera de rango.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "ProductoNoEncontrado": {
        "description": "No existe ningún producto con ese código.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "DemasiadasPeticiones": {
        "description": "Límite de 60 peticiones por minuto y IP en /api/agent/*, 5 en /api/quotes.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "CatalogoNoDisponible": {
        "description": "El catálogo no responde ahora mismo; reintentar.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  }
}
