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
| Campo | Valor |
|---|---|
| Dominio principal | app.vdf.matizal.com |
| Aliases | penta.matizal.com, gmc.matizal.com, dev.distribucionvdf.matizal.com |
| Subdominios "absorbidos" | distribucionvdf.matizal.com (era el viejo backend) |
| Repo | PedroFenixia/app-vdf |
| Path local | /Users/pedrosanchez/DEV/app-vdf |
| VPS | 91.134.43.229 |
| Puerto interno | :8950 (nginx) → :9000 (php-fpm) |
Container app-vdf-app | imagen app-vdf:latest |
Container app-vdf-pg-prod | postgres 16-alpine |
| Bind mount | /opt/app-vdf/public → /app/public |
| Estado | Producció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
Auditablepropio (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):
- Intenta
Tenant::resolveByDomain($host)con elHostHTTP entrante. - Si el usuario es superadmin:
- Si sesión
viewing_master=true→ fuerza tenant master (Tenant::master()). - Si sesión
selected_tenant_idestá definido → usa ese tenant. - Si no resolvió por dominio → fallback al master.
- Si sesión
- 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,PromocionGrupoAsignacionComisionTarifaInterna,ProductoMermaReglaTipoComision,TipoDescuento,Familia
Lógica (app/Models/Concerns/BelongsToTenant.php):
- Si modelo tiene
sharedFromMaster = truey 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
| ID | Slug | Nombre | Dominio | Notas |
|---|---|---|---|---|
| 1 | penta | Penta | penta.matizal.com | distribuidor activo |
| 2 | penta-local | Penta (local) | localhost | dev local |
| 3 | penta-test | Penta (test) | 127.0.0.1 | tests |
| 4 | grupomovil | Grupomovil | gmc.matizal.com | distribuidor activo |
| 5 | adevoz | Adevoz | — | activo, sin dominio aún |
| 6 | sconecta2 | Sconecta2 | — | activo, sin dominio aún |
| 7 | agentis | Agentis | — | activo, sin dominio aún |
| 8 | vodafone-master | Vodafone (master) | app.vdf.matizal.com | master, herencia |
| 9 | pruebas | Pruebas (dev) | dev.distribucionvdf.matizal.com | datos demo |
Sistema de permisos
spatie/laravel-permission con teams habilitado:
config/permission.php:'teams' => true,'team_foreign_key' => 'tenant_id'- Tabla
rolesllevatenant_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:
| Rol | Permisos clave |
|---|---|
admin | Todos |
comercial | clientes, productos (view), ofertas, apoyos, gastos |
jefe-equipo | comerciales, sfids, ofertas, conciliaciones, sugerencias |
stv-usuario | lectura: 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
| Tabla | Filas notables | Relaciones |
|---|---|---|
tenants | id, slug, name, domain, active, is_master | — |
users | id, tenant_id, name, email, password, active, is_superadmin | tenant |
productos | id, tenant_id, codigo, nombre, tipo_producto_id, familia_id, tipo_comision_id, active | familia, tipoComision, equivalencias, reglasMerma |
promociones | id, tenant_id, codigo, descripcion, active | asignaciones (vigentes a grupo) |
grupos_promocion | id, tenant_id, nombre, active | asignaciones |
promocion_grupo_asignaciones | id, tenant_id, promocion_id, grupo_promocion_id, vigente_desde, vigente_hasta, active | promocion, grupoPromocion |
comisiones_tarifas_internas | id, tenant_id, producto_id, grupo_promocion_id (nullable), importe_distribuidor, vigente_desde, vigente_hasta, active | producto, grupoPromocion |
productos_merma_reglas | id, tenant_id, producto_id, tipo_descuento_id, porcentaje_merma, vigente_desde, vigente_hasta, active | producto, tipoDescuento |
tipos_comision, tipos_descuento, familias | id, tenant_id, codigo/nombre, active | productos |
clientes | id, tenant_id, cif, nombre, comercial_id, dirección, etc. | comercial, contactos |
comerciales | id, tenant_id, user_id, nif, nombre, captacion, supervisor_id, stv_id, dimension_nav, fecha_inicio, fecha_fin, active | supervisor, stv, sfids |
sfids | id, tenant_id, codigo, titular_nombre/nif, comercial_id, fecha_inicio, fecha_fin, active | comercial |
ofertas | id, tenant_id, numero, cliente_id, comercial_id, sfid_id, tipo_descuento_id, estado, fecha, fecha_validez, subtotal, descuento_total, total | cliente, comercial, sfid, tipoDescuento, lineas |
oferta_lineas | id, tenant_id, oferta_id, producto_id, promocion_id, tarifa_id, descripcion, cantidad, precio_unitario, descuento_porcentaje, subtotal, comision_upfront, merma_porcentaje, merma_importe, comision_neta | oferta, producto, promocion, tarifa |
comisiones_devengadas | id, 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_devengo | oferta, comercial, sfid |
comisiones_liquidaciones | id, tenant_id, mes, fecha_carga, fichero_origen, total_lineas, total_importe, imported_by | lineas |
comisiones_liquidaciones_lineas | id, liquidacion_id, sfid_codigo, cif_cliente, msisdn, importe, importe_comercial, estado | liquidacion |
activaciones_callidus_batches | id, tenant_id, user_id, archivo_nombre, periodo, cif_distribuidor, filas_origen, filas_importadas, filas_filtradas, headers_detectados | user, activaciones |
activaciones_callidus | id, tenant_id, batch_id, sfid, cif, msisdn, fecha_activacion, codigo_producto, codigo_promocion, importe_comision, raw_payload, oferta_linea_id, estado_conciliacion | batch, ofertaLinea |
runs | id, tenant_id, started_at, finished_at, mode, clients_processed, matches_applied, errors, log_excerpt, status, source_host | conciliations, aiSuggestions |
conciliations | id, tenant_id, run_id, fecha_hora, cif, producto, promocion, msisdn, accion, resultado, raw_line | run |
rules | id, tenant_id, name, type, params, priority, active, description, created_by | — |
tenant_api_tokens | id, tenant_id, name, token, last_used_at | tenant |
audit_logs | id, tenant_id, user_id, user_email, model_type, model_id, event, old_values, new_values, ip, user_agent, created_at | user, 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 NULLsignifica abierto. - Asignación promo→grupo vigente sigue el mismo patrón.
Rutas
Web (routes/web.php) — middleware auth
Dashboard
| Método | URI | Controller |
|---|---|---|
| GET | /dashboard | DashboardController@index |
Superadmin
| Método | URI | Acción |
|---|---|---|
| POST | /master/toggle | togglea viewing_master en sesión |
| POST | /tenant/switch | cambia 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 grupoPOST /grupos-promocion/{gp}/asignaciones/{a}/cerrar— cierra asignación vigente
Clientes
- CRUD
clientes.*
Ofertas
- CRUD
ofertas.* POST /ofertas/import-redchannel—ImportOfertaController
Apoyos y Gastos
apoyos.*,gastos.*
Conciliación
reglas.*,auto-codes.*conciliaciones.{index,show}— listado/detalle de runssugerencias.{index,review}— sugerencias IA del CLI
Comisiones (/comisiones/*)
| URI | Acción |
|---|---|
GET /comisiones | Dashboard |
GET /comisiones/liquidaciones | listado |
GET POST /comisiones/liquidaciones/upload | subida Excel mensual |
GET /comisiones/liquidaciones/{liq} | detalle |
DELETE /comisiones/liquidaciones/{liq} | eliminar |
GET /comisiones/tarifas | listado paginado |
GET /comisiones/tarifas/matriz | matriz producto × grupo |
GET POST /comisiones/tarifas/import | import por filas |
GET POST /comisiones/tarifas/import-tabla | import por tabla pivot |
GET POST PUT DELETE /comisiones/tarifas/{tarifa?} | CRUD |
GET /comisiones/devengadas | comisiones generadas (lectura) |
GET /comisiones/activaciones | listado activaciones Callidus |
GET POST /comisiones/activaciones/upload | subida xlsx |
DELETE /comisiones/activaciones/batches/{batch} | borrar batch |
Auditoría
GET /audit-log— solo superadmin
CLI (routes/api.php) — auth X-Ingest-Token
| Método | URI | Acción |
|---|---|---|
| GET | /api/cli/{slug}/rules | reglas + auto-codes (consumido por CLI conciliador) |
| POST | /api/cli/{slug}/ingest | ingest run conciliador (Run + Conciliations + AiSuggestions) |
| POST | /api/cli/{slug}/callidus/ingest | subida 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étodo | URI | Throttle |
|---|---|---|
| POST | /tokens | 5/min por IP |
| GET | /me | 120/min |
| GET | /tokens | 120/min |
| DELETE | /tokens/current | 120/min |
Read (read o *)
| URI | Notas |
|---|---|
GET /productos | filtros 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 /sfids | filtro q |
GET /familias | array completo |
GET /tipos-comision | array completo |
GET /tipos-descuento | array completo |
GET /comisiones/tarifas | filtros producto_id, grupo_id, vigentes |
GET /ofertas, GET /ofertas/{id} | filtros q, desde, hasta |
POST /comisiones/calcular | calculadora sin persistir |
Write (write o *)
| URI | Acción |
|---|---|
POST /clientes | crea (cif único por tenant) |
PUT/PATCH /clientes/{id} | actualiza |
DELETE /clientes/{id} | borra |
POST /ofertas | crea + 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_iddel 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
-
Buscar tarifa vigente (
buscarTarifa):- Si hay
promocionId: buscaPromocionGrupoAsignacionvigente enfecha. 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).
- Si hay
-
Upfront =
tarifa.importe_distribuidor(o 0 si no hay tarifa). -
Merma (solo si
upfront > 0y haytipoDescuentoId):- Busca
ProductoMermaReglavigente para(producto, tipo_descuento, fecha). merma_pct = regla?.porcentaje_merma ?? 0merma_importe = round(upfront * merma_pct / 100, 2)
- Busca
-
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
CCEoCDF(regex/^(CCE|CDF)/i) - Normaliza fecha (serial Excel → Y-m-d)
- Normaliza importe (limpia €, espacios, comas decimales)
Persistencia:
- 1
ActivacionCallidusBatchpor archivo - N
ActivacionCalliduspor batch (insert por lotes de 500, en transacción)
Métodos públicos:
parse(string $path): array— sin persistir, devuelve estructuraimportar(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_idporsfids.codigo - Resuelve
cliente_idporclientes.cif
Persistencia:
- 1
ComisionLiquidacionpor mes - N
ComisionLiquidacionLineapor liquidación
OfertaRedChannelImporter
Importa ofertas desde catálogo Red Channel (similar patrón).
Auditoría
Trait App\Models\Concerns\Auditable. Engancha eventos Eloquent:
| Evento | Persiste |
|---|---|
created | event=created, new_values = atributos |
updated | event=updated, old_values = previos, new_values = getChanges() (excluye updated_at y campos en auditIgnore) |
deleted | event=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 globalAuditable::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
lighten<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
PersonalAccessTokenpropio para Sanctum - Fuerza HTTPS si
APP_URLempieza porhttps:// - Confía en proxy nginx (
X-Forwarded-Proto)
config/permission.php
teams: trueteam_foreign_key: tenant_id- Cache 24h
Tests
| Carpeta | Contenido |
|---|---|
tests/Feature/ApiV1Test.php | 11 tests sobre REST v1: auth, abilities, productos, calcular comisión, clientes/ofertas |
tests/Feature/ExampleTest.php | smoke |
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 adminConciliacionRulesSeeder— 3 reglas base por tenantDemoDataSeeder— 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)
- Cambios en local (modelos, migraciones, controllers).
- Build de imagen en local apuntando a amd64:
docker buildx build --platform linux/amd64 --load -t app-vdf:latest .
- Guardar y subir:
docker save app-vdf:latest | ssh debian@91.134.43.229 'docker load'
- En VPS:
cd /opt/app-vdf && docker compose -f docker-compose.prod.yml up -d - Migraciones (con snapshot previo SIEMPRE):
docker exec app-vdf-pg-prod pg_dump -U app_vdf app_vdf > /tmp/snap-$(date +%F-%H%M).sqldocker 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/tokenscon 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
- Recibir Excel pivot (producto × grupo + columna "única").
/comisiones/tarifas/import-tabla→ preview → apply.- Tarifas crean/actualizan filas en
comisiones_tarifas_internasconvigente_desdedel mes.
Subir liquidación mensual
- Excel "Informe Comisiones por No Teléfono" del mes.
/comisiones/liquidaciones/upload→ seleccionar mes → upload.- 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íntoma | Causa probable | Solución |
|---|---|---|
| Matriz tarifas vacía pero header "Producto" aparece sin columnas | Tenant sin productos ni tarifas | Cambiar al tenant con datos. La UI no muestra "Sin datos" cuando productos.length > 0 y matriz = {} (mejora pendiente). |
| Activaciones Callidus filtra todas las filas | SFID no empieza por CCE/CDF | Revisar Excel: el filtro es por diseño |
| Build Vite no se refleja | Cache del navegador | Cmd+Shift+R; verificar manifest.json en VPS |
| 500 al subir oferta grande | nginx buffering | Asegurar proxy_request_buffering off (incidente 2026-04-26) |
| Token API rechazado con 403 | Falta ability o tenant_mismatch | Verificar abilities del token y dominio del request |
tenant_not_resolved en API | Header Host incorrecto | En 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 (
Auditabletrait) 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érmino | Definición |
|---|---|
| Tenant | Distribuidor (Penta, Grupomovil…) o "Vodafone master". Cada uno tiene su propio dominio. |
| Master | Tenant vodafone-master que actúa de catálogo común. Otros tenants heredan productos/promos/tarifas/etc. |
| SFID | Identificador comercial Vodafone. Empieza por CCE o CDF. |
| MSISDN | Número de teléfono activado |
| Tarifa interna | Importe que el distribuidor paga al comercial por activación |
| Upfront | Comisión bruta antes de mermas |
| Merma | Porcentaje retenido (ligado a tipo de descuento) |
| Neta | Comisión final (upfront − merma) |
| Devengada | Comisión generada al firmar oferta (en comisiones_devengadas) |
| Liquidación | Pago mensual recibido de Vodafone (Excel mensual) |
| Activación Callidus | Activación reportada por sistema NMC Vodafone (xlsx descargado por bot Selenium) |
| Run | Ejecución del CLI conciliador (matchea liquidación con devengadas, aplica reglas, sugiere AI) |
| Conciliation | Línea individual de un Run (acción + resultado) |
| Auto-code | Código de producto auto-asignado por reglas (cuando Vodafone reporta uno desconocido) |
| AI Suggestion | Sugerencia 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-30 —
create_activaciones_callidus: tablas batch + activaciones. - 2026-04-29 —
enforce_one_sfid_vigente_por_comercial: cierre auto al reasignar. - 2026-04-28 —
create_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-26 —
add_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. Subdominiodistribucionvdf.matizal.comrepuntado aapp-vdf:8950. LaunchAgent CLI Playwright PAUSADO.