Saltar al contenido principal

app-vdf · Distribución Vodafone

App interna de gestión Vodafone multi-distribuidor. Reescritura del legacy PHP 5.4 + MySQL alojado en FTP. Sustituye también al backend distribuidores-b2b-app (DEPRECADO 2026-04-25).

Datos básicos

CampoValor
Dominio principalapp.vdf.matizal.com
Aliasespenta.matizal.com, gmc.matizal.com, dev.distribucionvdf.matizal.com
Subdominios "absorbidos"distribucionvdf.matizal.com (era el viejo backend)
RepoPedroFenixia/app-vdf
Path local/Users/pedrosanchez/DEV/app-vdf
VPS91.134.43.229
Puerto interno:8950 (nginx) → :9000 (php-fpm)
Container app-vdf-appimagen app-vdf:latest
Container app-vdf-pg-prodpostgres 16-alpine
Bind mount/opt/app-vdf/public/app/public
EstadoProducción

Stack

  • Backend: Laravel 13 + PHP 8.3
  • Frontend: Inertia.js + React 19 + TypeScript + Tailwind 4 + Vite
  • BD: PostgreSQL 16 (single-DB multi-tenant)
  • Auth web: Laravel session + spatie/permission con teams
  • Auth API: Laravel Sanctum (Personal Access Tokens con abilities)
  • Tests: Pest (PHPUnit) Feature + Unit; Playwright E2E (en repo separado)
  • Auditoría: trait Auditable propio (no spatie)

Arquitectura

Multi-tenant single-DB

Todos los distribuidores comparten una única BD app_vdf. Cada fila relevante lleva tenant_id. La separación se aplica automáticamente vía global scope.

Resolución del tenant por dominio:

App\Http\Middleware\ResolveTenant (app/Http/Middleware/ResolveTenant.php):

  1. Intenta Tenant::resolveByDomain($host) con el Host HTTP entrante.
  2. Si el usuario es superadmin:
    • Si sesión viewing_master=true → fuerza tenant master (Tenant::master()).
    • Si sesión selected_tenant_id está definido → usa ese tenant.
    • Si no resolvió por dominio → fallback al master.
  3. Almacena el tenant resuelto como binding global: app()->instance(Tenant::class, $tenant).

Los Eloquent global scopes leen app(Tenant::class) para filtrar consultas.

Master tenant pattern (sharedFromMaster)

Existe un tenant Vodafone (master) del que el resto hereda catálogos comunes. Algunos modelos opt-in al fallback al master mediante la propiedad protected static bool $sharedFromMaster = true.

Modelos con sharedFromMaster:

  • Producto, Promocion, GrupoPromocion, PromocionGrupoAsignacion
  • ComisionTarifaInterna, ProductoMermaRegla
  • TipoComision, TipoDescuento, Familia

Lógica (app/Models/Concerns/BelongsToTenant.php):

  • Si modelo tiene sharedFromMaster = true y el tenant actual NO es master:
    WHERE tabla.tenant_id IN (current_tenant_id, master_tenant_id)
  • Si es master o el modelo no usa la flag:
    WHERE tabla.tenant_id = current_tenant_id

Cada tenant puede sobreescribir entradas concretas creando su propia fila con su tenant_id. El método isFromMaster() permite distinguir si el registro es heredado.

Tenants registrados

IDSlugNombreDominioNotas
1pentaPentapenta.matizal.comdistribuidor activo
2penta-localPenta (local)localhostdev local
3penta-testPenta (test)127.0.0.1tests
4grupomovilGrupomovilgmc.matizal.comdistribuidor activo
5adevozAdevozactivo, sin dominio aún
6sconecta2Sconecta2activo, sin dominio aún
7agentisAgentisactivo, sin dominio aún
8vodafone-masterVodafone (master)app.vdf.matizal.commaster, herencia
9pruebasPruebas (dev)dev.distribucionvdf.matizal.comdatos demo

Sistema de permisos

spatie/laravel-permission con teams habilitado:

  • config/permission.php: 'teams' => true, 'team_foreign_key' => 'tenant_id'
  • Tabla roles lleva tenant_id (cada rol está scoped al tenant)
  • Cache 24h

