{
  "name": "Stock muerto y análisis ABC: informe del lunes",
  "nodes": [
    {
      "parameters": {
        "content": "> Plantilla de partida de fernandorivera.es. Úsala y adáptala libremente; sin garantía; revisa los datos que tratas.\n\n## Stock muerto + ABC, cada lunes\n1. Lee dos pestañas exportadas del TPV, Holded o el BI: **Ventas** y **Stock**.\n2. Un nodo de **código** calcula la clase ABC por venta y por margen, los días sin venta, el valor del stock y la marca *Vigilar* (60 días) o *Muerto* (90 días).\n3. Escribe el resultado en la pestaña **Resultado**.\n4. La IA **solo explica** la lista ya calculada y propone acciones. No calcula nada.\n5. Te llega un correo con la explicación y la tabla.\n\nEs una **plantilla de partida, no probada en tu cuenta**: revísala y compara el resultado con unas cuantas referencias que conozcas antes de fiarte.\n\nMontaje completo en «Monta tu app: detector de stock muerto con análisis ABC», en fernandorivera.es.",
        "height": 440,
        "width": 540,
        "color": 4
      },
      "id": "cb150752-e78c-5b95-bfbd-1557f45ba9bb",
      "name": "Nota: qué hace",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        -640
      ]
    },
    {
      "parameters": {
        "content": "## 1. Prepara la hoja\n**Ventas** (una fila por referencia, del periodo que elijas, por ejemplo 12 meses):\n`SKU` · `Descripción` · `Familia` · `Unidades` · `Venta neta` · `Margen bruto` · `Última venta`\n\n**Stock** (foto del stock de hoy):\n`SKU` · `Stock` · `Coste unitario`\n\n**Resultado** (vacía, solo la fila 1):\n`SKU` · `Descripción` · `Familia` · `Venta neta` · `Margen bruto` · `ABC venta` · `ABC margen` · `Días sin venta` · `Stock` · `Valor stock` · `Estado` · `Actualizado`\n\n- Importes sin IVA. `Última venta` como fecha (03/11/2026 o 2026-11-03).\n- Si tu exportación no trae `Última venta`, mira el artículo: se saca de un informe de líneas de venta.",
        "height": 460,
        "width": 560,
        "color": 5
      },
      "id": "b8580bce-c43d-568b-a898-05ada57ae5a0",
      "name": "Nota: las pestañas",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -200,
        -680
      ]
    },
    {
      "parameters": {
        "content": "## 2. Configura\n- **Configuración**: días para *Vigilar* y *Muerto*, cortes A y B (0,8 y 0,95 son los habituales), cuántas filas mandar a la IA, URL y modelo de la IA y correo.\n- Nodos de Google Sheets: cambia `TU_ID_DE_HOJA` y crea la credencial de Google.\n- **IA explica**: credencial *Header Auth* con `Authorization` = `Bearer TU_CLAVE`.\n\n**Cadena con SAP, Oracle o Dynamics**: no conectes nada al ERP. Pega en la hoja la exportación del informe de BI que ya recibes, con una herramienta y una IA que tu empresa haya aprobado. Si la empresa usa n8n autoalojado o Power Automate, mejor ahí.",
        "height": 400,
        "width": 520,
        "color": 5
      },
      "id": "5aba98ac-797f-5ce1-b78c-ec91164fd313",
      "name": "Nota: configura",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        400,
        -680
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 7,
              "triggerAtMinute": 30
            }
          ]
        }
      },
      "id": "020babf8-ff0f-5473-a7e1-594ddc4b033e",
      "name": "Cada lunes 7:30",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -700,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "e654b999-09a3-5783-941a-d24b7c4c4586",
              "name": "dias_vigilar",
              "value": 60,
              "type": "number"
            },
            {
              "id": "eb60ba2d-38c9-59f5-bcf4-cbb3980cc3b9",
              "name": "dias_muerto",
              "value": 90,
              "type": "number"
            },
            {
              "id": "dd02d74d-26ad-5dcc-973c-d53272df7a64",
              "name": "corte_a",
              "value": 0.8,
              "type": "number"
            },
            {
              "id": "588b6ae0-7fc2-5939-a2e6-e355837ab60f",
              "name": "corte_b",
              "value": 0.95,
              "type": "number"
            },
            {
              "id": "39a30a5a-2fd1-5c68-a8d6-ec77f2871124",
              "name": "max_filas_ia",
              "value": 25,
              "type": "number"
            },
            {
              "id": "e78667b3-95a6-5d75-970b-7662b77fc6ae",
              "name": "url_api_ia",
              "value": "https://api.openai.com/v1/chat/completions",
              "type": "string"
            },
            {
              "id": "cd22518e-6fd7-5190-9a10-54d01ad4fe0a",
              "name": "modelo",
              "value": "TU_MODELO",
              "type": "string"
            },
            {
              "id": "54c4d16f-8114-5ac7-987e-d622d6773e81",
              "name": "email_informe",
              "value": "TU_CORREO",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "1492c190-4287-5279-b90c-16360cf405fc",
      "name": "Configuración",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -480,
        0
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "TU_ID_DE_HOJA",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Ventas",
          "mode": "name"
        },
        "options": {}
      },
      "id": "34b3aa63-d2bb-5818-bf6e-097a803c25a9",
      "name": "Leer ventas",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        -260,
        0
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "TU_ID_DE_HOJA",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Stock",
          "mode": "name"
        },
        "options": {}
      },
      "id": "3346c601-0848-55b0-b086-b3ae9e9de1bc",
      "name": "Leer stock",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        -40,
        0
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "// Todo el cálculo está aquí. La IA no calcula nada.\nconst cfg = $('Configuración').first().json;\nconst num = v => { const n = parseFloat(String(v ?? '').replace(/\\s|€/g, '').replace(/\\.(?=\\d{3}(\\D|$))/g, '').replace(',', '.')); return isNaN(n) ? 0 : n; };\nconst fecha = v => { const s = String(v || '').trim(); let d = DateTime.fromFormat(s, 'd/M/yyyy'); if (!d.isValid) d = DateTime.fromISO(s); return d.isValid ? d : null; };\nconst hoy = $now.startOf('day');\nconst refs = {};\nfor (const i of $('Leer ventas').all()) {\n  const r = i.json; const sku = String(r['SKU'] || '').trim(); if (!sku) continue;\n  refs[sku] = { SKU: sku, 'Descripción': r['Descripción'] || '', Familia: r['Familia'] || '', venta: num(r['Venta neta']), margen: num(r['Margen bruto']), ultima: fecha(r['Última venta']), stock: 0, coste: 0 };\n}\nfor (const i of $input.all()) {\n  const r = i.json; const sku = String(r['SKU'] || '').trim(); if (!sku) continue;\n  if (!refs[sku]) refs[sku] = { SKU: sku, 'Descripción': r['Descripción'] || '', Familia: r['Familia'] || '', venta: 0, margen: 0, ultima: null, stock: 0, coste: 0 };\n  refs[sku].stock = num(r['Stock']); refs[sku].coste = num(r['Coste unitario']);\n}\nconst lista = Object.values(refs);\n// Clase ABC: se ordena de mayor a menor y se mira el % acumulado ANTES de cada referencia.\nconst abc = (campo, destino) => {\n  const total = lista.reduce((s, x) => s + Math.max(x[campo], 0), 0);\n  let acum = 0;\n  [...lista].sort((a, b) => b[campo] - a[campo]).forEach(x => {\n    if (x[campo] <= 0 || total <= 0) { x[destino] = 'C'; return; }\n    const previo = acum / total;\n    x[destino] = previo < cfg.corte_a ? 'A' : previo < cfg.corte_b ? 'B' : 'C';\n    acum += x[campo];\n  });\n};\nabc('venta', 'abcVenta'); abc('margen', 'abcMargen');\nreturn lista.map(x => {\n  const dias = x.ultima ? Math.floor(hoy.diff(x.ultima.startOf('day'), 'days').days) : null;\n  let estado = '';\n  if (x.stock > 0 && (dias === null || dias >= cfg.dias_muerto)) estado = 'Muerto';\n  else if (x.stock > 0 && dias >= cfg.dias_vigilar) estado = 'Vigilar';\n  return { json: {\n    SKU: x.SKU, 'Descripción': x['Descripción'], Familia: x.Familia,\n    'Venta neta': Math.round(x.venta * 100) / 100, 'Margen bruto': Math.round(x.margen * 100) / 100,\n    'ABC venta': x.abcVenta, 'ABC margen': x.abcMargen,\n    'Días sin venta': dias === null ? 'sin venta' : dias,\n    Stock: x.stock, 'Valor stock': Math.round(x.stock * x.coste * 100) / 100,\n    Estado: estado, Actualizado: $now.toFormat('yyyy-MM-dd')\n  } };\n});"
      },
      "id": "255878b7-e22c-54da-9541-7d154d39890c",
      "name": "Calcular ABC y días sin venta",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        180,
        0
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "TU_ID_DE_HOJA",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Resultado",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "SKU"
          ],
          "schema": []
        },
        "options": {}
      },
      "id": "67f5dccb-2000-5fcb-83c4-226647bb1f18",
      "name": "Escribir resultado",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        400,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Configuración').first().json;\nconst filas = $('Calcular ABC y días sin venta').all().map(i => i.json);\nconst eur = n => n.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';\nconst cuenta = (c, k) => filas.filter(f => f[c] === k).length;\nconst muertos = filas.filter(f => f.Estado === 'Muerto').sort((a, b) => b['Valor stock'] - a['Valor stock']);\nconst vigilar = filas.filter(f => f.Estado === 'Vigilar').sort((a, b) => b['Valor stock'] - a['Valor stock']);\nconst suma = l => l.reduce((s, f) => s + f['Valor stock'], 0);\nconst resumen = 'Referencias: ' + filas.length + '. ABC por venta: ' + cuenta('ABC venta', 'A') + ' A, ' + cuenta('ABC venta', 'B') + ' B, ' + cuenta('ABC venta', 'C') + ' C. ' +\n  'Muerto (' + cfg.dias_muerto + '+ días): ' + muertos.length + ' refs, ' + eur(suma(muertos)) + ' a coste. ' +\n  'Vigilar (' + cfg.dias_vigilar + '+ días): ' + vigilar.length + ' refs, ' + eur(suma(vigilar)) + ' a coste.';\nconst top = [...muertos, ...vigilar].slice(0, cfg.max_filas_ia);\nconst cab = ['SKU', 'Descripción', 'Familia', 'ABC venta', 'ABC margen', 'Días sin venta', 'Stock', 'Valor stock', 'Estado'];\nconst tabla = cab.join(' | ') + '\\n' + top.map(f => cab.map(c => f[c]).join(' | ')).join('\\n');\nconst html = '<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\"><tr>' + cab.map(c => '<th>' + c + '</th>').join('') + '</tr>' +\n  top.map(f => '<tr>' + cab.map(c => '<td>' + (c === 'Valor stock' ? eur(f[c]) : f[c]) + '</td>').join('') + '</tr>').join('') + '</table>';\nreturn [{ json: { hay: top.length > 0, resumen, html, body: {\n  model: cfg.modelo, temperature: 0.2,\n  messages: [{ role: 'user', content: \"Eres la persona responsable de surtido de una tienda. Te paso una tabla YA CALCULADA con las referencias que tienen stock y llevan tiempo sin venderse: SKU, descripción, familia, clase ABC por venta, clase ABC por margen, días sin venta, unidades en stock y valor del stock a coste. No recalcules ni cambies ninguna cifra: si citas una, cópiala tal cual. Empieza con dos frases sobre el resumen. Después, para cada referencia, por orden de valor de stock, propone UNA acción de esta lista y explica en una frase por qué: traspasar a otra tienda, cambiar la ubicación o la exposición, hacer pack o venta cruzada con un producto A, rebaja escalonada, devolver al proveedor si el acuerdo lo permite, liquidar o donar, no volver a comprar. Si falta información para decidir (temporada, fecha de entrada, acuerdos con el proveedor), dilo en lugar de suponer. Máximo 25 líneas, en español de España.\" + '\\n\\nResumen: ' + resumen + '\\n\\n' + tabla }]\n} } }];"
      },
      "id": "604844bf-3723-5651-b8f1-c0762b77268b",
      "name": "Preparar resumen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        620,
        0
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "0c9c9046-c22e-5b70-a369-9265029de966",
              "leftValue": "={{ $json.hay }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "309754e9-9ba3-53ad-8344-5bca099b4a96",
      "name": "¿Hay stock parado?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        840,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Configuración').first().json.url_api_ia }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body) }}",
        "options": {
          "timeout": 60000
        }
      },
      "id": "a2a36476-5b1c-5cf2-96be-975f729c8649",
      "name": "IA explica y propone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1060,
        -100
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Configuración').first().json.email_informe }}",
        "subject": "=Stock parado y ABC · lunes {{ $now.toFormat('dd/MM') }}",
        "emailType": "html",
        "message": "=<p><b>Resumen (calculado por la hoja, no por la IA):</b><br>{{ $('Preparar resumen').first().json.resumen }}</p><p><b>Lectura y propuestas de la IA (revísalas antes de actuar):</b><br>{{ $json.choices[0].message.content.replace(/\\n/g, '<br>') }}</p>{{ $('Preparar resumen').first().json.html }}<p>Detalle completo en la pestaña Resultado de la hoja.</p>",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "e4529bc5-ef66-502b-bf83-8851dd37d37f",
      "name": "Enviar informe",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1280,
        -100
      ],
      "webhookId": "1ca0d5a5-9d62-5ec3-8172-de5c9ea115df"
    },
    {
      "parameters": {},
      "id": "048789d1-1921-58ff-8a0c-b37bd43fedb1",
      "name": "Nada parado esta semana",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1060,
        120
      ]
    },
    {
      "parameters": {
        "content": "### Por qué la IA no calcula\nUn modelo de lenguaje puede equivocarse sumando o redondeando y no avisa. Los porcentajes acumulados, las clases y los días los hace el código; la IA recibe la tabla terminada y solo redacta. En el correo, el resumen numérico sale del código, no de la IA.",
        "height": 220,
        "width": 440,
        "color": 3
      },
      "id": "43766f67-aede-5a12-a3cf-c1501861baaf",
      "name": "Nota: por qué la IA no calcula",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        620,
        200
      ]
    }
  ],
  "connections": {
    "Cada lunes 7:30": {
      "main": [
        [
          {
            "node": "Configuración",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuración": {
      "main": [
        [
          {
            "node": "Leer ventas",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Leer ventas": {
      "main": [
        [
          {
            "node": "Leer stock",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Leer stock": {
      "main": [
        [
          {
            "node": "Calcular ABC y días sin venta",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calcular ABC y días sin venta": {
      "main": [
        [
          {
            "node": "Escribir resultado",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Escribir resultado": {
      "main": [
        [
          {
            "node": "Preparar resumen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Preparar resumen": {
      "main": [
        [
          {
            "node": "¿Hay stock parado?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "¿Hay stock parado?": {
      "main": [
        [
          {
            "node": "IA explica y propone",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nada parado esta semana",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IA explica y propone": {
      "main": [
        [
          {
            "node": "Enviar informe",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Madrid"
  },
  "tags": []
}
