{
    "version": "1.38.0",
    "generatedAt": "2026-10-08T13:12:53+00:00",
    "title": "API Restaurante — Cuchara para Pajaros",
    "description": "API REST compartida por el panel de meseros, futuras apps web y móviles. JSON UTF-8.",
    "baseUrl": "https:\/\/panel.cucharaparapajaros.com\/apis\/api.php\/",
    "contentType": "application\/json; charset=utf-8",
    "auth": {
        "type": "Bearer JWT (HS256)",
        "header": "Authorization: Bearer {token}",
        "login": "POST \/login con JSON { \"usuario\", \"contrasena\" } — devuelve token y datos del usuario.",
        "expiry": "El token dura ~400 días. Renovar con POST \/auth\/refresh (Bearer). En el panel web: cookie de sesión en save_path propio + cookie HttpOnly sr_persist (JWT) que rehidrata si el archivo de sesión del SO se borra; solo se cierra con logout explícito o usuario inactivo.",
        "publicRoutes": "Las rutas bajo \/public\/ no requieren token.",
        "permissions": "Algunas rutas exigen permisos RBAC (403 si no aplica). El login incluye permisos en sesión web; en API use el rol del usuario."
    },
    "conventions": [
        {
            "label": "Métodos",
            "text": "GET para consultas, POST para crear\/actualizar\/eliminar (no hay PUT\/DELETE)."
        },
        {
            "label": "Cuerpo",
            "text": "Enviar JSON en POST salvo descarga de PDF\/XML."
        },
        {
            "label": "Respuesta",
            "text": "JSON con success, message y datos según endpoint. Errores: 400, 401, 403, 404, 409, 500."
        },
        {
            "label": "CORS",
            "text": "Access-Control-Allow-Origin: * — usable desde web\/app externa."
        },
        {
            "label": "Estados",
            "text": "Muchos recursos usan estado A (activo), I (inactivo) o X (eliminado lógico). Inactivo oculta en pedidos pero conserva relaciones (ej. complementos vinculados a productos)."
        },
        {
            "label": "Menaje",
            "text": "Inventario de vajilla\/utensilios (\/menaje\/*) es independiente de la lista de compras de insumos (\/inventario\/*). Permiso base: menaje.ver (+ menaje.crear|editar|conteo|movimientos|historial|eliminar). Conteos e ingresos\/bajas generan historial; no mezclan stock con app_inventario_productos."
        },
        {
            "label": "Recetas de inventario",
            "text": "Un insumo (app_inventario_productos) se liga a platos (app_productos) o opciones de complemento (app_complemento_opciones) vía \/inventario\/recetas*. Al cobrar ítems del pedido se aplica movimiento tipo consumo (cantidad_receta × cantidad_vendida, y lo mismo por cada complemento). Si no hay stock suficiente el cobro no falla (soft-fail). Flag idempotente app_pedido_items.stock_descontado; al revertir cobro se reponen las salidas."
        },
        {
            "label": "Proteínas",
            "text": "Procesamiento de proteínas (\/inventario\/proteinas*): INGRESO registra lotes con fundas disponibles (estado D) y suma stock en ubicaciones ambito=proteinas; EGRESO retira fundas marcadas y\/o unidades con spinner (POST \/inventario\/proteinas\/egreso). Panel meseros: pestaña Egreso por defecto. Lectura: inventario.ver. Mutaciones: inventario.editar."
        },
        {
            "label": "Totales de línea",
            "text": "total_linea = (precio_unitario + suma de complementos[].precio × (complementos[].cantidad||1)) × cantidad − descuento + tarrinas. Al crear\/actualizar, si complementos traen opcion_id el API completa precio desde el catálogo (no confía en precio 0 del cliente). Al recalcular (crear, editar, cobrar) también repara precios de complemento en 0 cuando el catálogo tiene valor > 0. En grupos multi (max>1) el panel permite cantidad por opción; la suma de cantidades debe respetar min\/max del grupo."
        },
        {
            "label": "Nómina",
            "text": "La forma de pago de la empresa (app_empresas.frecuencia_pago: semanal | quincenal | mensual) define el período de roles, cuotas de anticipos y descuentos. Semanal = lunes a domingo. Quincenal = cortes de calendario 1–15 y 16–último día (no es una ventana rodante de 15 días). Mensual = día 1 al último del mes. El horario de turnos sigue siendo semanal. Roles ya generados conservan sus fecha_inicio\/fecha_fin; no se recortan al cambiar la frecuencia. Zona horaria: America\/Guayaquil."
        },
        {
            "label": "Cuenta dividida (impresión)",
            "text": "Precuenta y recibo térmico (nota) encolan un trabajo MAXINE por dueño (corte al terminar cada PDF). La cola no dispara el siguiente hasta el ACK, para no abrir dos Bluetooth a la vez. La comanda de cocina no se parte. El cobro y los totales por cuenta no cambian."
        },
        {
            "label": "Scope de sucursal (pedidos)",
            "text": "Listar\/crear pedidos respeta X-Cod-Sucursal. Mutaciones y lecturas sensibles de un pedido validan cod_sucursal (ADMIN exento): detalle\/cobrar\/actualizar\/finalizar\/revertir-cobro\/eliminar\/programar_horario\/actualizar_cliente\/comprobante\/items-asignar\/documento-enlace\/enviar_sri, más factura-enlace, documento cuentas\/emitir\/qr\/correo, convertir_factura, anular_sri\/reenviar_sri\/enviar_correo, pickup whatsapp y datos_cliente_link. Pedidos legacy sin sucursal se permiten. create\/update no aceptan estados terminales desde el cliente; cancelado no es editable. Crear\/reasignar mesa bloquea la fila de app_mesas (FOR UPDATE) para evitar doble ocupación concurrente."
        },
        {
            "label": "Factura por cuenta",
            "text": "POST \/pedidos\/documento\/cuentas y \/emitir: nota imprime recibo\/precuenta por dueño; factura envía al SRI (una clave por dueño o una única del pedido, mutuamente excluyentes) e imprime. GET \/pedidos\/facturas lista una fila por comprobante SRI: primero por enviar\/error, luego autorizadas por secuencial descendente; query estado_sri=por_enviar|autorizada|anulada|todas. Acciones PDF\/SRI\/anular aceptan owner. La impresión térmica\/PDF de factura usa ese owner: si ya hay individuales no se genera una tira única ni un secuencial de vista previa de toda la mesa. Si ya está autorizada, el ticket no lleva “documento no autorizado”. El QR de datos acepta owner para cliente distinto por persona. No se revierte cobro si hay factura autorizada. Enviar al SRI bloquea la fila de app_facturas_sri del owner (FOR UPDATE) antes de reservar secuencial. Factura única (sin owner) exige mesa sin cuentas\/saldo pendientes. Asignar dueños (POST \/pedidos\/items\/asignar) usa TX; omite líneas ya cobradas y solo bloquea cuentas cobradas cuando el dueño de la línea realmente cambia (reenviar dueños ya pagados no falla)."
        },
        {
            "label": "Cocina \/ print WS",
            "text": "Mutaciones de kitchen (tickets\/estado, preparar-todo, items\/estado, cancelar) validan que el ticket\/pedido pertenezca a la sucursal del tablero. POST \/kitchen\/tickets\/estado usa FOR UPDATE + CAS (estado actual) y bloquea regresión delivered\/completed sin privilegio OTP\/ADMIN; expected_estado opcional → 409 si desfasado. Socket.IO kitchen exige JWT (query token) con cod_empresa coincidente. \/kitchen-ws no admite soft auth (solo token). \/print-ws legacy soft conserva job.print_token en el wake (Maxine imprime desde el evento); el claim atómico sigue en poll. Al autenticar \/print-ws se reenvía wake del próximo pendiente. ImpresoraCola wake no se silencia por otros pendientes (solo en_proceso)."
        },
        {
            "label": "Roles \/ caja",
            "text": "Solo el rol ADMIN puede tener permiso *. roles.gestionar y equipo.gestionar son ADMIN_ONLY. equipo.gestionar no puede desactivar ADMIN. negocio.ver no otorga admin de caja (operar\/borrar cualquier turno); eso requiere ADMIN\/*. Abrir turno usa GET_LOCK por sucursal. GET caja_chica\/estado y turnos_reporte exigen acceso de caja."
        }
    ],
    "groups": [
        {
            "id": "meta",
            "title": "Información general",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/",
                    "auth": "public",
                    "summary": "Health check \/ información del API",
                    "response": "{ \"success\": true, \"message\": \"API activa\" }"
                }
            ]
        },
        {
            "id": "auth",
            "title": "Autenticación y recuperación",
            "endpoints": [
                {
                    "method": "POST",
                    "path": "\/login",
                    "auth": "public",
                    "summary": "Iniciar sesión y obtener JWT",
                    "body": {
                        "usuario": "string (usuario o email)",
                        "contrasena": "string"
                    },
                    "response": "token, cod_usuario, cod_empresa, rol, permisos, nombre, email, expires…",
                    "example": "{\"usuario\":\"admin@correo.com\",\"contrasena\":\"***\"}",
                    "notes": "JWT con TTL ~400 días. En el panel web la cookie PHP también es persistente (~400 días, sliding) y solo se destruye con logout."
                },
                {
                    "method": "POST",
                    "path": "\/recuperar_clave",
                    "auth": "public",
                    "summary": "Solicitar OTP de recuperación por correo",
                    "body": {
                        "email": "string"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/forgot_password",
                    "auth": "public",
                    "summary": "POST \/forgot_password"
                },
                {
                    "method": "POST",
                    "path": "\/verify_recovery_otp",
                    "auth": "public",
                    "summary": "Validar código OTP recibido por correo",
                    "body": {
                        "email": "string",
                        "otp": "string 6 dígitos",
                        "cod_usuario": "int"
                    },
                    "notes": "Máx. 8 intentos fallidos por usuario en 15 minutos (HTTP 429)."
                },
                {
                    "method": "POST",
                    "path": "\/nueva_clave",
                    "auth": "public",
                    "summary": "Establecer nueva contraseña tras OTP válido",
                    "body": {
                        "email": "string",
                        "otp": "string",
                        "nueva_clave": "string"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/auth\/refresh",
                    "auth": "Bearer (puede estar caducado si la firma es válida y el usuario sigue activo)",
                    "summary": "Renovar JWT (token válido o caducado con firma OK)",
                    "response": "{ success, token, expires, cod_usuario, cod_empresa }",
                    "notes": "Emite un JWT nuevo. Rechaza tokens con firma inválida o usuarios inactivos."
                },
                {
                    "method": "POST",
                    "path": "\/logout",
                    "auth": "bearer",
                    "summary": "POST \/logout"
                }
            ]
        },
        {
            "id": "public",
            "title": "Endpoints públicos (sin token)",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/public\/factura_sri\/pdf",
                    "auth": "public",
                    "summary": "GET \/public\/factura_sri\/pdf"
                },
                {
                    "method": "GET",
                    "path": "\/public\/factura_sri\/xml",
                    "auth": "public",
                    "summary": "GET \/public\/factura_sri\/xml"
                },
                {
                    "method": "GET",
                    "path": "\/public\/app\/bootstrap",
                    "auth": "public",
                    "summary": "GET \/public\/app\/bootstrap"
                },
                {
                    "method": "GET",
                    "path": "\/public\/app\/menu",
                    "auth": "public",
                    "summary": "GET \/public\/app\/menu"
                },
                {
                    "method": "GET",
                    "path": "\/public\/app\/sucursales",
                    "auth": "public",
                    "summary": "GET \/public\/app\/sucursales"
                },
                {
                    "method": "GET",
                    "path": "\/public\/app\/producto",
                    "auth": "public",
                    "summary": "GET \/public\/app\/producto"
                },
                {
                    "method": "POST",
                    "path": "\/public\/app\/mesa\/resolver",
                    "auth": "public",
                    "summary": "POST \/public\/app\/mesa\/resolver"
                },
                {
                    "method": "GET",
                    "path": "\/public\/app\/mesas",
                    "auth": "public",
                    "summary": "GET \/public\/app\/mesas"
                },
                {
                    "method": "POST",
                    "path": "\/public\/app\/pedido",
                    "auth": "public",
                    "summary": "POST \/public\/app\/pedido"
                },
                {
                    "method": "GET",
                    "path": "\/public\/web\/info",
                    "auth": "public",
                    "summary": "GET \/public\/web\/info"
                },
                {
                    "method": "POST",
                    "path": "\/public\/web\/contacto",
                    "auth": "public",
                    "summary": "POST \/public\/web\/contacto"
                },
                {
                    "method": "POST",
                    "path": "\/public\/web\/suscribir",
                    "auth": "public",
                    "summary": "POST \/public\/web\/suscribir"
                },
                {
                    "method": "POST",
                    "path": "\/public\/web\/reserva",
                    "auth": "public",
                    "summary": "POST \/public\/web\/reserva"
                },
                {
                    "method": "GET",
                    "path": "\/public\/pedido_factura",
                    "auth": "public",
                    "summary": "Contexto QR precuenta (datos pedido + cliente vinculado + reseña)",
                    "query": {
                        "id": "cod_pedido",
                        "token": "token público del QR",
                        "owner": "cuenta dividida (opcional)"
                    },
                    "response": "pedido, empresa, cliente_vinculado (de esa cuenta si hay owner; no el cliente de otra persona de la mesa), resena, puede_editar, bloqueo_motivo",
                    "notes": "puede_editar=false si cancelado, día de negocio distinto a hoy (America\/Guayaquil) o factura SRI ya autorizada. Pedidos aún en servicio (nuevo\/barra\/cocina\/programado) o pagado+pedido_abierto siempre pueden editar. Finalizada del mismo día de cobro\/cierre sí; otra fecha no."
                },
                {
                    "method": "POST",
                    "path": "\/public\/pedido_factura\/buscar",
                    "auth": "public",
                    "summary": "Buscar cliente por documento (landing QR)",
                    "body": [
                        "id",
                        "token",
                        "tipo_identificacion",
                        "numero_identificacion"
                    ],
                    "notes": "Misma validación de puede_editar que GET\/guardar. La búsqueda de cliente replica GET \/clientes\/buscar (cédula↔RUC flexible); no existe → found:false (no 404)."
                },
                {
                    "method": "POST",
                    "path": "\/public\/pedido_factura\/guardar",
                    "auth": "public",
                    "summary": "Guardar datos factura + calificación cliente",
                    "body": "id, token, owner, datos cliente, puntuacion (1-5), comentario",
                    "notes": "Vincula el cliente a la cuenta (owner) en pago_detalle.cuentas_documento.owners. Si owner está vacío, también actualiza cod_cliente del pedido. Mismo día y sin SRI autorizada de esa cuenta."
                }
            ]
        },
        {
            "id": "perfil",
            "title": "Perfil de usuario",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/perfil",
                    "auth": "bearer",
                    "summary": "Perfil del usuario autenticado",
                    "permission": "Sesión activa",
                    "response": "Datos de usuario + valor_tarrina\/valor_vaso de la empresa (solo lectura operativa). No incluye SMTP ni edición de empresa.",
                    "notes": "SMTP, tarrina editable y demás config de empresa solo en GET\/POST \/empresa."
                },
                {
                    "method": "POST",
                    "path": "\/perfil\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualiza el perfil del usuario autenticado",
                    "permission": "Sesión activa",
                    "body": {
                        "usuario": "string",
                        "nombres": "string",
                        "apellidos": "string",
                        "email": "string",
                        "telefono": "string",
                        "clave_actual\/clave_nueva\/clave_confirmar": "opcionales para cambiar contraseña"
                    },
                    "notes": "No acepta smtp_* ni valor_tarrina\/valor_vaso: esos campos se gestionan en POST \/empresa\/actualizar (pantalla Empresa). Si se envían, responde 400."
                }
            ]
        },
        {
            "id": "mesas",
            "title": "Mesas y plano",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/mesas",
                    "auth": "bearer+permiso",
                    "summary": "Listar mesas"
                },
                {
                    "method": "POST",
                    "path": "\/mesas\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en mesas"
                },
                {
                    "method": "POST",
                    "path": "\/mesas\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar mesas"
                },
                {
                    "method": "POST",
                    "path": "\/mesas\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar mesas"
                },
                {
                    "method": "POST",
                    "path": "\/mesas\/toggle_activo",
                    "auth": "bearer+permiso",
                    "summary": "POST \/mesas\/toggle_activo"
                },
                {
                    "method": "POST",
                    "path": "\/mesas\/guardar_layout",
                    "auth": "bearer+permiso",
                    "summary": "POST \/mesas\/guardar_layout"
                },
                {
                    "method": "GET",
                    "path": "\/mesas\/plano_elementos",
                    "auth": "bearer",
                    "summary": "GET \/mesas\/plano_elementos"
                }
            ]
        },
        {
            "id": "zonas",
            "title": "Zonas del local",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/zonas",
                    "auth": "bearer",
                    "summary": "Listar zonas"
                },
                {
                    "method": "POST",
                    "path": "\/zonas\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en zonas"
                },
                {
                    "method": "POST",
                    "path": "\/zonas\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar zonas"
                },
                {
                    "method": "POST",
                    "path": "\/zonas\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar zonas"
                }
            ]
        },
        {
            "id": "reservas",
            "title": "Reservas",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/reservas",
                    "auth": "bearer",
                    "summary": "Listar reservas"
                },
                {
                    "method": "POST",
                    "path": "\/reservas\/crear",
                    "auth": "bearer",
                    "summary": "Crear registro en reservas"
                },
                {
                    "method": "POST",
                    "path": "\/reservas\/reenviar_correo",
                    "auth": "bearer",
                    "summary": "POST \/reservas\/reenviar_correo"
                },
                {
                    "method": "POST",
                    "path": "\/reservas\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar reservas"
                },
                {
                    "method": "POST",
                    "path": "\/reservas\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar reservas"
                }
            ]
        },
        {
            "id": "categorias",
            "title": "Categorías de menú",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/categorias",
                    "auth": "bearer",
                    "summary": "Listar categorias"
                },
                {
                    "method": "POST",
                    "path": "\/categorias\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en categorias"
                },
                {
                    "method": "POST",
                    "path": "\/categorias\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar categorias"
                },
                {
                    "method": "POST",
                    "path": "\/categorias\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar categorias"
                }
            ]
        },
        {
            "id": "productos",
            "title": "Productos",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/productos",
                    "auth": "bearer+permiso",
                    "summary": "Catálogo de productos activos",
                    "query": {
                        "q": "búsqueda opcional",
                        "categoria": "id categoría"
                    },
                    "permission": "productos.ver",
                    "response": "Incluye modifier_groups (solo grupos\/opciones activos) y complementos_grupos (todos los vínculos del producto)."
                },
                {
                    "method": "GET",
                    "path": "\/productos\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "Detalle de productos"
                },
                {
                    "method": "POST",
                    "path": "\/productos\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en productos"
                },
                {
                    "method": "POST",
                    "path": "\/productos\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar productos"
                },
                {
                    "method": "POST",
                    "path": "\/productos\/divisiones\/guardar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/productos\/divisiones\/guardar"
                },
                {
                    "method": "GET",
                    "path": "\/productos\/receta",
                    "auth": "bearer+permiso",
                    "summary": "Pasos de la receta de un producto (editor del catálogo)",
                    "permission": "productos.ver",
                    "query": {
                        "cod_producto": "int (requerido)"
                    },
                    "response": "pasos[] { id, orden, nombre, descripcion, tiempo_minutos (int|null) } ordenados por orden"
                },
                {
                    "method": "POST",
                    "path": "\/productos\/receta\/guardar",
                    "auth": "bearer+permiso",
                    "summary": "Guarda la receta completa de un producto (reemplaza todos los pasos)",
                    "permission": "productos.editar",
                    "body": {
                        "cod_producto": "int",
                        "pasos": "[{ nombre (requerido, máx. 160), descripcion (opcional, máx. 4000), tiempo_minutos (0–1440 o null) }]"
                    },
                    "response": "message, pasos[] guardados",
                    "notes": "Operación transaccional: el orden del arreglo define PASO 1, 2, …; para editar o eliminar pasos se envía la lista final. pasos=[] elimina la receta. Máximo 60 pasos. 400 si falta el nombre de un paso o el producto no es de la empresa."
                },
                {
                    "method": "POST",
                    "path": "\/productos\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar productos"
                }
            ]
        },
        {
            "id": "divisiones",
            "title": "Divisiones de producto",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/divisiones",
                    "auth": "bearer",
                    "summary": "Listar divisiones"
                },
                {
                    "method": "POST",
                    "path": "\/divisiones\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en divisiones"
                },
                {
                    "method": "POST",
                    "path": "\/divisiones\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar divisiones"
                },
                {
                    "method": "POST",
                    "path": "\/divisiones\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar divisiones"
                }
            ]
        },
        {
            "id": "complementos",
            "title": "Complementos y opciones",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/complementos",
                    "auth": "bearer",
                    "summary": "Listar grupos de complementos de la empresa",
                    "query": {
                        "con_opciones": "1 para incluir opciones de cada grupo",
                        "todos": "1 para incluir grupos inactivos (I); por defecto solo excluye eliminados (X)"
                    },
                    "response": "complementos[] con id, nombre, maximo, minimo, descripcion, area, prep_bundle, mostrar_producto_estacion, estado (A|I|X), estado_label, opciones_count y opciones[] si aplica."
                },
                {
                    "method": "GET",
                    "path": "\/complementos\/detalle",
                    "auth": "bearer",
                    "summary": "Detalle de un grupo de complementos",
                    "query": {
                        "id": "id del grupo"
                    },
                    "response": "complemento con opciones[] (incluye inactivas I; excluye eliminadas X), productos[] vinculados {id, cod_producto, nombre, estado, estado_label} y productos_count."
                },
                {
                    "method": "POST",
                    "path": "\/complementos\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear grupo de complementos",
                    "body": {
                        "nombre": "string obligatorio",
                        "maximo": "int (default 1)",
                        "minimo": "int (default 0)",
                        "descripcion": "string opcional",
                        "area": "string slugs separados por coma",
                        "prep_bundle": "string opcional — fusiona líneas en cocina",
                        "mostrar_producto_estacion": "0|1",
                        "estado": "A|I opcional (default A)"
                    },
                    "response": "complemento creado"
                },
                {
                    "method": "POST",
                    "path": "\/complementos\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar grupo de complementos",
                    "body": {
                        "id": "id del grupo",
                        "nombre, maximo, minimo, descripcion, area, prep_bundle, mostrar_producto_estacion": "opcionales",
                        "estado": "A activo (visible en pedidos) | I inactivo (oculto en pedidos, conserva vínculos con productos)"
                    },
                    "response": "complemento actualizado"
                },
                {
                    "method": "POST",
                    "path": "\/complementos\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar grupo de complementos (lógico, estado X)",
                    "body": {
                        "id": "id del grupo"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/complementos\/opciones\/crear",
                    "auth": "bearer",
                    "summary": "Agregar opción a un grupo",
                    "body": {
                        "cod_grupo": "int obligatorio",
                        "nombre": "string obligatorio",
                        "precio": "float (default 0)",
                        "area": "slug de estación opcional; vacío = hereda el área del grupo",
                        "estado": "A|I opcional (default A)"
                    },
                    "response": "opcion creada"
                },
                {
                    "method": "POST",
                    "path": "\/complementos\/opciones\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar opción de complemento",
                    "body": {
                        "id": "id de la opción",
                        "nombre, precio": "opcionales",
                        "area": "slug de estación; vacío o \"\" limpia el override y hereda el área del grupo",
                        "estado": "A activo (visible en pedidos) | I inactivo"
                    },
                    "notes": "Si area está vacío, en pedidos\/cocina la opción usa el área del grupo (modifier_groups y resolveKitchenAreaSlugsForItem). Un área propia reemplaza la del grupo solo para esa opción; no mueve tickets de pedidos ya enviados (snapshot en complementos_json).",
                    "response": "opcion actualizada"
                },
                {
                    "method": "POST",
                    "path": "\/complementos\/opciones\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar opción (lógico, estado X)",
                    "body": {
                        "id": "id de la opción"
                    }
                }
            ]
        },
        {
            "id": "kitchen",
            "title": "Cocina (KDS)",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/kitchen\/areas",
                    "auth": "bearer+permiso",
                    "summary": "GET \/kitchen\/areas"
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/areas\/crear",
                    "auth": "bearer+permiso",
                    "summary": "POST \/kitchen\/areas\/crear"
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/areas\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/kitchen\/areas\/actualizar"
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/areas\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/kitchen\/areas\/eliminar"
                },
                {
                    "method": "GET",
                    "path": "\/kitchen\/tickets",
                    "auth": "bearer",
                    "summary": "Listar tickets del tablero de cocina (día actual)",
                    "permission": "JWT mesero\/admin o token de pantalla",
                    "query": {
                        "estado": "pending|in_progress|ready|all (default activos)",
                        "area": "slug opcional",
                        "cod_sucursal": "sucursal del tablero"
                    },
                    "response": "tickets[] con item_payload_json, item_dueno, mesa_zona (slug) y mesa_zona_label (nombre visible de app_zonas_restaurante)",
                    "notes": "item_payload_json se enriquece al responder: tiene_receta (bool, el producto tiene pasos en GET \/kitchen\/productos\/receta), product_prep_areas, para_llevar\/tarrinas y dueno desde la línea viva de app_pedido_items (evita tablero desfasado si se asignó dueño o se marcó para llevar después del envío a cocina). item_dueno también se alinea con la línea viva. mesa_zona_label es el nombre configurado de la zona (ej. Afuera); mesa_zona sigue siendo el slug (ej. terraza). estado=all: incluye delivered\/completed de pedidos no finalizados\/cancelados (vista «completados»); si estado_pedido=pagado solo si pedido_abierto (cobrado pendiente de FINALIZAR). Cancelar un ítem no oculta el resto delivered del pedido. Al FINALIZAR dejan de listarse."
                },
                {
                    "method": "GET",
                    "path": "\/kitchen\/productos\/receta",
                    "auth": "bearer",
                    "summary": "Receta (pasos) de un producto para el tablero de comandas",
                    "permission": "JWT mesero\/admin con acceso al tablero o token de pantalla",
                    "query": {
                        "cod_producto": "int (requerido)"
                    },
                    "response": "producto { id, nombre }, pasos[] { id, orden, nombre, descripcion, tiempo_minutos (int|null) }",
                    "notes": "Solo lectura. Usar payload.tiene_receta de GET \/kitchen\/tickets para decidir si mostrar «Ver receta». Incluye productos eliminados para tickets antiguos del día."
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/tickets\/estado",
                    "auth": "bearer",
                    "summary": "Cambiar el estado de un ticket de cocina por id",
                    "permission": "comandas.ver \/ pedidos.ver \/ estaciones",
                    "body": {
                        "id": "int ticket",
                        "estado": "pending|in_progress|ready|completed|delivered",
                        "expected_estado": "opcional; si no coincide con BD → 409",
                        "station_line_key": "opcional",
                        "station_line_ready": "bool opcional"
                    },
                    "notes": "No usa cancelled aquí (usar \/kitchen\/tickets\/cancelar). ADMIN o código OTP pueden devolver un ticket cancelled al estado pedido. delivered\/completed no regresan sin privilegio."
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/pedidos\/preparar-todo",
                    "auth": "bearer",
                    "summary": "Marcar todos los tickets pending de un pedido como in_progress (atómico)",
                    "permission": "comandas.ver \/ pedidos.ver \/ estaciones",
                    "body": {
                        "cod_pedido": "int (id del pedido)"
                    },
                    "response": "data.updated_tickets, data.ticket_ids[]",
                    "notes": "Usado por el botón «Preparar todo» del tablero. Actualiza todos los tickets pending del pedido en una sola operación y alinea estado_cocina de las líneas. Evita la carrera de N POSTs por ítem."
                },
                {
                    "method": "GET",
                    "path": "\/kitchen\/motivos-cancelacion",
                    "auth": "bearer",
                    "summary": "GET \/kitchen\/motivos-cancelacion"
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/items\/estado",
                    "auth": "bearer",
                    "summary": "Cambiar el estado de cocina de una unidad de producto (por item_key)",
                    "permission": "comandas.ver \/ pedidos.ver \/ estaciones",
                    "body": {
                        "cod_pedido": "int",
                        "item_key": "clave de la unidad (kitchen_item_key)",
                        "estado": "pending|in_progress|ready|completed|delivered|cancelled"
                    },
                    "response": "data.updated_tickets, data.estado, data.pedido_totals, data.item_line_total, data.item_unit_price",
                    "notes": "El estado se aplica SOLO a los tickets con exactamente esa item_key (una unidad). Entregar\/completar un jugo u otro producto NUNCA actualiza tickets de otros ítems del mismo pedido; el ticket solo desaparece del tablero cuando TODAS las unidades activas están delivered. En líneas con cantidad > 1 cada unidad tiene su propia clave y su estado es independiente. El estado_cocina de la línea se agrega con weakest-link (el más atrasado de sus unidades). Para cancelled: use motivo (cod_motivo); cancela solo esa unidad, baja cantidad en 1, descuenta el empaque (tarrinas_qty) de esa unidad, recalcula total_linea y reasigna tickets activos a claves canónicas u:1..N (nunca cancela hermanas ni reactiva el ticket cancelado). Devolver un ítem cancelado (pending\/in_progress\/…) solo ADMIN o mesero con código OTP de acceso vigente: reactiva la línea o la unidad huérfana, restaura total\/empaque desde el ticket y reaparece en cocina. Sin privilegio, un ítem cancelado no se puede cambiar."
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/tickets\/cancelar",
                    "auth": "bearer",
                    "summary": "Cancelar ticket(s) de cocina con motivo",
                    "permission": "comandas.ver \/ pedidos.ver \/ estaciones",
                    "body": {
                        "ticket_ids": "int[] (opcional si se envía cod_pedido + item_key)",
                        "cod_pedido": "int",
                        "item_key": "clave de la unidad a cancelar",
                        "cod_motivo": "int requerido",
                        "motivo_otro": "string si el motivo requiere detalle"
                    },
                    "response": "data.cancelled_tickets, data.pedidos_actualizados, data.pedido_totals",
                    "notes": "Cancela únicamente los tickets de la unidad indicada (ticket_ids y\/o item_key). En líneas con cantidad > 1: reduce cantidad en 1, resta tarrinas_qty de esa unidad (payload por-unidad o reparto; payloads legacy con el total de línea no vacían todo el embalaje), recalcula total_linea, reasigna tickets activos a u:1..newQty. Transacción atómica con recalc de totales\/cupón. No se puede cancelar delivered salvo ADMIN o mesero con código OTP de acceso vigente. El UPDATE del ticket es idempotente (estado <> cancelled; sin privilegio también excluye delivered). Si se envían ticket_ids, no se reintenta por item_key (evita cancelar el survivor tras remap). Evita empaque huérfano."
                },
                {
                    "method": "POST",
                    "path": "\/kitchen\/tickets\/vaciar-cancelados",
                    "auth": "bearer",
                    "summary": "POST \/kitchen\/tickets\/vaciar-cancelados"
                }
            ]
        },
        {
            "id": "clientes",
            "title": "Clientes",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/clientes",
                    "auth": "bearer",
                    "summary": "Listar clientes"
                },
                {
                    "method": "GET",
                    "path": "\/clientes\/buscar",
                    "auth": "bearer",
                    "summary": "Buscar cliente por cédula (10 dígitos) o RUC (13)",
                    "query": {
                        "numero": "cédula o RUC (también acepta q, cedula, ruc)"
                    },
                    "response": "{ success, found, incomplete, cliente?, message }",
                    "notes": "Si el documento está incompleto, found=false e incomplete=true. No crea clientes. El RUC también intenta cédula (10) y la cédula intenta RUC+001."
                },
                {
                    "method": "GET",
                    "path": "\/clientes\/detalle",
                    "auth": "bearer",
                    "summary": "Detalle de clientes"
                },
                {
                    "method": "POST",
                    "path": "\/clientes\/crear",
                    "auth": "bearer",
                    "summary": "Crear registro en clientes"
                },
                {
                    "method": "POST",
                    "path": "\/clientes\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar clientes"
                },
                {
                    "method": "POST",
                    "path": "\/clientes\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar clientes"
                }
            ]
        },
        {
            "id": "pedidos",
            "title": "Pedidos, cobros y facturación",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/pedidos",
                    "auth": "bearer",
                    "summary": "Listado de pedidos del día \/ filtros",
                    "query": {
                        "fecha": "YYYY-MM-DD (por defecto hoy en finalizada\/cancelado)",
                        "hora_desde": "HH:MM opcional (Finalizados: hora del cobro\/cierre)",
                        "hora_hasta": "HH:MM opcional (Finalizados: hora del cobro\/cierre)",
                        "estado": "servicio | programado | finalizada | cancelado (solo ADMIN) | …",
                        "page": "paginación (finalizada y cancelado)"
                    },
                    "response": "Pedidos con estado_pedido, saldo_pendiente, pedido_abierto. En finalizada\/cancelado: pagination + resumen_facturado {total, efectivo, transferencia, otros, pedidos, label}.",
                    "notes": "hora_desde\/hora_hasta filtran por TIME de la fecha de lista (cobrado_en \/ finalizado_en \/ creación). Tarjeta cuenta en transferencia. resumen_facturado agrega todos los pedidos del filtro (no solo la página)."
                },
                {
                    "method": "GET",
                    "path": "\/pedidos\/facturas",
                    "auth": "bearer+permiso",
                    "summary": "Listado paginado de facturas electrónicas",
                    "permission": "negocio.ver",
                    "query": {
                        "buscar": "texto: pedido, mesa, cliente, secuencial SRI, dueño",
                        "page": "página",
                        "per_page": "1–100 (default 20)",
                        "pedido_id": "fuerza incluir ese pedido",
                        "estado_sri": "por_enviar (pendiente\/error\/recibida) | autorizada | anulada | todas"
                    },
                    "response": "facturas[]: una fila por comprobante SRI (pedidos con varias cuentas = varias filas). sri.owner_key, factura_owner, total y cliente de ESA factura. paginacion.",
                    "notes": "Requiere negocio.ver. No agrupa por pedido. Orden: primero por enviar\/error (pedido reciente), luego autorizadas\/anuladas por secuencial descendente. El panel meseros abre por defecto en por_enviar; si hay buscar=, el panel consulta todas (el filtro de pestaña no oculta coincidencias). pedido_id fuerza incluir ese pedido aunque el filtro estado_sri lo excluya. GET \/pedidos\/facturas\/sri, PDF y anular aceptan owner para elegir la factura de la cuenta."
                },
                {
                    "method": "GET",
                    "path": "\/pedidos\/facturas\/sri",
                    "auth": "bearer",
                    "summary": "Estado SRI de una factura del pedido",
                    "query": {
                        "id": "cod_pedido",
                        "owner": "cuenta; vacío = factura única"
                    },
                    "notes": "Valida acceso por sucursal del pedido (mismo criterio que PDFs meseros): sin permiso de empresa\/sucursales cruzadas → 403."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/facturas\/enviar_sri",
                    "auth": "bearer",
                    "summary": "Genera y envía factura electrónica al SRI",
                    "body": {
                        "id": "cod_pedido",
                        "owner": "cuenta dividida (opcional); vacío = factura única del pedido"
                    },
                    "notes": "Requiere estado_pedido pagado o finalizada. Con owner, exige saldo pendiente ≈ 0 de esa cuenta. Sin owner (factura única), exige que no queden cuentas divididas pendientes ni saldo de mesa. fecha_emision usa cobrado_en \/ finalizado_en \/ historial, no updated_at."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/facturas\/descartar_pendiente",
                    "auth": "bearer",
                    "summary": "Elimina factura pendiente de consumidor final (sin enviar al SRI)",
                    "body": {
                        "id": "cod_pedido",
                        "owner": "cuenta opcional"
                    },
                    "notes": "Solo CONSUMIDOR FINAL y estados no autorizados\/anulados. Borra fila SRI pendiente y restaura doc_tipo=nota si no quedan FE activas."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/facturas\/anular_sri",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/facturas\/anular_sri"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/facturas\/reenviar_sri",
                    "auth": "bearer",
                    "summary": "Reenvía \/ regenera comprobante SRI del pedido (o cuenta)",
                    "body": {
                        "id": "cod_pedido",
                        "owner": "cuenta (opcional)"
                    },
                    "notes": "Mismas reglas de cobro que enviar_sri."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/facturas\/enviar_correo",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/facturas\/enviar_correo"
                },
                {
                    "method": "GET",
                    "path": "\/pedidos\/detalle",
                    "auth": "bearer",
                    "summary": "Detalle de pedidos"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/crear",
                    "auth": "bearer",
                    "summary": "Crear pedido (mesa, delivery, pickup)",
                    "body": "tipo_pedido, estado_pedido (cocina|programado), programado_listo_en + programado_anticipacion_min si programado, cod_mesa, items[] (precio_unitario base + complementos[] con opcion_id\/precio + tarrinas_*), cliente…",
                    "notes": "Tras insertar, recalcula total_linea\/subtotal igual que actualizar. Corre en transacción (incl. asignación de numero_pedido): si falla el sync de ítems\/cocina, no deja pedido a medias. Si items[] trae solo cod_producto (sin nombre\/precio), se completa desde el catálogo activo; sin nombre resoluble se rechaza (no crea pedido vacío). Los precios de complementos con opcion_id se toman del catálogo si el cliente envía 0 o incompleto. estado_pedido=programado no crea tickets de cocina hasta programado_enviar_en (listo − anticipación). Al activar, enviado_en de los tickets usa programado_activado_en (no fecha_creacion del pedido), para que el cronómetro del tablero empiece en cero al entrar a cocina. Reprogramar un pedido ya activado (sin avance de cocina) limpia programado_activado_en y cancela tickets del tablero; si cocina ya avanzó, se rechaza. tipo_pedido=table: valida que la mesa exista, esté activa y pertenezca a la sucursal; rechaza si ya tiene otro pedido activo en servicio."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/eliminar",
                    "auth": "bearer",
                    "summary": "Cancelar pedido en servicio o eliminar pedido finalizado",
                    "permission": "Solo rol ADMIN",
                    "body": {
                        "id": "cod_pedido",
                        "cod_motivo": "requerido al cancelar pedido en servicio",
                        "motivo_otro": "opcional según motivo"
                    },
                    "response": "pedido cancelado o eliminado según estado",
                    "notes": "Valida acceso a la sucursal del pedido (anti-IDOR; ADMIN exento). Pedidos en servicio pasan a estado_pedido cancelado (también limpia cod_cupon\/descuento). Pedidos finalizados\/pagados se eliminan del listado (solo ADMIN) y liberan el uso de cupón. Al cancelar o eliminar, el historial de usos queda Liberado y baja usos_actuales."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/programar_horario",
                    "auth": "bearer",
                    "summary": "Actualizar fecha\/hora de un pedido programado pendiente",
                    "body": {
                        "id": "cod_pedido",
                        "programado_listo_en": "YYYY-MM-DD HH:MM:SS cuando debe estar listo",
                        "programado_anticipacion_min": "minutos antes para enviar a cocina\/tablero"
                    },
                    "notes": "Si la anticipación deja el envío en el pasado, activa cocina de inmediato."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/actualizar",
                    "auth": "bearer",
                    "summary": "Editar pedido existente (items, mesa, cliente, notas)",
                    "body": {
                        "id": "cod_pedido",
                        "items": "array completo de líneas: cada línea vigente con su id; {id, eliminar: 1} para borrar; sin id = línea nueva. Una línea de cantidad > 1 puede llegar como varias unidades con el MISMO id (cantidad 1 c\/u): el API las re-agrupa sumando cantidades, no se colapsa a 1.",
                        "tipo_pedido \/ cod_mesa \/ cliente…": "opcionales, se conserva el valor actual si no se envían",
                        "estado_pedido \/ programado_*": "programado programa envío a cocina; cocina activa de inmediato un pedido programado pendiente"
                    },
                    "response": "pedido actualizado con items y kitchen_sync {created, cancelled, updated_notes}",
                    "notes": "Valida acceso a la sucursal del pedido (anti-IDOR). Toda línea vigente debe venir en items (con eliminar: 1 si se borra), si falta alguna se rechaza. GET \/pedidos expone las líneas con cantidad > 1 expandidas en unidades (una por ticket de cocina) que comparten el id de la línea; al guardar, esas unidades se re-agrupan por id (se suman cantidades\/tarrinas) para no perder unidades. Meseros: las líneas ya enviadas a cocina o cobradas no se pueden reescribir ni eliminar (solo nota de dueño y tarrinas\/para llevar). Un cambio solo de para llevar \/ tarrinas no corre syncKitchenTickets completo (evita cancelar tickets de qty≥2); solo parchea item_payload_json de tickets existentes. GET \/kitchen\/tickets también enriquece para_llevar desde la línea viva. ADMIN\/OTP: puede reescribir (cantidad, producto, complementos) y eliminar líneas ya enviadas a cocina — los tickets se resincronizan cancelando huérfanos y creando las unidades nuevas, conservando el estado de cocina de la línea; si un complemento\/área sin ticket se detecta tras kitchen_sent=1, se crea o reactiva: si el área ya estaba implícita en payloads hermanos (reparación) hereda delivered\/completed\/ready según corresponda; si el área es nueva (complemento recién añadido) nace pending para que cocina lo prepare. En líneas qty>1 no se reutiliza el ticket de otra unidad. Las líneas cobradas (linea_cobrada=1) no se pueden eliminar ni reescribir hasta revertir el cobro (ni con ADMIN). Pedidos finalizados no se editan (ni con OTP). Precios de línea: ver convención Totales de línea (complementos por opcion_id desde catálogo). Pedidos programados pendientes no sincronizan cocina hasta la hora o envío manual. La actualización corre en transacción: si falla un eliminar\/protegido, no deja total corrupto. cod_cupon=null o 0 quita el cupón; si se omite la clave, se conserva el cupón actual. Cambiar cod_mesa a una mesa con otro pedido activo se rechaza. Serializa con FOR UPDATE y no permite revertir pagado→cocina por carrera con cobrar."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/actualizar_cliente",
                    "auth": "bearer",
                    "summary": "Asignar cliente a un pedido (nombre y\/o cod_cliente)",
                    "body": {
                        "id": "cod_pedido",
                        "cod_cliente": "id del cliente o null",
                        "cliente_nombre": "string requerido",
                        "vincular_existente": "1: permite pagado\/finalizada del día (mismo criterio que el QR de datos)"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/comprobante",
                    "auth": "bearer",
                    "summary": "Guardar preferencia nota\/factura del pedido (precuenta o cobro)",
                    "body": {
                        "id": "cod_pedido",
                        "doc_tipo": "nota|factura"
                    },
                    "permission": "pedidos.ver|pedidos.crear|pedidos.cobrar|comandas.ver|estaciones.ver (o rol COCINA\/MESERO\/CAJERO)",
                    "notes": "Usado al imprimir precuenta desde pedidos o diseño-comandas. Cocina\/estaciones pueden guardar la preferencia para no bloquear la cola de impresión; el job de impresión también lleva doc_tipo aunque el save falle."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/items\/asignar",
                    "auth": "bearer",
                    "summary": "Asignar dueño (cuenta dividida) a líneas del pedido",
                    "permission": "Rol\/permiso de cobro",
                    "body": {
                        "id": "cod_pedido",
                        "items": "[{ id, dueno, unit_index? }]"
                    },
                    "response": "pedido actualizado",
                    "notes": "Omite líneas con linea_cobrada=1. Solo rechaza dueño ya cobrado (historial\/cuentas_divididas_pagos o líneas cobradas) cuando el dueño de esa línea realmente cambia; reenviar asignaciones existentes de una cuenta ya pagada no falla. Assert freebie ocurre antes de explode de unidades."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/documento\/enlace",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/documento\/enlace"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/factura\/enlace",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/factura\/enlace"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/pickup\/whatsapp",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/pickup\/whatsapp"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/datos_cliente_link",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/datos_cliente_link"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/documento\/cuentas",
                    "auth": "bearer",
                    "summary": "Estado de comprobantes por dueño (modal nota\/factura)",
                    "body": {
                        "id": "cod_pedido"
                    },
                    "response": "cuentas[], modo_sri unica|individual|null, bloquea_individual, bloquea_unica"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/documento\/emitir",
                    "auth": "bearer",
                    "summary": "Imprime recibo o emite factura SRI por cuenta \/ única",
                    "body": {
                        "id": "cod_pedido",
                        "doc_tipo": "nota|factura",
                        "modo": "individual|unica",
                        "owner": "nombre de la cuenta; vacío si unica"
                    },
                    "notes": "Factura: datos de cliente, envía SRI, imprime la tira de ESA cuenta. Si ya hay facturas individuales no se permite factura única (ni imprimir el total de la mesa). Si ya autorizada, imprime el comprobante autorizado (sin vista previa). Individual y única son excluyentes."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/documento\/qr_cuenta",
                    "auth": "bearer",
                    "summary": "QR de datos de facturación para un dueño",
                    "body": {
                        "id": "cod_pedido",
                        "owner": "string"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/documento\/enviar_correo",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/documento\/enviar_correo"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/cobrar",
                    "auth": "bearer",
                    "summary": "Registrar cobro del pedido (no cierra el pedido)",
                    "permission": "Rol\/permiso de cobro",
                    "body": {
                        "id": "cod_pedido",
                        "formas_pago": "array según modal de cobro",
                        "pago_detalle.cuenta_dividida": "nombre de la persona (cuenta dividida)",
                        "pago_detalle.tarjeta": "monto de la cuenta pagado con tarjeta de crédito (SIN comisión)",
                        "documento": "factura\/nota según configuración"
                    },
                    "response": "pedido con estado_pedido pagado, pedido_abierto: true, cobro_parcial si aplica. Marca linea_cobrada en ítems cobrados. Guarda cod_cajero (usuario autenticado que cobró). La mesa no se libera hasta finalizar.",
                    "notes": "Valida acceso a la sucursal del pedido. El mesero del pedido sigue siendo cod_usuario (quien creó\/tomó el pedido); el cajero es el usuario de la sesión al cobrar (puede ser la misma persona). Se persiste en app_pedidos.cod_cajero y en pago_detalle_json (raíz + cada entrada de cuentas_divididas_pagos: cod_cajero, cajero_nombre). Pedidos antiguos sin cajero: en UI Finalizados se muestra el mesero como cajero. Cuenta dividida: debe cobrar el saldo exacto de esa persona (sin abonos parciales por cuenta). Cada cobro se guarda en pago_detalle_json.cuentas_divididas_pagos (efectivo\/transferencia de ESA persona); las raíces del JSON son la suma del historial, no el último método. El saldo se calcula desde la suma de ítems pendientes de esa persona más su proporción de delivery\/servicio (misma base que el modal), no desde un subtotal de encabezado desfasado tras cancelaciones. Tras cobros parciales, el modal ya no pone delivery=0 en las cuentas pendientes. Si solo queda una cuenta pendiente y no se envía cuenta_dividida, el API la resuelve sola. metodo_pago en app_pedidos es un resumen (VARCHAR ampliado \/ recorte); el historial completo está en pago_detalle_json. IVA incluido \/ sin servicio-delivery: saldo = subtotal pendiente de la persona. Serializa el cobro con bloqueo de fila del pedido y rechaza si el estado pasó a finalizada\/cancelado (evita reabrir un pedido ya cerrado en carrera con finalizar). Multipago efectivo+transferencia: no inventar transferencia por el total si ya hay efectivo; si la suma supera el total se recortan ambos medios en proporción (no borrar el banco para dejar solo efectivo). El label mixto no se reescribe como «todo Transferencia». En el modal, «Pagar ahora» solo autocompleta el método visible (Transferencia no vuelca el teclado de efectivo) y total_cobrado sale solo del desglose asignado (nunca rellena el total del pedido si no hay montos). Abono parcial (sin cuenta dividida): no marca linea_cobrada; responde cobro_parcial=true y deja saldo pendiente. Si el desglose (efectivo\/tarjeta\/transfer) difiere de total_cobrado, manda el desglose. Pedido pagado+pedido_abierto admite agregar productos; al hacerlo se quita cuenta_dividida_completa si quedan líneas sin cobrar. Factura + consumidor final: el mínimo de $50 se valida por cuenta (saldo de esa persona), no por el total de la mesa; así se pueden cobrar todas las cuentas chicas de un pedido grande sin registrar cliente en el encabezado. Si linea_cobrada se desincroniza del historial, el cobro remarca las líneas de dueños ya pagados y no permite cobrar de nuevo la misma persona. El modal carga pago_detalle (historial) al abrir\/detalle para marcar PAGADO aunque falte linea_cobrada en el DOM. Revertir cobro limpia cod_cajero. Tarjeta de crédito: solo se ofrece en el modal si GET \/empresa → tarjeta_credito.activo. La comisión bancaria la asume el cliente y NO forma parte del total del pedido, de la factura\/nota ni de total_cobrado\/saldo: el API ignora cualquier comisión enviada y la calcula con la config vigente de la empresa; en cada entrada de cuentas_divididas_pagos guarda tarjeta_comision_pct, tarjeta_proveedor, tarjeta_comision (= tarjeta × pct \/ 100, redondeado a 2 decimales) y tarjeta_total_cliente (tarjeta + comisión = monto a pasar en el datáfono); la raíz de pago_detalle lleva tarjeta_comision acumulada. metodo_pago usa la etiqueta «Tarjeta de crédito $X»."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/convertir_factura\/preview",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/convertir_factura\/preview"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/convertir_factura",
                    "auth": "bearer",
                    "summary": "POST \/pedidos\/convertir_factura"
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/revertir-cobro",
                    "auth": "bearer",
                    "summary": "Revertir cobro de un pedido pagado (vuelve a servicio)",
                    "permission": "ADMIN o código de acceso OTP",
                    "body": {
                        "id": "cod_pedido"
                    },
                    "notes": "Valida acceso a la sucursal del pedido (anti-IDOR; ADMIN exento). No revierte si hay factura SRI autorizada."
                },
                {
                    "method": "POST",
                    "path": "\/pedidos\/finalizar",
                    "auth": "bearer",
                    "summary": "Cerrar pedido tras cobro completo (libera mesa)",
                    "permission": "Mismo permiso que cobrar",
                    "body": {
                        "id": "cod_pedido"
                    },
                    "response": "pedido con estado_pedido finalizada, pedido_abierto: false",
                    "notes": "Valida acceso a la sucursal del pedido (anti-IDOR). Requiere: saldo 0, cobro completo (o todas las cuentas en split pagadas), y todos los ítems de cocina en delivered (Entregado). Las líneas sueltas de embalaje (Tarrina\/Vaso) no van a estaciones y no bloquean finalizar. No aplica a pedidos cancelados o ya finalizados. Bloquea la fila del pedido (FOR UPDATE), revalida saldo\/estados y solo escribe si el estado sigue permitido — compatible con cobros concurrentes. Al finalizar reconstruye las raíces de pago desde el historial de cobros (no desde el último método); conserva mixto y cuentas separadas."
                }
            ]
        },
        {
            "id": "cupones",
            "title": "Cupones",
            "endpoints": [
                {
                    "method": "POST",
                    "path": "\/cupones\/validar",
                    "auth": "bearer",
                    "summary": "Preview de descuento (no persiste uso)",
                    "body": "codigo + items[] del pedido; cod_pedido opcional (edición: excluye ese pedido del tope max_usos)",
                    "notes": "No incrementa usos ni escribe historial. El uso se registra en POST \/pedidos\/crear o \/pedidos\/actualizar."
                },
                {
                    "method": "GET",
                    "path": "\/cupones",
                    "auth": "bearer",
                    "summary": "Listar cupones de la empresa\/sucursal",
                    "response": "cupones[] con usos_actuales (conteo de usos activos en app_cupon_usos)",
                    "notes": "En el primer listado puede hacer backfill de usos desde pedidos que ya tenían cod_cupon."
                },
                {
                    "method": "GET",
                    "path": "\/cupones\/usos",
                    "auth": "bearer",
                    "summary": "Historial de usos de un cupón (por pedido)",
                    "query": {
                        "id": "cod_cupon"
                    },
                    "response": "usos[]: numero_pedido, estado_pedido, descuento, estado A|L, usuario, creado_en, liberado_en, motivo_liberacion",
                    "notes": "A = cupón aplicado al pedido vigente; L = liberado (pedido anulado\/eliminado, cupón quitado o cambiado). Validar cupón (POST \/cupones\/validar) no crea uso: solo al guardar el pedido con cod_cupon. El registro de uso serializa max_usos con bloqueo de fila (evita carrera entre pedidos concurrentes). En validar, envíe cod_pedido al editar para no contar el uso del propio pedido en max_usos."
                },
                {
                    "method": "POST",
                    "path": "\/cupones\/crear",
                    "auth": "bearer",
                    "summary": "Crear registro en cupones"
                },
                {
                    "method": "POST",
                    "path": "\/cupones\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar cupones"
                },
                {
                    "method": "POST",
                    "path": "\/cupones\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar cupones"
                }
            ]
        },
        {
            "id": "inventario",
            "title": "Inventario \/ lista de compras",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/inventario\/tablero",
                    "auth": "bearer+permiso",
                    "summary": "Tablero de inventario + lista de compras activa (agrupado por categoría\/proveedor)",
                    "permission": "inventario.ver",
                    "query": {
                        "q": "búsqueda nombre\/código\/categoría\/ubicación",
                        "categoria_id": "int opcional",
                        "rotacion": "alta|media|baja",
                        "estado": "pendientes|comprados|sin_necesidad|inactivos|todos",
                        "ocultar_sin_necesidad": "1|0 (default 1)"
                    },
                    "response": "tablero { lista, resumen, grupos[], marcados_finalizar[], categorias, ubicaciones (ambito=inventario), unidades, rotaciones }",
                    "notes": "Sincroniza a la lista activa los productos con faltante (stock_recomendado > stock_actual), excepto los omitidos con POST \/inventario\/lista\/quitar. Los omitidos NO se devuelven en el tablero (quedan ocultos). Query q filtra por nombre\/código\/categoría. Cada producto en lista incluye cantidad_comprada y precio_unitario temporales si se marcaron con toggle-comprado. En UI modo compra hay dos spinners: cantidad actual (ajuste inmediato) y cantidad comprada (temporal; habilita Finalizar). Checkbox\/tachar solo fuera de modo compra. Filtro estado=pendientes = lista de mercado; comprados = solo tachados. Marcar comprado NO altera existencias; eso ocurre en POST \/inventario\/lista\/finalizar."
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/categorias",
                    "auth": "bearer+permiso",
                    "summary": "GET \/inventario\/categorias"
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/categorias\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crea categoría\/proveedor o subcategoría",
                    "permission": "inventario.editar",
                    "body": {
                        "nombre": "string requerido",
                        "padre_id": "int opcional: si se envía, crea subcategoría bajo ese proveedor (un solo nivel)",
                        "orden": "int opcional"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/categorias\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/inventario\/categorias\/actualizar"
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/ubicaciones",
                    "auth": "bearer+permiso",
                    "summary": "Lista ubicaciones de stock por ámbito",
                    "permission": "inventario.ver",
                    "query": {
                        "ambito": "inventario|proteinas (default inventario)",
                        "todos": "1 = incluir inactivas (excluye X)"
                    },
                    "response": {
                        "ambito": "string",
                        "ubicaciones": "[{id,nombre,descripcion,estado,ambito}]"
                    },
                    "notes": "Inventario y proteínas no comparten catálogo de ubicaciones."
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/ubicaciones\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crea ubicación en el ámbito indicado",
                    "permission": "inventario.editar",
                    "body": {
                        "nombre": "string requerido",
                        "descripcion": "string opcional",
                        "ambito": "inventario|proteinas (default inventario)",
                        "estado": "A|I"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/ubicaciones\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualiza nombre\/estado; no cambia de ámbito",
                    "permission": "inventario.editar",
                    "body": {
                        "id": "int requerido",
                        "nombre": "string",
                        "ambito": "si se envía, la ubicación debe pertenecer a ese ámbito (filtro)",
                        "estado": "A|I"
                    }
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/productos\/buscar",
                    "auth": "bearer+permiso",
                    "summary": "Busca productos de inventario\/proteínas por nombre, código o categoría",
                    "permission": "inventario.ver",
                    "query": {
                        "q": "string; con ámbito puede ir vacío para listar el catálogo del ámbito",
                        "ambito": "inventario|proteinas (omitir = todos los ámbitos; q vacío sin ámbito no lista nada)",
                        "limit": "int (default 15; con q vacío + ámbito se fuerza hasta 200)"
                    },
                    "response": {
                        "productos": "[{id,nombre,unidad,stock_actual,stock_actual_label,…}]"
                    },
                    "notes": "Panel proteínas: q vacío + ambito=proteinas alimenta el modal «Ver proteínas». Sin ámbito, q vacío responde lista vacía."
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/productos\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "GET \/inventario\/productos\/detalle"
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/productos\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crea producto de inventario (insumo) con stock inicial opcional",
                    "permission": "inventario.editar",
                    "body": {
                        "nombre": "string requerido",
                        "ambito": "inventario|proteinas (default inventario); la ubicación de stock inicial debe ser del mismo ámbito",
                        "categoria_ids": "int[] proveedores raíz donde se puede comprar (stock único; aparece en cada grupo del tablero)",
                        "categoria_id": "int opcional legacy: proveedor o subcategoría (hoja primaria)",
                        "subcategoria_id": "int opcional; debe ser hija de uno de categoria_ids",
                        "unidad": "unidad|funda|paquete|caja|botella|frasco|cubeta|balde|galon|litro|…",
                        "stock_recomendado": "decimal > 0 (requerido; si no, no entra a lista de mercado)",
                        "rotacion": "alta|media|baja",
                        "ubicacion_id": "int si hay cantidad_inicial (mismo ambito del producto)",
                        "cantidad_inicial": "decimal >= 0",
                        "codigo": "string opcional único por sucursal",
                        "confirmar_duplicado": "bool si hay homónimo"
                    },
                    "notes": "categoria_ids vincula varios proveedores sin duplicar stock ni cantidad a comprar. Response incluye categorias_ids y categorias_label. Si stock_recomendado > stock actual, se agrega a la lista activa (agregado_a_lista). 409 DUPLICADO_NOMBRE sin confirmar_duplicado."
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/productos\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualiza producto de inventario",
                    "permission": "inventario.editar",
                    "body": {
                        "id": "int",
                        "categoria_ids": "int[] proveedores raíz (reemplaza vínculos)",
                        "categoria_id": "int hoja primaria (proveedor o subcategoría)",
                        "nombre": "string",
                        "unidad": "string",
                        "stock_recomendado": "decimal",
                        "rotacion": "alta|media|baja"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/productos\/desactivar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/inventario\/productos\/desactivar"
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/stock\/ajustar",
                    "auth": "bearer+permiso",
                    "summary": "Ajuste manual de stock (entrada\/salida\/corrección\/merma\/consumo) con movimiento",
                    "permission": "inventario.editar",
                    "body": {
                        "producto_id": "int",
                        "ubicacion_id": "int",
                        "tipo": "entrada|salida|correccion|merma|consumo",
                        "cantidad": "decimal (UI de meseros usa enteros en spinners)",
                        "motivo": "string opcional (puede ir vacío)",
                        "observacion": "string opcional; texto libre o con prefijo SOLICITAR COMPRA",
                        "solicitar_compra": "bool opcional; true fuerza ítem en lista + marca; false quita solo la marca (no borra otras notas); omitido = no tocar marca salvo compat por texto sentinel"
                    },
                    "notes": "Motivo opcional desde 1.17.2. UI meseros envía solicitar_compra=true si el checkbox está marcado; =false solo si el usuario lo desmarcó en esa sesión; omitido = no tocar la marca. true → ensureEnLista forzado, reabre ítem comprado, GREATEST(planificada\/sugerida,1), conserva nota del comprador. false → quitarSolicitudCompraListaActiva. Tipo correccion fija cantidad absoluta (0 permitido)."
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/stock",
                    "auth": "bearer+permiso",
                    "summary": "Listado de insumos con stock por ámbito de ubicación\/producto",
                    "permission": "inventario.ver",
                    "query": {
                        "ambito": "inventario|proteinas (default inventario)",
                        "q": "búsqueda opcional nombre\/código"
                    },
                    "response": {
                        "ambito": "string",
                        "productos": "[{id,nombre,unidad,stock_total,stock_total_label,existencias,filas}]",
                        "filas": "tabla inventario: [{ubicacion,producto,cantidad_label}]",
                        "filas_proteinas": "solo ambito=proteinas: [{tipo_label,producto,cantidad_label,otp,ubicacion}]",
                        "total": "int"
                    },
                    "notes": "Filtra productos y ubicaciones del mismo ámbito. Proteínas lista fundas\/menudeo abiertas (unidades + OTP), no el peso."
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/recetas",
                    "auth": "bearer+permiso",
                    "summary": "Lista vínculos activos de un insumo hacia platos\/complementos",
                    "permission": "inventario.ver",
                    "query": {
                        "insumo_id": "int (alias producto_id)"
                    },
                    "response": {
                        "vinculos": "[{ id, tipo: producto|complemento, cod_ref, cantidad, label, ubicacion_id? }]"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/recetas\/guardar",
                    "auth": "bearer+permiso",
                    "summary": "Reemplaza todos los vínculos activos de un insumo",
                    "permission": "inventario.editar",
                    "body": {
                        "insumo_id": "int",
                        "vinculos": "[{ tipo: producto|complemento, cod_ref, cantidad, ubicacion_id? }]"
                    },
                    "notes": "Soft-delete de vínculos previos (estado X) e inserta\/reactiva los enviados. cantidad = unidades de insumo por 1 unidad vendida del plato\/complemento."
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/recetas\/buscar-catalogo",
                    "auth": "bearer+permiso",
                    "summary": "Busca platos y opciones de complemento para vincular",
                    "permission": "inventario.ver",
                    "query": {
                        "q": "string (mín. 2 chars)"
                    },
                    "response": {
                        "productos": "[{id,nombre,label}]",
                        "complementos": "[{id,nombre,label,grupo}]"
                    }
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/proteinas",
                    "auth": "bearer+permiso",
                    "summary": "Historial de lotes o mixto ingreso+egreso",
                    "permission": "inventario.ver",
                    "query": {
                        "limit": "int 1–200 (default 50)",
                        "historial": "1 = mixto [{tipo:ingreso|egreso, lote|egreso}]"
                    }
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/proteinas\/disponible",
                    "auth": "bearer+permiso",
                    "summary": "Stock + fundas\/menudeo disponibles de un insumo de proteínas",
                    "permission": "inventario.ver",
                    "query": {
                        "producto_id": "int"
                    },
                    "response": {
                        "stock_total": "decimal (unidades)",
                        "modo": "fundas|unidades",
                        "modo_menudeo": "bool",
                        "menudeo_unidades": "int",
                        "fundas": "solo tipo funda [{id,tipo,otp,unidades,peso}]"
                    }
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/proteinas\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "Detalle de un lote con fundas",
                    "permission": "inventario.ver",
                    "query": {
                        "id": "int"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/proteinas\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Ingreso: lote con fundas\/menudeo (unidades + peso + OTP)",
                    "permission": "inventario.editar",
                    "body": {
                        "insumo_origen_id": "int (producto ambito=proteinas)",
                        "ubicacion_id": "int ambito=proteinas",
                        "fundas": "[{ tipo: funda|menudeo, cantidad int, peso?, otp? }]",
                        "peso_inicial": "opcional; si 0 usa suma pesos o unidades"
                    },
                    "notes": "OTP por fila. Stock en unidades. Catálogo separado de inventario (ambito)."
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/proteinas\/egreso",
                    "auth": "bearer+permiso",
                    "summary": "Egreso: fundas (check) y\/o unidades (spinner disminuir|conteo)",
                    "permission": "inventario.editar",
                    "body": {
                        "producto_id": "int",
                        "funda_ids": "int[] fundas OTP a retirar enteras",
                        "cantidad_unidades": "int unidades a restar (o conteo final si modo=conteo)",
                        "modo_unidades": "disminuir|conteo",
                        "ubicacion_id": "int si hay cantidad_unidades"
                    },
                    "notes": "disminuir resta N del stock (máx. = existencias). conteo deja el stock en N. Las unidades también reducen fundas\/menudeo FIFO (parcial o egreso)."
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/movimientos",
                    "auth": "bearer+permiso",
                    "summary": "GET \/inventario\/movimientos"
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/lista\/agregar",
                    "auth": "bearer+permiso",
                    "summary": "Agrega un producto a la lista activa (o restaura uno omitido)",
                    "permission": "inventario.editar",
                    "body": {
                        "producto_id": "int requerido",
                        "cantidad|cantidad_planificada": "decimal opcional"
                    },
                    "response": "data.detalle_id, data.lista, data.producto; data.already=true si ya estaba en la lista",
                    "notes": "Idempotente desde 1.34.6: si el producto ya está en la lista (no omitido), no falla — devuelve el detalle_id existente. Usado por el spinner “Comprada” en modo compra cuando el ítem aún no estaba en la lista."
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/lista\/quitar",
                    "auth": "bearer+permiso",
                    "summary": "Quita un ítem de la lista activa (soft-omit: no borra el producto maestro)",
                    "permission": "inventario.editar",
                    "body": {
                        "detalle_id": "int"
                    },
                    "notes": "Marca omitido=1 en el detalle y el producto desaparece del tablero. El sync automático no lo vuelve a agregar en la misma lista; para reincorporarlo usar POST \/inventario\/lista\/agregar (restaura omitido=0) vía buscador\/Agregar. En una lista nueva el sync puede volver a incluirlo si sigue faltando stock."
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/lista\/reordenar",
                    "auth": "bearer+permiso",
                    "summary": "Reordena ítems de la lista activa (drag-and-drop)",
                    "permission": "inventario.editar",
                    "body": {
                        "detalle_ids": "int[] en el orden visual deseado"
                    },
                    "notes": "Actualiza app_inventario_lista_detalle.orden = 1..n según el arreglo. Solo ítems de la lista activa no omitidos\/procesados."
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/lista\/toggle-comprado",
                    "auth": "bearer+permiso",
                    "summary": "Marca\/desmarca un ítem de la lista activa como comprado (sin tocar stock)",
                    "permission": "inventario.editar",
                    "body": {
                        "detalle_id": "int",
                        "comprado": "bool",
                        "cantidad_comprada": "decimal > 0 requerido al marcar (temporal hasta finalizar)",
                        "precio_unitario": "decimal opcional (USD\/local; temporal hasta finalizar)",
                        "observacion": "string opcional (nota de compra; visible en modo compra)"
                    },
                    "notes": "Al marcar guarda cantidad_comprada, precio_unitario y observacion en app_inventario_lista_detalle (también sincroniza cantidad_planificada) y registra comprado_por. No crea movimientos de stock; eso ocurre en POST \/inventario\/lista\/finalizar. Al desmarcar limpia comprado pero conserva cantidad\/precio\/observacion previos para reutilizar. El tablero expone lista_observacion y comprado_por_nombre."
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/lista\/limpiar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/inventario\/lista\/limpiar"
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/lista\/finalizar",
                    "auth": "bearer+permiso",
                    "summary": "Finaliza la compra: crea movimientos de entrada y actualiza existencias (transacción)",
                    "permission": "inventario.editar",
                    "body": {
                        "items": "[{ detalle_id, cantidad_comprada, ubicacion_destino_id, precio_unitario?, observacion? }]",
                        "observacion": "string opcional de la lista"
                    },
                    "notes": "Solo procesa ítems con comprado=1. Idempotente: rechaza si la lista ya está finalizada. Rollback completo si falla un ítem."
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/historial",
                    "auth": "bearer+permiso",
                    "summary": "Listas de compra finalizadas o canceladas",
                    "permission": "inventario.ver"
                },
                {
                    "method": "GET",
                    "path": "\/inventario\/historial\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "Detalle de una lista histórica + movimientos",
                    "permission": "inventario.ver",
                    "query": {
                        "id": "int lista"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/inventario\/seed",
                    "auth": "bearer+permiso",
                    "summary": "POST \/inventario\/seed"
                }
            ]
        },
        {
            "id": "servicios",
            "title": "Servicios adicionales",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/servicios",
                    "auth": "bearer+permiso",
                    "summary": "Listar servicios"
                },
                {
                    "method": "GET",
                    "path": "\/servicios\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "Detalle de servicios"
                },
                {
                    "method": "POST",
                    "path": "\/servicios\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en servicios"
                },
                {
                    "method": "POST",
                    "path": "\/servicios\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar servicios"
                },
                {
                    "method": "POST",
                    "path": "\/servicios\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar servicios"
                }
            ]
        },
        {
            "id": "proformas",
            "title": "Proformas",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/proformas",
                    "auth": "bearer+permiso",
                    "summary": "Listar proformas"
                },
                {
                    "method": "GET",
                    "path": "\/proformas\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "Detalle de proformas"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en proformas"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/marcar-enviada",
                    "auth": "bearer+permiso",
                    "summary": "POST \/proformas\/marcar-enviada"
                },
                {
                    "method": "GET",
                    "path": "\/proformas\/historial",
                    "auth": "bearer+permiso",
                    "summary": "GET \/proformas\/historial"
                },
                {
                    "method": "GET",
                    "path": "\/proformas\/pdf",
                    "auth": "bearer+permiso",
                    "summary": "GET \/proformas\/pdf"
                },
                {
                    "method": "GET",
                    "path": "\/proformas\/adjunto",
                    "auth": "bearer+permiso",
                    "summary": "GET \/proformas\/adjunto"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/adjuntos",
                    "auth": "bearer+permiso",
                    "summary": "POST \/proformas\/adjuntos"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/facturar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/proformas\/facturar"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar proformas"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar proformas"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/estado",
                    "auth": "bearer+permiso",
                    "summary": "POST \/proformas\/estado"
                },
                {
                    "method": "POST",
                    "path": "\/proformas\/enviar-correo",
                    "auth": "bearer+permiso",
                    "summary": "POST \/proformas\/enviar-correo"
                }
            ]
        },
        {
            "id": "equipo_usuarios",
            "title": "Equipo \/ usuarios",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/equipo_usuarios",
                    "auth": "bearer+permiso",
                    "summary": "Listar equipo usuarios"
                },
                {
                    "method": "POST",
                    "path": "\/equipo_usuarios\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en equipo usuarios"
                },
                {
                    "method": "POST",
                    "path": "\/equipo_usuarios\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar equipo usuarios"
                },
                {
                    "method": "POST",
                    "path": "\/equipo_usuarios\/desactivar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/equipo_usuarios\/desactivar"
                }
            ]
        },
        {
            "id": "roles",
            "title": "Roles y permisos",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/roles",
                    "auth": "bearer+permiso",
                    "summary": "Roles y catálogo de permisos RBAC",
                    "permission": "roles.gestionar"
                },
                {
                    "method": "POST",
                    "path": "\/roles\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar roles"
                }
            ]
        },
        {
            "id": "repartidores",
            "title": "Repartidores delivery",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/repartidores",
                    "auth": "bearer",
                    "summary": "Listar repartidores"
                },
                {
                    "method": "POST",
                    "path": "\/repartidores\/crear",
                    "auth": "bearer",
                    "summary": "Crear registro en repartidores"
                },
                {
                    "method": "POST",
                    "path": "\/repartidores\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar repartidores"
                },
                {
                    "method": "POST",
                    "path": "\/repartidores\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar repartidores"
                }
            ]
        },
        {
            "id": "trabajadores",
            "title": "Trabajadores, turnos y nómina",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/trabajadores\/cargos",
                    "auth": "bearer+permiso",
                    "summary": "GET \/trabajadores\/cargos"
                },
                {
                    "method": "GET",
                    "path": "\/trabajadores\/consumo-opciones",
                    "auth": "bearer",
                    "summary": "Empleados activos para «Consumo empleados» en el modal de cobro",
                    "permission": "Cualquier usuario autenticado de la empresa",
                    "response": "workers[] { id, name, cargo } de la sucursal resuelta (X-Cod-Sucursal o predeterminada), solo estado A",
                    "notes": "Lista liviana sin nómina, anticipos ni datos personales. El id se envía como pago_detalle.pendiente_trabajador_id en POST \/pedidos\/cobrar, que valida que el empleado sea de la empresa."
                },
                {
                    "method": "GET",
                    "path": "\/trabajadores\/estado",
                    "auth": "bearer+permiso",
                    "summary": "GET \/trabajadores\/estado"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crear registro en trabajadores"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualizar trabajadores"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Eliminar trabajadores"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/turnos\/asignar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/turnos\/asignar"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/turnos\/quitar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/turnos\/quitar"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/turnos\/copiar_semana",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/turnos\/copiar_semana"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/adelantos\/crear",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/adelantos\/crear"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/adelantos\/aplicar_cuota",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/adelantos\/aplicar_cuota"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/adelantos\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/adelantos\/actualizar"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/adelantos\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/adelantos\/eliminar"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/bonos\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Registrar un bono\/ingreso extra (p. ej. horas extra) para un trabajador",
                    "permission": "empleados.ver",
                    "body": {
                        "workerId": "int id del trabajador",
                        "fecha": "YYYY-MM-DD (fecha del bono; define semana_inicio = lunes de esa fecha)",
                        "amount|monto": "decimal > 0",
                        "motivo": "string obligatorio. Horas extra: \"Horas extras - Mañana 10:00AM - 16:00PM\\nTOTAL : 6 HORAS\"",
                        "notes|notas": "string opcional (detalle calc: Trabajadas, Real HH:MM–HH:MM, Factor, etc.)"
                    },
                    "response": "bono creado; syncPendingRolForWorker actualiza el rol pendiente del período",
                    "notes": "Si el motivo empieza por “Horas extra(s)”, el PDF del rol lo muestra como ingreso (sin prefijo “Bono:”), con horario y TOTAL de horas. Si el motivo es corto (legacy), el PDF reconstruye el rubro desde notes (Real \/ Trabajadas). Rechaza si el rol de esa semana ya está pagado. Usado por la calculadora de horas extra del panel."
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/bonos\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/bonos\/actualizar"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/bonos\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/bonos\/eliminar"
                },
                {
                    "method": "GET",
                    "path": "\/trabajadores\/asistencias",
                    "auth": "bearer+permiso",
                    "summary": "GET \/trabajadores\/asistencias"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/asistencias\/guardar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/asistencias\/guardar"
                },
                {
                    "method": "GET",
                    "path": "\/trabajadores\/roles_pago",
                    "auth": "bearer+permiso",
                    "summary": "Listar roles del período de nómina (preview con cálculo vivo si no está pagado)",
                    "permission": "empleados.ver",
                    "query": {
                        "semana": "YYYY-MM-DD (cualquier día del período). Si ya existen roles con esa fecha_inicio se respetan esas fechas; si no, se usa frecuencia_pago de la empresa: semanal→lunes, quincenal→1 o 16, mensual→día 1."
                    },
                    "response": "fechaInicio, fechaFin, semanaLabel, periodo {frecuencia, frecuenciaLabel, inicio, fin, label, anterior, siguiente, esActual}, roles[]",
                    "notes": "Auto-crea roles pendientes de trabajadores activos del período actual. Si encuentra un rol pagado vacío (0 turnos\/ingresos\/recibir y sin gasto de nómina), lo reabre a pendiente y recalcula desde asistencias. Egresos de adelantos\/descuentos del período se muestran en vivo también en snapshots congelados. En snapshots congelados, los montos de cada turno del detalle se alinean con valor_dia y total_ingresos (evita desfase listado vs PDF). Horario de turnos no cambia: se suman los lunes que se cruzan con el período y se recortan los días fuera del corte."
                },
                {
                    "method": "GET",
                    "path": "\/trabajadores\/roles_pago\/historial",
                    "auth": "bearer+permiso",
                    "summary": "GET \/trabajadores\/roles_pago\/historial"
                },
                {
                    "method": "GET",
                    "path": "\/trabajadores\/roles_pago\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "GET \/trabajadores\/roles_pago\/detalle"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/roles_pago\/generar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/trabajadores\/roles_pago\/generar"
                },
                {
                    "method": "POST",
                    "path": "\/trabajadores\/roles_pago\/pagar",
                    "auth": "bearer+permiso",
                    "summary": "Marcar un rol de pago como pagado y registrar el gasto de nómina",
                    "permission": "empleados.ver",
                    "body": {
                        "id": "int id del rol",
                        "metodo_pago": "efectivo|transferencia (default efectivo)"
                    },
                    "response": "rol actualizado con estado pagado",
                    "notes": "Rechaza si el rol ya está pagado, si total_recibir es <= 0, o si no hay turnos\/ingresos (no permite pagar roles vacíos). Solo crea gasto de nómina cuando hay monto a pagar. Aplica cuotas de adelantos\/descuentos del período del rol (semanal\/quincenal\/mensual según fecha_inicio\/fecha_fin guardadas). Los roles congelados como pagado en $0 sin cod_gasto (pago fantasma) se reabren y recalculan automáticamente al listar\/generar el período."
                }
            ]
        },
        {
            "id": "caja_chica",
            "title": "Caja chica",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/caja_chica\/encargados",
                    "auth": "bearer",
                    "summary": "GET \/caja_chica\/encargados"
                },
                {
                    "method": "GET",
                    "path": "\/caja_chica\/estado",
                    "auth": "bearer",
                    "summary": "GET \/caja_chica\/estado"
                },
                {
                    "method": "GET",
                    "path": "\/caja_chica\/metricas_mes",
                    "auth": "bearer",
                    "summary": "GET \/caja_chica\/metricas_mes"
                },
                {
                    "method": "GET",
                    "path": "\/caja_chica\/ventas_semana",
                    "auth": "bearer",
                    "summary": "Resumen lunes–domingo de ventas, gastos, transferencias y efectivo",
                    "permission": "caja.ver (misma sesión que caja chica)",
                    "query": {
                        "fecha": "YYYY-MM-DD de cualquier día de la semana (default hoy)"
                    },
                    "response": "semana { lunes, domingo, rangoLabel, semanaAnterior, semanaSiguiente, esSemanaActual, turnos[], dias[], totales }. Cada día: ventas (alias ingresos), ingresosPorTurno[] {turnoKey, turnoLabel, ingresos}, gastos, transferencias, efectivo, cupones, resultado, gastosDetalle[], bancos[] {banco, label, logo, monto}. turnos[] define la leyenda del gráfico (mañana\/tarde\/noche según app_turnos_atencion).",
                    "notes": "Ventas = venta bruta del día (efectivo + transferencias + cupones). ingresosPorTurno reparte esa venta según TurnosAtencion::resolverTurnoAtencion en el momento de contabilización (misma lógica que métrica mensual). La suma de turnos debe igualar ingresos del día. gastosDetalle lista cada gasto de caja. bancos desglosa transferencias por entidad. Semana lunes–domingo, zona America\/Guayaquil."
                },
                {
                    "method": "GET",
                    "path": "\/caja_chica\/historial",
                    "auth": "bearer",
                    "summary": "GET \/caja_chica\/historial"
                },
                {
                    "method": "GET",
                    "path": "\/caja_chica\/turnos_reporte",
                    "auth": "bearer",
                    "summary": "Turnos de caja de una fecha para generar reporte de cuadre",
                    "permission": "negocio.ver o caja chica",
                    "query": {
                        "fecha": "YYYY-MM-DD (default hoy)"
                    },
                    "response": "turnos[] con id, turnoNombre, turnoLabel (nombre + rango real), rangoReal {inicio, fin, label}, usuarios[] {nombre, primerHora, ultimaHora, rangoLabel}, balance, estado (abierto|cerrado)",
                    "notes": "El rango real se calcula desde el primer movimiento (apertura, venta, ingreso, egreso o gasto) hasta el último. En turnos cerrados las ventas se filtran por fecha_apertura y fecha_cierre. En turnos abiertos se incluyen todas las ventas del día desde la apertura (sin tope del horario configurado del turno). El primer turno del día también absorbe las ventas cobradas antes de su apertura (ej. pedidos de la mañana cobrados antes de abrir la caja), para que no queden fuera de todos los turnos. Si hay 2+ turnos, el PDF acepta consolidado=1&fecha=YYYY-MM-DD (columna Turno en detalle; fondo\/balance del día sin sumar fondos entre turnos). KPI Efectivo y Transferencias = suma de las filas del detalle (pago mixto se desglosa; tarjeta cuenta en Transferencias) tanto en GET \/caja_chica\/estado (pantalla) como en el PDF; el PDF no pisa el KPI de Transferencias con la suma de bancos si algún día divergieran. Un cobro grabado (pago_detalle_json) no se completa hasta el total del pedido con el método anterior: el hueco no entra a Efectivo (ni se inventa Transferencia). El resumen metodo_pago con «|» no pisa esos montos: solo desglosa bancos si efectivo y transferencia coinciden con el JSON. Pedido con factura electrónica anulada se excluye de ventas; no se genera retiro automático de efectivo (si hubo devolución física, registrarlo a mano). Pedido pagado con mesa abierta se etiqueta Pagado, no Por cobrar. detalleVentas\/movimientos incluyen tiempoCocinaMin\/Label (máximo entre oleadas e ítems; listo_en\/ready o delivered del día, salvo job post-cierre; un listo temprano de bebida no tapa comida más lenta; sin señal → \"—\") y tiempoServicioMin\/Label (llegada→cierre de cuenta, texto plano p.ej. \"19 minutos\")."
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/turnos\/eliminar",
                    "auth": "bearer",
                    "summary": "POST \/caja_chica\/turnos\/eliminar"
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/abrir",
                    "auth": "bearer",
                    "summary": "POST \/caja_chica\/abrir"
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/encargado",
                    "auth": "bearer",
                    "summary": "POST \/caja_chica\/encargado"
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/movimientos\/crear",
                    "auth": "bearer",
                    "summary": "Registrar ingreso o retiro de efectivo del turno abierto",
                    "permission": "Encargado del turno o ADMIN\/negocio.ver",
                    "body": {
                        "cod_caja_chica": "int (turno abierto)",
                        "tipo": "ingreso|retiro",
                        "concepto": "string",
                        "monto": "decimal > 0",
                        "notas": "texto opcional"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/movimientos\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar ingreso o retiro de efectivo del turno abierto (baja lógica)",
                    "permission": "Encargado del turno o ADMIN\/negocio.ver",
                    "body": {
                        "id": "int"
                    },
                    "notes": "Solo en turno abierto."
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/movimientos\/actualizar",
                    "auth": "bearer",
                    "summary": "Editar ingreso o retiro de efectivo del turno abierto",
                    "permission": "Encargado del turno o ADMIN\/negocio.ver",
                    "body": {
                        "id": "int",
                        "tipo": "ingreso|retiro",
                        "concepto": "string",
                        "monto": "decimal > 0",
                        "notas": "texto opcional"
                    },
                    "notes": "Solo en turno abierto. El ADMIN también puede editar aunque no sea el encargado."
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/gastos\/crear",
                    "auth": "bearer",
                    "summary": "Registrar un gasto del turno de caja abierto",
                    "permission": "Encargado del turno o ADMIN\/negocio.ver",
                    "body": {
                        "cod_caja_chica": "int (turno abierto)",
                        "concepto": "string",
                        "monto": "decimal > 0",
                        "notas": "HTML\/texto opcional"
                    },
                    "notes": "Solo en turno abierto. El gasto queda ligado al turno (cod_caja_chica) y descuenta del balance de caja."
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/gastos\/actualizar",
                    "auth": "bearer",
                    "summary": "Editar un gasto del turno de caja abierto",
                    "permission": "Encargado del turno o ADMIN\/negocio.ver",
                    "body": {
                        "id": "int",
                        "concepto": "string",
                        "monto": "decimal > 0",
                        "notas": "HTML\/texto opcional"
                    },
                    "notes": "Solo se pueden editar gastos cuyo turno siga abierto. El ADMIN (o quien tenga negocio.ver) también puede editar aunque no sea el encargado."
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/gastos\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar un gasto del turno de caja abierto (baja lógica)",
                    "permission": "Encargado del turno o ADMIN\/negocio.ver",
                    "body": {
                        "id": "int"
                    },
                    "notes": "Solo se pueden eliminar gastos cuyo turno siga abierto. Actualiza el balance de caja."
                },
                {
                    "method": "GET",
                    "path": "\/caja_chica\/ventas_manuales\/listar",
                    "auth": "bearer",
                    "summary": "Listado de ventas manuales (pedidos fuera del sistema)",
                    "permission": "ADMIN o negocio.ver",
                    "query": {
                        "fecha_desde": "YYYY-MM-DD opcional",
                        "fecha_hasta": "YYYY-MM-DD opcional"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/ventas_manuales\/crear",
                    "auth": "bearer",
                    "summary": "Registrar venta manual",
                    "permission": "ADMIN o negocio.ver",
                    "body": {
                        "fecha": "YYYY-MM-DD",
                        "hora": "HH:mm",
                        "monto": "decimal",
                        "motivo": "HTML\/texto"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/ventas_manuales\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar venta manual",
                    "permission": "ADMIN o negocio.ver",
                    "body": {
                        "id": "int",
                        "fecha": "YYYY-MM-DD",
                        "hora": "HH:mm",
                        "monto": "decimal",
                        "motivo": "HTML\/texto"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/ventas_manuales\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar venta manual (baja lógica)",
                    "permission": "ADMIN o negocio.ver",
                    "body": {
                        "id": "int"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/caja_chica\/cerrar",
                    "auth": "bearer",
                    "summary": "POST \/caja_chica\/cerrar"
                }
            ]
        },
        {
            "id": "turnos_atencion",
            "title": "Turnos de atención",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/turnos_atencion",
                    "auth": "bearer",
                    "summary": "Listar turnos atencion"
                },
                {
                    "method": "GET",
                    "path": "\/turnos_atencion\/puede_inactivar",
                    "auth": "bearer",
                    "summary": "GET \/turnos_atencion\/puede_inactivar"
                },
                {
                    "method": "POST",
                    "path": "\/turnos_atencion\/crear",
                    "auth": "bearer",
                    "summary": "Crear registro en turnos atencion"
                },
                {
                    "method": "POST",
                    "path": "\/turnos_atencion\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar turnos atencion"
                }
            ]
        },
        {
            "id": "kpis_diarios",
            "title": "KPIs diarios",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/kpis_diarios",
                    "auth": "bearer+permiso",
                    "summary": "Panel KPIs del día (ventas, mesas, reseñas, etc.)",
                    "permission": "inicio.ver",
                    "query": {
                        "fecha": "YYYY-MM-DD opcional"
                    },
                    "notes": "totales.gastos \/ utilidad del día solo incluyen gastos de empresa con ambito=negocio (personal excluido)."
                }
            ]
        },
        {
            "id": "gastos_empresa",
            "title": "Negocio \/ gastos empresa",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/gastos_empresa\/resumen",
                    "auth": "bearer+permiso",
                    "summary": "Resumen mensual negocio (KPIs, gastos, reseñas)",
                    "permission": "negocio.ver",
                    "query": {
                        "anio": "int",
                        "mes": "1-12"
                    },
                    "response": "resumen con gastos[] (ambito negocio|personal, fotoUrl), totales.gastosNegocio \/ gastosPersonal (solo negocio afecta utilidad)",
                    "notes": "PDF mensual: gastos-empresa-reporte-pdf.php. PDF detalle por rango: gastos-empresa-detalle-pdf.php?desde=&hasta="
                },
                {
                    "method": "POST",
                    "path": "\/gastos_empresa\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Registra un gasto de empresa",
                    "permission": "negocio.ver",
                    "body": {
                        "tipo": "luz|agua|internet|arriendo|proveedores|otro",
                        "ambito": "negocio|personal (default negocio)",
                        "concepto": "string (obligatorio si tipo=otro)",
                        "monto": "decimal",
                        "fecha_gasto": "YYYY-MM-DD",
                        "metodo_pago": "transferencia|efectivo|mixto",
                        "monto_efectivo \/ monto_transferencia": "si mixto",
                        "cod_proveedor": "si tipo=proveedores",
                        "notas": "opcional",
                        "foto_url": "opcional URL Spaces (UI sube al guardar; huérfanos se limpian con descartar_foto)"
                    },
                    "response": "{ success, gasto } con ambito y fotoUrl"
                },
                {
                    "method": "POST",
                    "path": "\/gastos_empresa\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualiza un gasto de empresa",
                    "permission": "negocio.ver",
                    "body": {
                        "id": "int",
                        "campos": "iguales a crear; foto_url vacío quita la foto y limpia el objeto en Spaces"
                    },
                    "notes": "Solo muta gastos de la sucursal en scope (anti-IDOR entre sucursales)."
                },
                {
                    "method": "POST",
                    "path": "\/gastos_empresa\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "Soft-delete de gasto (estado X) y limpia foto en Spaces si había",
                    "permission": "negocio.ver",
                    "body": {
                        "id": "int"
                    },
                    "notes": "Respeta scope de sucursal."
                },
                {
                    "method": "POST",
                    "path": "\/gastos_empresa\/descartar_foto",
                    "auth": "bearer+permiso",
                    "summary": "Elimina un comprobante huérfano en Spaces (folder GASTOS)",
                    "permission": "negocio.ver",
                    "body": {
                        "foto_url": "URL Spaces del comprobante a borrar"
                    },
                    "notes": "Uso UI: upload OK pero create\/update falló, o cancelación del modal con foto ya subida."
                }
            ]
        },
        {
            "id": "proveedores",
            "title": "Proveedores",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/proveedores",
                    "auth": "bearer",
                    "summary": "Listar proveedores"
                },
                {
                    "method": "POST",
                    "path": "\/proveedores\/crear",
                    "auth": "bearer",
                    "summary": "Crear registro en proveedores"
                },
                {
                    "method": "POST",
                    "path": "\/proveedores\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar proveedores"
                },
                {
                    "method": "POST",
                    "path": "\/proveedores\/eliminar",
                    "auth": "bearer",
                    "summary": "Eliminar proveedores"
                }
            ]
        },
        {
            "id": "sucursales",
            "title": "Sucursales",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/sucursales",
                    "auth": "bearer",
                    "summary": "Listar sucursales"
                },
                {
                    "method": "POST",
                    "path": "\/sucursales\/crear",
                    "auth": "bearer",
                    "summary": "Crear registro en sucursales"
                },
                {
                    "method": "POST",
                    "path": "\/sucursales\/actualizar",
                    "auth": "bearer",
                    "summary": "Actualizar sucursales"
                }
            ]
        },
        {
            "id": "empresa",
            "title": "Configuración empresa",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/empresa\/kitchen-pantallas",
                    "auth": "bearer+permiso",
                    "summary": "GET \/empresa\/kitchen-pantallas"
                },
                {
                    "method": "GET",
                    "path": "\/empresa",
                    "auth": "bearer+permiso",
                    "summary": "Configuración de la empresa (datos generales, logo, favicon, FE, SMTP, impresión, nómina)",
                    "permission": "empresa.ver",
                    "response": "empresa con logo_path\/logo_url, favicon_path\/favicon_url, bancos, fe_*, smtp (solo rol ADMIN; password vacío + password_set), frecuencia_pago, frecuencia_pago_label, frecuencia_pago_opciones, tarjeta_credito { proveedor, activo (bool), porcentaje (0–100, 2 decimales), logo_path (URL Spaces) }, can_edit (true solo ADMIN), etc.",
                    "notes": "favicon_path es el icono de pestaña del panel. Si está vacío, el HTML del sistema usa logo_path (o fe_logo_path) como respaldo. frecuencia_pago default semanal. smtp.* y can_edit solo para ADMIN. smtp.password y factura.clave_firma nunca se devuelven en claro; enviar vacío en POST conserva el valor actual. tarjeta_credito configura el cobro con tarjeta de crédito en el modal de pago: si activo, aparece el método «Tarjeta de crédito» con desglose valor + comisión (porcentaje) = total a cobrar en el datáfono. La comisión la asume el cliente y no altera el total del pedido ni la factura (ver POST \/pedidos\/cobrar)."
                },
                {
                    "method": "GET",
                    "path": "\/empresa\/bancos",
                    "auth": "bearer",
                    "summary": "Bancos de transferencia y config de tarjeta de crédito para el modal de cobro",
                    "permission": "empresa.ver, pedidos.ver o caja.ver",
                    "response": "bancos[] { banco, slug, label, logo }, tarjeta_credito { proveedor, activo (bool), porcentaje, logo_path }",
                    "notes": "Sin secretos de empresa. tarjeta_credito.activo=false oculta el método Tarjeta de crédito en el modal."
                },
                {
                    "method": "POST",
                    "path": "\/empresa\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualiza la configuración de la empresa",
                    "permission": "empresa.ver (edición admin)",
                    "body": {
                        "nombre": "string",
                        "ruc": "string",
                        "logo_path": "string ruta Spaces\/relativa",
                        "favicon_path": "string ruta Spaces\/relativa (ICO\/PNG\/WEBP\/JPG)",
                        "url_publica_web": "string URL pública",
                        "frecuencia_pago": "semanal|quincenal|mensual (nómina; no altera el horario semanal de turnos)",
                        "smtp_password": "string opcional; vacío = no cambiar",
                        "factura.clave_firma": "string opcional; vacío = no cambiar",
                        "tarjeta_credito": "objeto opcional { proveedor: string ≤120, activo: bool, porcentaje: number 0–100, logo_path: URL Spaces o \"\" para quitar }. activo=true exige proveedor. Omitir el objeto conserva los valores actuales."
                    },
                    "notes": "Al reemplazar o quitar tarjeta_credito.logo_path, el objeto anterior se borra de Spaces (salvo que siga en uso como logo\/favicon\/logo FE). Tras guardar favicon_path, todas las páginas del panel (login + meseros) lo muestran vía partial head-favicon. Subir archivo con el uploader de imágenes (tipo=empresas) y persistir la ruta aquí. Cambiar frecuencia_pago no borra roles históricos; los nuevos períodos, cuotas de anticipos y descuentos usan el corte elegido."
                },
                {
                    "method": "GET",
                    "path": "\/empresa\/cartas",
                    "auth": "bearer+permiso",
                    "summary": "GET \/empresa\/cartas"
                },
                {
                    "method": "POST",
                    "path": "\/empresa\/cartas\/eliminar",
                    "auth": "bearer+permiso",
                    "summary": "POST \/empresa\/cartas\/eliminar"
                }
            ]
        },
        {
            "id": "accesos_log",
            "title": "Log de accesos",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/accesos_log",
                    "auth": "bearer",
                    "summary": "Listar accesos log"
                }
            ]
        },
        {
            "id": "cms",
            "title": "Cms",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/cms\/contactos",
                    "auth": "bearer+permiso",
                    "summary": "GET \/cms\/contactos"
                },
                {
                    "method": "POST",
                    "path": "\/cms\/contactos\/estado",
                    "auth": "bearer+permiso",
                    "summary": "POST \/cms\/contactos\/estado"
                },
                {
                    "method": "GET",
                    "path": "\/cms\/suscripciones",
                    "auth": "bearer+permiso",
                    "summary": "GET \/cms\/suscripciones"
                }
            ]
        },
        {
            "id": "app",
            "title": "App",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/app\/perfiles",
                    "auth": "bearer",
                    "summary": "GET \/app\/perfiles"
                }
            ]
        },
        {
            "id": "codigos_acceso",
            "title": "Codigos acceso",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/codigos_acceso",
                    "auth": "bearer+permiso",
                    "summary": "Listado e historial de códigos OTP de acceso temporal",
                    "permission": "equipo.gestionar",
                    "query": {
                        "filtro": "activos|usados|caducados|todos (opcional)"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/codigos_acceso\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Genera un OTP de 4 dígitos asignado a un usuario",
                    "permission": "equipo.gestionar",
                    "body": {
                        "cod_usuario_asignado": "int",
                        "duracion_minutos": "1-180",
                        "reutilizable": "bool opcional (default false = un solo uso)"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/codigos_acceso\/anular",
                    "auth": "bearer+permiso",
                    "summary": "Anula un código OTP vigente (no usado)",
                    "permission": "equipo.gestionar",
                    "body": {
                        "id": "int"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/codigos_acceso\/canjear",
                    "auth": "bearer",
                    "summary": "Canjea un OTP asignado al usuario autenticado y otorga elevación temporal",
                    "permission": "autenticado",
                    "body": {
                        "codigo": "4 dígitos"
                    },
                    "notes": "Por defecto un solo uso; si el código es reutilizable se puede canjear hasta que expire. Rate limit: bloqueo tras 8 intentos fallidos (10 min). Devuelve elevacion_token para header X-Acceso-Otp."
                },
                {
                    "method": "GET",
                    "path": "\/codigos_acceso\/elevacion",
                    "auth": "bearer",
                    "summary": "Consulta si el usuario tiene elevación OTP vigente",
                    "permission": "autenticado",
                    "notes": "Acepta header X-Acceso-Otp. ADMIN siempre elevado=true."
                }
            ]
        },
        {
            "id": "radio",
            "title": "Radio",
            "endpoints": [
                {
                    "method": "POST",
                    "path": "\/radio\/join",
                    "auth": "bearer",
                    "summary": "Une la sesión a la radio PTT (puente HTTPS → Node \/radio-http)",
                    "permission": "autenticado",
                    "body": {
                        "channel": "TODOS (fijo; destino es usuario)",
                        "nombre": "opcional",
                        "rol": "opcional",
                        "foto": "URL foto perfil opcional (presencia)",
                        "resumeTalk": "1 solo si el cliente aún tiene el PTT pulsado (rejoin del hablante)"
                    },
                    "notes": "Devuelve online[] y reclaimedTalk solo si resumeTalk=1. Varias sesiones de escucha del mismo user son válidas (no se matan al join). Solo al reclamar PTT (resumeTalk) se desalojan hermanas. Lock propio idle >3s se limpia."
                },
                {
                    "method": "POST",
                    "path": "\/radio\/request_talk",
                    "auth": "bearer",
                    "summary": "Solicita el turno PTT",
                    "body": {
                        "sessionId": "string",
                        "targetUserId": "0=Todos u otro userId online"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/radio\/release_talk",
                    "auth": "bearer",
                    "summary": "Libera el turno PTT",
                    "body": {
                        "sessionId": "string"
                    },
                    "notes": "Tras liberar, el servidor acepta ~2.8s de audio HTTP\/WS rezagado (lateAudio) para no cortar el final de la frase."
                },
                {
                    "method": "POST",
                    "path": "\/radio\/audio",
                    "auth": "bearer",
                    "summary": "Reenvía audio efímero: segmentos WebM independientes (preferido) o Opus stream\/PCM",
                    "body": {
                        "sessionId": "string",
                        "mime": "audio\/webm;codecs=opus | audio\/pcm;rate=16000;…",
                        "audio": "base64",
                        "rate": "0 (webm\/opus) o Hz (PCM)",
                        "seq": "int secuencia",
                        "stream": "opus solo si MediaRecorder timeslice continuo (MSE); omitir en segmentos stop\/start"
                    },
                    "notes": "Cliente v30: no mata sesiones hermanas al join (evita join wars); RELEASE espera POSTs en vuelo; RX mantiene pipeline ~3s tras STOPPED; servidor lateAudio + accept por userId. Idle lock 7s."
                },
                {
                    "method": "POST",
                    "path": "\/radio\/leave",
                    "auth": "bearer",
                    "summary": "Sale del canal de radio",
                    "body": {
                        "sessionId": "string"
                    }
                },
                {
                    "method": "GET",
                    "path": "\/radio\/poll",
                    "auth": "bearer",
                    "summary": "Long-poll de eventos de radio (hablando\/audio\/liberación)",
                    "query": {
                        "sessionId": "id de \/radio\/join",
                        "wait": "ms hasta 25000"
                    }
                }
            ]
        },
        {
            "id": "mis-sucursales",
            "title": "Mis-sucursales",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/mis-sucursales",
                    "auth": "bearer",
                    "summary": "Listar mis-sucursales"
                }
            ]
        },
        {
            "id": "pruebas",
            "title": "Pruebas",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/pruebas\/resumen",
                    "auth": "bearer",
                    "summary": "GET \/pruebas\/resumen"
                },
                {
                    "method": "POST",
                    "path": "\/pruebas\/reset",
                    "auth": "bearer",
                    "summary": "POST \/pruebas\/reset"
                }
            ]
        },
        {
            "id": "menaje",
            "title": "Menaje",
            "endpoints": [
                {
                    "method": "GET",
                    "path": "\/menaje\/tablero",
                    "auth": "bearer+permiso",
                    "summary": "Tablero de inventario de menaje (vajilla\/utensilios; aislado de lista de compras)",
                    "permission": "menaje.ver",
                    "query": {
                        "q": "búsqueda nombre\/código",
                        "categoria_id": "int opcional",
                        "area_id": "int opcional",
                        "estado": "activos|inactivos|completo|faltantes|danados|reposicion",
                        "solo_reposicion": "1|0"
                    },
                    "response": "tablero { resumen, grupos[], categorias, areas, unidades, total_activos }",
                    "notes": "No usa tablas de insumos\/compras. KPIs: artículos, disponibles, dañados, faltantes, reposición. Cada artículo incluye estados[] (completo\/faltantes\/danados\/reposicion\/sin_conteo), cantidades y labels. Crea catálogos seed y backfill de permisos menaje.* la primera vez."
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/articulos\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crea artículo físico de menaje",
                    "permission": "menaje.ver",
                    "body": {
                        "nombre": "string requerido",
                        "codigo": "string opcional único por sucursal",
                        "categoria_id": "int requerido",
                        "area_id": "int requerido",
                        "unidad": "unidad|juego|docena|caja|paquete|otro",
                        "cantidad_esperada": "decimal > 0",
                        "cantidad_minima": "decimal <= esperada",
                        "costo_referencial": "decimal USD opcional",
                        "estado": "A|I",
                        "observacion": "string"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/articulos\/actualizar",
                    "auth": "bearer+permiso",
                    "summary": "Actualiza artículo de menaje",
                    "permission": "menaje.ver",
                    "body": {
                        "id": "int",
                        "0": "…mismos campos que crear"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/articulos\/desactivar",
                    "auth": "bearer+permiso",
                    "summary": "Desactiva (soft) un artículo de menaje",
                    "permission": "menaje.ver",
                    "body": {
                        "id": "int"
                    }
                },
                {
                    "method": "GET",
                    "path": "\/menaje\/articulos\/detalle",
                    "auth": "bearer+permiso",
                    "summary": "Detalle de un artículo de menaje",
                    "permission": "menaje.ver",
                    "query": {
                        "id": "int"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/categorias\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crea categoría de menaje",
                    "permission": "menaje.ver",
                    "body": {
                        "nombre": "string"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/areas\/crear",
                    "auth": "bearer+permiso",
                    "summary": "Crea área\/ubicación de menaje",
                    "permission": "menaje.ver",
                    "body": {
                        "nombre": "string"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/conteos\/guardar",
                    "auth": "bearer+permiso",
                    "summary": "Guarda conteo físico (lote) con historial",
                    "permission": "menaje.ver",
                    "body": {
                        "items": "[{ articulo_id, cantidad_disponible, cantidad_danada, observacion? }]",
                        "observacion": "string opcional general"
                    },
                    "notes": "Transacción. Faltante = max(0, esperada - disponible - dañada); sobrante si disponible+dañada > esperada. Inserta app_menaje_conteos + movimiento tipo conteo. No sobrescribe conteos previos."
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/movimientos\/registrar",
                    "auth": "bearer+permiso",
                    "summary": "Registra ingreso o baja de menaje (actualiza existencias + historial)",
                    "permission": "menaje.ver",
                    "body": {
                        "articulo_id": "int",
                        "tipo_movimiento": "ingreso|compra|baja_dano|danado|roto|baja_perdida|perdido|baja_desecho|desechado|baja_transferencia|ajuste|correccion",
                        "cantidad": "decimal > 0",
                        "motivo": "string",
                        "costo": "decimal opcional",
                        "observacion": "string"
                    }
                },
                {
                    "method": "POST",
                    "path": "\/menaje\/reposicion\/marcar",
                    "auth": "bearer+permiso",
                    "summary": "Marca\/desmarca artículo en lista de reposición de menaje (no mezcla con compras de alimentos)",
                    "permission": "menaje.ver",
                    "body": {
                        "id": "int",
                        "en_reposicion": "bool"
                    }
                },
                {
                    "method": "GET",
                    "path": "\/menaje\/reposicion",
                    "auth": "bearer+permiso",
                    "summary": "Lista de reposición de menaje",
                    "permission": "menaje.ver",
                    "response": "articulos[] con cantidad_sugerida_compra"
                },
                {
                    "method": "GET",
                    "path": "\/menaje\/historial",
                    "auth": "bearer+permiso",
                    "summary": "Historial de conteos y movimientos de un artículo",
                    "permission": "menaje.ver",
                    "query": {
                        "articulo_id": "int"
                    }
                }
            ]
        }
    ],
    "routeCount": 276
}