18 permisos seedeados en DatabaseSeeder:

users.view / users.manage
roles.view / roles.manage
comerciales.view / comerciales.manage
supervisores.view / supervisores.manage
sfids.view / sfids.manage
stvs.view / stvs.manage
productos.view / productos.manage
tipos_producto.view / tipos_producto.manage
promociones.view / promociones.manage
ofertas.view / ofertas.manage
conciliaciones.view / conciliaciones.manage
reglas.view / reglas.manage
auto_codes.view / auto_codes.manage
sugerencias_ia.view / sugerencias_ia.manage
clientes.view / clientes.manage
comisiones.view / comisiones.manage
apoyos.view / apoyos.manage
gastos.view / gastos.manage

4 roles por tenant:

RolPermisos clave
adminTodos
comercialclientes, productos (view), ofertas, apoyos, gastos
jefe-equipocomerciales, sfids, ofertas, conciliaciones, sugerencias
stv-usuariolectura: comerciales, sfids, clientes, productos, ofertas, conciliaciones

Layout de carpetas

app/
├── Http/
│ ├── Controllers/
│ │ ├── Api/
│ │ │ ├── V1Controller.php # endpoints REST v1
│ │ │ ├── CliController.php # CLI conciliador + Callidus
│ │ │ └── TokenController.php # emisión Sanctum
│ │ ├── ComisionesController.php
│ │ ├── ActivacionesCallidusController.php
│ │ ├── ConciliacionController.php
│ │ ├── OfertaController.php, ClienteController.php, ...
│ └── Middleware/
│ ├── ResolveTenant.php # web
│ └── ResolveTenantApi.php # API
├── Models/
│ ├── Concerns/
│ │ ├── BelongsToTenant.php # global scope + master fallback
│ │ └── Auditable.php # auditoría automática
│ ├── Tenant.php, User.php
│ ├── Producto.php, Promocion.php, ...
│ └── ...
├── Providers/
│ └── AppServiceProvider.php # Sanctum, X-Forwarded-Proto
└── Services/
├── CalculadoraComision.php
├── ComisionesImporter.php
├── ComisionesDevengador.php
└── Importers/
├── CallidusXlsxImporter.php
└── OfertaRedChannelImporter.php

Modelo de datos

Tablas principales

TablaFilas notablesRelaciones
tenantsid, slug, name, domain, active, is_master
usersid, tenant_id, name, email, password, active, is_superadmintenant
productosid, tenant_id, codigo, nombre, tipo_producto_id, familia_id, tipo_comision_id, activefamilia, tipoComision, equivalencias, reglasMerma
promocionesid, tenant_id, codigo, descripcion, activeasignaciones (vigentes a grupo)
grupos_promocionid, tenant_id, nombre, activeasignaciones
promocion_grupo_asignacionesid, tenant_id, promocion_id, grupo_promocion_id, vigente_desde, vigente_hasta, activepromocion, grupoPromocion
comisiones_tarifas_internasid, tenant_id, producto_id, grupo_promocion_id (nullable), importe_distribuidor, vigente_desde, vigente_hasta, activeproducto, grupoPromocion
productos_merma_reglasid, tenant_id, producto_id, tipo_descuento_id, porcentaje_merma, vigente_desde, vigente_hasta, activeproducto, tipoDescuento
tipos_comision, tipos_descuento, familiasid, tenant_id, codigo/nombre, activeproductos
clientesid, tenant_id, cif, nombre, comercial_id, dirección, etc.comercial, contactos
comercialesid, tenant_id, user_id, nif, nombre, captacion, supervisor_id, stv_id, dimension_nav, fecha_inicio, fecha_fin, activesupervisor, stv, sfids
sfidsid, tenant_id, codigo, titular_nombre/nif, comercial_id, fecha_inicio, fecha_fin, activecomercial
ofertasid, tenant_id, numero, cliente_id, comercial_id, sfid_id, tipo_descuento_id, estado, fecha, fecha_validez, subtotal, descuento_total, totalcliente, comercial, sfid, tipoDescuento, lineas
oferta_lineasid, tenant_id, oferta_id, producto_id, promocion_id, tarifa_id, descripcion, cantidad, precio_unitario, descuento_porcentaje, subtotal, comision_upfront, merma_porcentaje, merma_importe, comision_netaoferta, producto, promocion, tarifa
comisiones_devengadasid, tenant_id, oferta_id, oferta_linea_id, comercial_id, sfid_id, cliente_id, codigo_producto, codigo_promocion, importe_distribuidor_bruto, importe_distribuidor, importe_comercial, pct_reparto_aplicado, estado, fecha_devengooferta, comercial, sfid
comisiones_liquidacionesid, tenant_id, mes, fecha_carga, fichero_origen, total_lineas, total_importe, imported_bylineas
comisiones_liquidaciones_lineasid, liquidacion_id, sfid_codigo, cif_cliente, msisdn, importe, importe_comercial, estadoliquidacion
activaciones_callidus_batchesid, tenant_id, user_id, archivo_nombre, periodo, cif_distribuidor, filas_origen, filas_importadas, filas_filtradas, headers_detectadosuser, activaciones
activaciones_callidusid, tenant_id, batch_id, sfid, cif, msisdn, fecha_activacion, codigo_producto, codigo_promocion, importe_comision, raw_payload, oferta_linea_id, estado_conciliacionbatch, ofertaLinea
runsid, tenant_id, started_at, finished_at, mode, clients_processed, matches_applied, errors, log_excerpt, status, source_hostconciliations, aiSuggestions
conciliationsid, tenant_id, run_id, fecha_hora, cif, producto, promocion, msisdn, accion, resultado, raw_linerun
rulesid, tenant_id, name, type, params, priority, active, description, created_by
tenant_api_tokensid, tenant_id, name, token, last_used_attenant
audit_logsid, tenant_id, user_id, user_email, model_type, model_id, event, old_values, new_values, ip, user_agent, created_atuser, tenant

Reglas de integridad relevantes

  • SFID 1:1 vigente por comercial: migration 2026_04_29_000000_enforce_one_sfid_vigente_por_comercial. Cierre automático del SFID anterior al asignar uno nuevo (fecha_fin = today).
  • Tarifa vigente por (producto, grupo, fecha): rango [vigente_desde, vigente_hasta]. vigente_hasta NULL significa abierto.
  • Asignación promo→grupo vigente sigue el mismo patrón.

Rutas

Web (routes/web.php) — middleware auth

Dashboard

MétodoURIController
GET/dashboardDashboardController@index

Superadmin

MétodoURIAcción
POST/master/toggletogglea viewing_master en sesión
POST/tenant/switchcambia selected_tenant_id

API Tokens (UI)

  • GET /api-tokens, POST /api-tokens, DELETE /api-tokens/{token}ApiTokenController

Admin

  • users.*, roles.*

Maestros

  • comerciales.*, supervisores.*, sfids.*, stvs.*, familias.*, tipos-comision.*, tipos-descuento.*, tipos-producto.*, reglas-merma.*

Productos

  • CRUD productos.*
  • Import: productos.import.{form,preview,apply}
  • Bulk assign: productos.bulk-assign-{tipo,familia,tipo-comision}

Promociones

  • CRUD promociones.*
  • Import: promociones.import.{form,preview,apply}

Grupos promoción

  • CRUD grupos-promocion.*
  • POST /grupos-promocion/{gp}/asignar — asigna promoción a grupo
  • POST /grupos-promocion/{gp}/asignaciones/{a}/cerrar — cierra asignación vigente

Clientes

  • CRUD clientes.*

Ofertas

  • CRUD ofertas.*
  • POST /ofertas/import-redchannelImportOfertaController

Apoyos y Gastos

  • apoyos.*, gastos.*

Conciliación

  • reglas.*, auto-codes.*
  • conciliaciones.{index,show} — listado/detalle de runs
  • sugerencias.{index,review} — sugerencias IA del CLI

Comisiones (/comisiones/*)

URIAcción
GET /comisionesDashboard
GET /comisiones/liquidacioneslistado
GET POST /comisiones/liquidaciones/uploadsubida Excel mensual
GET /comisiones/liquidaciones/{liq}detalle
DELETE /comisiones/liquidaciones/{liq}eliminar
GET /comisiones/tarifaslistado paginado
GET /comisiones/tarifas/matrizmatriz producto × grupo
GET POST /comisiones/tarifas/importimport por filas
GET POST /comisiones/tarifas/import-tablaimport por tabla pivot
GET POST PUT DELETE /comisiones/tarifas/{tarifa?}CRUD
GET /comisiones/devengadascomisiones generadas (lectura)
GET /comisiones/activacioneslistado activaciones Callidus
GET POST /comisiones/activaciones/uploadsubida xlsx
DELETE /comisiones/activaciones/batches/{batch}borrar batch

Auditoría

  • GET /audit-log — solo superadmin

CLI (routes/api.php) — auth X-Ingest-Token

MétodoURIAcción
GET/api/cli/{slug}/rulesreglas + auto-codes (consumido por CLI conciliador)
POST/api/cli/{slug}/ingestingest run conciliador (Run + Conciliations + AiSuggestions)
POST/api/cli/{slug}/callidus/ingestsubida xlsx desde scripts Python
GET/api/cli/{slug}/callidus/healthcheckúltimo batch + conteo 30d

REST v1 (routes/api.php) — auth Sanctum

Base: https://{dominio}/api/v1

Sin ability requerida

MétodoURIThrottle
POST/tokens5/min por IP
GET/me120/min
GET/tokens120/min
DELETE/tokens/current120/min

Read (read o *)

URINotas
GET /productosfiltros q, only_active, paginado
GET /promociones+ asignacion vigente eager-loaded
GET /grupos-promocion+ conteo de promociones
GET /clientes, GET /clientes/{id}filtros q, only_active
GET /comerciales+ user eager
GET /sfidsfiltro q
GET /familiasarray completo
GET /tipos-comisionarray completo
GET /tipos-descuentoarray completo
GET /comisiones/tarifasfiltros producto_id, grupo_id, vigentes
GET /ofertas, GET /ofertas/{id}filtros q, desde, hasta
POST /comisiones/calcularcalculadora sin persistir

Write (write o *)

URIAcción
POST /clientescrea (cif único por tenant)
PUT/PATCH /clientes/{id}actualiza
DELETE /clientes/{id}borra
POST /ofertascrea + líneas con cálculo automático
PUT/PATCH /ofertas/{id}actualiza
DELETE /ofertas/{id}borra

Errores estandarizados: 401 (token inválido), 403 (ability/tenant_mismatch), 422 (validación / tenant_not_resolved), 429 (rate limit).

Multi-tenant en API:

  • Por dominio (automático)
  • Header X-Tenant-Slug (solo superadmin)
  • Fallback a tenant_id del usuario

Calculadora de comisiones

App\Services\CalculadoraComision::calcular(...).

Firma

calcular(
int $tenantId,
int $productoId,
?int $promocionId,
?int $tipoDescuentoId,
string $fecha, // Y-m-d
): array

Lógica

  1. Buscar tarifa vigente (buscarTarifa):

    • Si hay promocionId: busca PromocionGrupoAsignacion vigente en fecha. Si existe → tarifa(producto, grupo, fecha). Si no hay grupo o no hay tarifa → fallback a tarifa(producto, NULL, fecha) ("comisión única").
    • Si no hay promo: tarifa(producto, NULL, fecha).
    • Filtro vigencia: vigente_desde <= fecha AND (vigente_hasta IS NULL OR vigente_hasta >= fecha).
  2. Upfront = tarifa.importe_distribuidor (o 0 si no hay tarifa).

  3. Merma (solo si upfront > 0 y hay tipoDescuentoId):

    • Busca ProductoMermaRegla vigente para (producto, tipo_descuento, fecha).
    • merma_pct = regla?.porcentaje_merma ?? 0
    • merma_importe = round(upfront * merma_pct / 100, 2)
  4. Neta = round(upfront - merma_importe, 2).

Salida

[
'tarifa_id' => ?int,
'comision_upfront' => float,
'merma_porcentaje' => float,
'merma_importe' => float,
'comision_neta' => float,
]

Invocación automática

OfertaLinea::booted() engancha el evento saving: cualquier alta/edición de línea recalcula comision_upfront/merma_porcentaje/merma_importe/comision_neta y persiste tarifa_id.

Importers

CallidusXlsxImporter

Input: informe .xlsx de Callidus NMC Vodafone.

Estructura del Excel:

  • Filas 1-7: metadata (título, periodo, distribuidor)
  • Header dinámico: el importer busca "SFID" entre filas 1-15
  • Datos: a partir de la fila siguiente al header

Mapeo de columnas (COLUMN_MAP): SFID (obligatorio CCE/CDF), CIF, MSISDN, Fecha Activación, Código Producto, Código Promoción, Importe Comisión. Match case-insensitive con normalización de espacios y acentos.

Validaciones:

  • Filtra filas vacías
  • Filtra SFID que NO empiezan por CCE o CDF (regex /^(CCE|CDF)/i)
  • Normaliza fecha (serial Excel → Y-m-d)
  • Normaliza importe (limpia €, espacios, comas decimales)

Persistencia:

  • 1 ActivacionCallidusBatch por archivo
  • N ActivacionCallidus por batch (insert por lotes de 500, en transacción)

Métodos públicos:

  • parse(string $path): array — sin persistir, devuelve estructura
  • importar(string $path, string $filename, ?int $userId): ActivacionCallidusBatch

ComisionesImporter

Input: Excel mensual Vodafone "Informe Comisiones por No Teléfono" (25 columnas B..Y).

Columnas: ID Proveedor, SFID, Tipo SFID, Nombre SFID, Código Transacción, Orden, Tipo Evento, Tipo Comisión, Nº Fiscal, ID Cliente, Nº Teléfono, Plan Precios, Descripción Producto, Promoción, Fecha Evento, Fecha Alta, Nº IMEI, % Pago Alta, Observaciones, Anexo, Importe, Informe Actividad, Pagos MSISDN, Pagos CIF.

Validaciones:

  • Detecta fila header (contiene "SFID" o "ID Proveedor")
  • SFID y CIF no vacíos
  • Resuelve comercial_id por sfids.codigo
  • Resuelve cliente_id por clientes.cif

Persistencia:

  • 1 ComisionLiquidacion por mes
  • N ComisionLiquidacionLinea por liquidación

OfertaRedChannelImporter

Importa ofertas desde catálogo Red Channel (similar patrón).

Auditoría

Trait App\Models\Concerns\Auditable. Engancha eventos Eloquent:

EventoPersiste
createdevent=created, new_values = atributos
updatedevent=updated, old_values = previos, new_values = getChanges() (excluye updated_at y campos en auditIgnore)
deletedevent=deleted, old_values = atributos

Datos guardados en audit_logs: tenant_id, user_id, user_email, model_type, model_id, event, old_values/new_values (JSON saneado: excluye password, remember_token, api_token), ip, user_agent, created_at.

Modelos auditados: Producto, Promocion, GrupoPromocion, PromocionGrupoAsignacion, ComisionTarifaInterna, TipoComision, TipoDescuento, Familia, Cliente, Comercial, ProductoMermaRegla, OfertaLinea, Oferta.

Control:

  • Auditable::$auditDisabled = true — desactivación global
  • Auditable::withoutAuditing(fn () => …) — desactivación temporal (útil en imports masivos)
  • AuditLog::logImport(modelType, meta) — registra import como evento especial

Consulta: /audit-log (solo superadmin) — AuditLogController@index.

Frontend

Estructura resources/js/:

Pages/
├── Dashboard.tsx
├── Auth/Login.tsx
├── Comerciales/{Index,Edit}.tsx
├── Productos/{Index,Edit,Import}.tsx
├── Promociones/{Index,Edit,Import}.tsx
├── Clientes/{Index,Edit}.tsx
├── Ofertas/{Index,Edit,Create}.tsx
├── Comisiones/
│ ├── Dashboard.tsx
│ ├── TarifasIndex.tsx · TarifaEdit.tsx · TarifasMatriz.tsx · TarifasImportTabla.tsx
│ ├── LiquidacionesIndex.tsx · LiquidacionUpload.tsx · LiquidacionShow.tsx
│ ├── DevengadasIndex.tsx
│ └── ActivacionesCallidus/{Index,Upload}.tsx
├── Conciliaciones/{Index,Show}.tsx
├── Reglas/, Roles/, Usuarios/, Supervisores/, SFIDs/, STVs/, …
Layouts/
└── AppLayout.tsx # navegación, tenant switcher, user menu, permisos
components/ # compartidos (paginación, modales, etc.)
hooks/ # custom hooks

Convenciones:

  • Paginación unificada (componente Pagination + PerPageSelector, dropdown 25/50/100/200, query string ?per_page=N)
  • Sidebar colapsable persistido en localStorage
  • Modos light/dark vía clase light en <html>
  • Acento Matizal: rojo #ef4444

Configuración

.env relevantes

APP_URL=https://app.vdf.matizal.com
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=app_vdf
DB_USERNAME=app_vdf
DB_PASSWORD=...

AppServiceProvider

  • Registra PersonalAccessToken propio para Sanctum
  • Fuerza HTTPS si APP_URL empieza por https://
  • Confía en proxy nginx (X-Forwarded-Proto)

config/permission.php

  • teams: true
  • team_foreign_key: tenant_id
  • Cache 24h

Tests

CarpetaContenido
tests/Feature/ApiV1Test.php11 tests sobre REST v1: auth, abilities, productos, calcular comisión, clientes/ofertas
tests/Feature/ExampleTest.phpsmoke
tests/Unit/unit tests (cobertura básica de calculadora, importers)
playwright/* (repo separado)45/45 specs E2E: login, productos, ofertas, comisiones, conciliaciones

Ejecutar:

docker compose exec app php artisan test
docker compose exec app php artisan test --filter ApiV1Test

Migrations relevantes (orden)

2026_04_25_112344 add_user_id_to_maestros
2026_04_25_123451 create_promociones_table
2026_04_25_124030 create_clientes_table
2026_04_25_125352 create_ofertas_table
2026_04_25_141031 create_comisiones_tables
2026_04_25_141403 add_sfid_id_to_ofertas
2026_04_25_141655 create_comisiones_tarifas_devengadas
2026_04_25_175137 refactor_oferta_quitar_precios_y_licencias
2026_04_25_175446 create_producto_equivalencias
2026_04_25_181859 create_apoyos_gastos
2026_04_26_151532 add_master_and_superadmin_flags
2026_04_26_151712 migrate_productos_promociones_to_master
2026_04_26_232827 drop_precio_from_productos
2026_04_26_233359 create_familias_tipos_comision_tables
2026_04_26_235615 drop_unused_columns_from_promociones
2026_04_26_235837 create_grupos_promocion_and_link_promociones
2026_04_27_000415 create_tipos_descuento_y_reglas_merma
2026_04_27_001324 simplify_reglas_merma_drop_tipos_y_grupo
2026_04_27_002933 create_promocion_grupo_asignaciones
2026_04_27_003941 refactor_comisiones_tarifas_internas_para_grupos
2026_04_27_004705 add_descuento_a_ofertas_y_calculos_a_lineas
2026_04_27_005756 drop_nombre_de_promociones
2026_04_27_013728 drop_pct_reparto_comercial_de_tarifas
2026_04_27_224211 drop_familia_y_tipo_comision_string_de_productos
2026_04_28_175311 create_audit_logs
2026_04_28_183955 create_personal_access_tokens_table
2026_04_28_220000 drop_descripcion_from_maestros
2026_04_28_223000 add_fechas_y_active_to_stvs
2026_04_29_000000 enforce_one_sfid_vigente_por_comercial
2026_04_30_000000 create_activaciones_callidus

Seeders

  • DatabaseSeeder — crea tenants, permisos, roles, usuarios admin
  • ConciliacionRulesSeeder — 3 reglas base por tenant
  • DemoDataSeeder — datos demo (solo local/testing)
  • Maestros: FamiliasSeeder, TiposComisionSeeder, TiposDescuentoSeeder, TiposProductoSeeder, MaestrosSeeder
  • Catálogo: GruposPromocionSeeder, PromocionesSeeder, ProductosSeeder, RedChannelCatalogoSeeder
  • RRHH: ComercialesSeeder, SupervisoresSeeder, SfidsSeeder, StvsSeeder
  • Negocio: ClientesSeeder, ComisionesTarifasSeeder, ProductosMermaReglasSeeder, OfertasSeeder, ApoyosSeeder, GastosSeeder

Activaciones Callidus (subsistema)

Container externo callidus-cron en VPS (/opt/callidus, Selenium + Chrome + cron):

scripts/callidus/
├── .env.example
├── Dockerfile
├── docker-compose.yml
├── crontab
├── callidus_penta_mes_actual.py
├── callidus_penta_mes_anterior.py
├── callidus_gm_mes_actual.py
├── callidus_gm_mes_anterior.py
├── upload_to_appvdf.py # POST /api/cli/{slug}/callidus/ingest
└── run-script.sh

Schedule: lunes 07:00 (penta), 07:30 (grupomovil).

Healthcheck: routine remota Anthropic Cloud lunes 09:00 consulta /api/cli/{slug}/callidus/healthcheck. Si ambos tenants tienen batches recientes (35d), abre issue en PedroFenixia/app-vdf para actualizar el mapa. Idempotente.

Cómo se levanta en local

cd /Users/pedrosanchez/DEV/app-vdf
docker compose up -d
composer install
npm install
php artisan key:generate
php artisan migrate --seed
npm run dev
  • App Laravel: http://localhost:8950
  • Vite dev: http://localhost:5173
  • Postgres expuesto en localhost:5435

Deploy

⚠️ Nunca docker compose build en VPS (regla del ecosistema).

Backend (Laravel)

  1. Cambios en local (modelos, migraciones, controllers).
  2. Build de imagen en local apuntando a amd64:
    docker buildx build --platform linux/amd64 --load -t app-vdf:latest .
  3. Guardar y subir:
    docker save app-vdf:latest | ssh debian@91.134.43.229 'docker load'
  4. En VPS: cd /opt/app-vdf && docker compose -f docker-compose.prod.yml up -d
  5. Migraciones (con snapshot previo SIEMPRE):
    docker exec app-vdf-pg-prod pg_dump -U app_vdf app_vdf > /tmp/snap-$(date +%F-%H%M).sql
    docker exec app-vdf-app php artisan migrate --force

Frontend (Vite)

npm run build
rsync -az --delete public/build/ debian@91.134.43.229:/opt/app-vdf/public/build/

El bind-mount /opt/app-vdf/public → /app/public hace que el rsync baste — sin restart.

Operativa

Crear/revocar tokens API

  • Vía web: /api-tokens (cualquier usuario crea sus propios con permisos y caducidad seleccionables 1-3650d).
  • Programático: POST /api/v1/tokens con email+password+device_name+abilities (5/min anti brute-force).
  • Restricción por dominio para no-superadmin.

Auditar acciones de un usuario

/audit-log con filtros por usuario, modelo, evento, rango de fechas. Solo superadmin.

Importar tarifas mensuales

  1. Recibir Excel pivot (producto × grupo + columna "única").
  2. /comisiones/tarifas/import-tabla → preview → apply.
  3. Tarifas crean/actualizan filas en comisiones_tarifas_internas con vigente_desde del mes.

Subir liquidación mensual

  1. Excel "Informe Comisiones por No Teléfono" del mes.
  2. /comisiones/liquidaciones/upload → seleccionar mes → upload.
  3. Resultado en /comisiones/liquidaciones/{liq} con líneas conciliadas por SFID.

Cerrar SFID antiguo al asignar uno nuevo

Cualquier asignación nueva pone fecha_fin = today al SFID anterior del mismo comercial automáticamente (constraint y trigger en 2026_04_29_000000_enforce_one_sfid_vigente_por_comercial).

Troubleshooting

SíntomaCausa probableSolución
Matriz tarifas vacía pero header "Producto" aparece sin columnasTenant sin productos ni tarifasCambiar al tenant con datos. La UI no muestra "Sin datos" cuando productos.length > 0 y matriz = {} (mejora pendiente).
Activaciones Callidus filtra todas las filasSFID no empieza por CCE/CDFRevisar Excel: el filtro es por diseño
Build Vite no se reflejaCache del navegadorCmd+Shift+R; verificar manifest.json en VPS
500 al subir oferta grandenginx bufferingAsegurar proxy_request_buffering off (incidente 2026-04-26)
Token API rechazado con 403Falta ability o tenant_mismatchVerificar abilities del token y dominio del request
tenant_not_resolved en APIHeader Host incorrectoEn CLI usar dominio del tenant o X-Tenant-Slug (solo superadmin)

Decisiones de diseño

  • Single-DB multi-tenant vs DB-per-tenant: elegido single-DB por menor overhead operativo (un único docker pg, una única migración, backups simples). Compatible con master fallback de catálogos comunes.
  • Master tenant pattern vs duplicar catálogos: elegido master por la naturaleza homogénea del catálogo Vodafone (533 productos comunes). Cada distribuidor sobreescribe lo que necesita; el resto hereda.
  • spatie/permission con teams vs ACL custom: estándar Laravel, scoping per-tenant gratis, gestión via UI sencilla.
  • Auditoría custom (Auditable trait) vs spatie/laravel-activitylog: trait propio para tener control absoluto del JSON serializado y poder excluir secrets sin pelearse con la librería.
  • Sanctum (Personal Access Tokens) vs Passport: API simple sin OAuth, suficiente para integraciones programáticas y Cliente CLI.
  • Inertia + React vs SPA pura: aprovecha controllers Laravel sin construir API duplicada para la UI; type-safe con shared TypeScript.

Glosario

TérminoDefinición
TenantDistribuidor (Penta, Grupomovil…) o "Vodafone master". Cada uno tiene su propio dominio.
MasterTenant vodafone-master que actúa de catálogo común. Otros tenants heredan productos/promos/tarifas/etc.
SFIDIdentificador comercial Vodafone. Empieza por CCE o CDF.
MSISDNNúmero de teléfono activado
Tarifa internaImporte que el distribuidor paga al comercial por activación
UpfrontComisión bruta antes de mermas
MermaPorcentaje retenido (ligado a tipo de descuento)
NetaComisión final (upfront − merma)
DevengadaComisión generada al firmar oferta (en comisiones_devengadas)
LiquidaciónPago mensual recibido de Vodafone (Excel mensual)
Activación CallidusActivación reportada por sistema NMC Vodafone (xlsx descargado por bot Selenium)
RunEjecución del CLI conciliador (matchea liquidación con devengadas, aplica reglas, sugiere AI)
ConciliationLínea individual de un Run (acción + resultado)
Auto-codeCódigo de producto auto-asignado por reglas (cuando Vodafone reporta uno desconocido)
AI SuggestionSugerencia de match generada por Claude Sonnet en runs con --ai-fallback

Histórico

  • 2026-05-02 — Sidebar reordenado: Activaciones Callidus movida del grupo Comisiones al grupo Activaciones. Rediseño visual del sidebar (paleta dedicada slate, item activo con gradiente y barra acento).
  • 2026-04-30create_activaciones_callidus: tablas batch + activaciones.
  • 2026-04-29enforce_one_sfid_vigente_por_comercial: cierre auto al reasignar.
  • 2026-04-28create_audit_logs + create_personal_access_tokens_table. Sanctum operativo.
  • 2026-04-27 — Reorganización catálogo: grupos promoción separados de promociones, asignaciones vigentes con rango, tipos descuento, reglas merma simplificadas.
  • 2026-04-26add_master_and_superadmin_flags. Master tenant pattern operativo. Productos/promociones migrados al master.
  • 2026-04-26 — Incidente: uploads detrás de nginx HOST → activado proxy_request_buffering off.
  • 2026-04-25 — Cutover desde distribuidores-b2b-app. Subdominio distribucionvdf.matizal.com repuntado a app-vdf:8950. LaunchAgent CLI Playwright PAUSADO.