Compare commits
32 Commits
b5a20aed4e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b5660769 | |||
| cb1c36b669 | |||
| 013e13b9df | |||
| ed367582c3 | |||
| 4e3ce67ae7 | |||
| 1c1e6b04e4 | |||
| 17afcc9d8f | |||
| 438e3c85af | |||
| 9bdd666626 | |||
| 2533a05aab | |||
| 4a3ddd2f35 | |||
| 61d1b84fc3 | |||
| 23d288d0aa | |||
| ea03e6eeba | |||
| db28af1c58 | |||
| 19a8a5b037 | |||
| 3e8dca3c37 | |||
| 5d1b366cbd | |||
| 6435086181 | |||
| 2400ecf6ff | |||
| 7f663208a0 | |||
| 8a5db114ec | |||
| 8a2584438d | |||
| cfc0acb5d3 | |||
| d5276f4466 | |||
| 9d97197ba1 | |||
| e963267f9f | |||
| e8d1c57ad5 | |||
| 31abeec362 | |||
| 7c9ed699c4 | |||
| d6548999bf | |||
| 2fc80e7f9e |
160
api-gateway/package-lock.json
generated
160
api-gateway/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.6.0",
|
||||
"pg": "^8.23.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -27,6 +28,7 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"prisma": "^6.14.0",
|
||||
"tsx": "^4.20.3",
|
||||
@@ -1086,6 +1088,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@@ -2658,6 +2672,95 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2719,6 +2822,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||
@@ -3098,6 +3240,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
@@ -3600,6 +3751,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"seed:ai": "tsx scripts/seed-ai.ts",
|
||||
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
|
||||
"seed:config": "tsx scripts/seed-config.js",
|
||||
"seed:desarrollos": "tsx scripts/seed-desarrollos.js",
|
||||
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"seed:catalogos": "node scripts/actualizar-catalogos-ia.js"
|
||||
@@ -28,6 +29,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.6.0",
|
||||
"pg": "^8.23.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -39,6 +41,7 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"prisma": "^6.14.0",
|
||||
"tsx": "^4.20.3",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Módulo Desarrollos: catálogo de desarrollos (ficha, modelos y reglas del
|
||||
-- cotizador) más el piso (`nivel`) del inventario.
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
-- CreateTable Desarrollo
|
||||
CREATE TABLE IF NOT EXISTS "Desarrollo" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"clave" TEXT NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"estado" TEXT NOT NULL DEFAULT 'ACTIVO',
|
||||
"tipo" TEXT NOT NULL DEFAULT 'VERTICAL',
|
||||
"constructora" TEXT,
|
||||
"direccion" TEXT,
|
||||
"zona" TEXT,
|
||||
"descripcion" TEXT,
|
||||
"amenidades" TEXT,
|
||||
"fechaInicioObra" DATETIME,
|
||||
"fechaEntregaProgramada" DATETIME,
|
||||
"fechaEntregaReprogramada" DATETIME,
|
||||
"activoEnCotizador" BOOLEAN NOT NULL DEFAULT true,
|
||||
"activoEnAlta" BOOLEAN NOT NULL DEFAULT true,
|
||||
"prefijoUnidadPdf" TEXT,
|
||||
"imagenUrl" TEXT,
|
||||
"logoUrl" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Desarrollo_clave_key" ON "Desarrollo"("clave");
|
||||
CREATE INDEX IF NOT EXISTS "Desarrollo_estado_idx" ON "Desarrollo"("estado");
|
||||
|
||||
-- CreateTable ModeloDesarrollo
|
||||
CREATE TABLE IF NOT EXISTS "ModeloDesarrollo" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"desarrolloId" TEXT NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"recamaras" INTEGER,
|
||||
"banos" REAL,
|
||||
"estacionamientos" INTEGER,
|
||||
"supConstruccion" REAL,
|
||||
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||
CONSTRAINT "ModeloDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_nombre_key" ON "ModeloDesarrollo"("desarrolloId", "nombre");
|
||||
CREATE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_idx" ON "ModeloDesarrollo"("desarrolloId");
|
||||
|
||||
-- CreateTable ReglaCotizadorDesarrollo
|
||||
-- `desarrolloId` guarda el uuid de `Desarrollo.id` (lo que escriben el seed y
|
||||
-- PUT /desarrollos/:id/reglas). El endpoint del cotizador traduce uuid → clave.
|
||||
CREATE TABLE IF NOT EXISTS "ReglaCotizadorDesarrollo" (
|
||||
"desarrolloId" TEXT NOT NULL PRIMARY KEY,
|
||||
"parametrosJson" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"updatedPorId" TEXT,
|
||||
CONSTRAINT "ReglaCotizadorDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- AlterTable Unidad: piso del inventario ('' si no aplica)
|
||||
ALTER TABLE "Unidad" ADD COLUMN "nivel" TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Dedup de leads/afluencias: teléfono normalizado (solo dígitos, últimos 10)
|
||||
-- en su propia columna indexada. La búsqueda anterior `telefono contains`
|
||||
-- fallaba contra teléfonos guardados con espacios ("+52 664 225 7841" no
|
||||
-- contiene "6642257841"), permitiendo duplicados.
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
ALTER TABLE "LeadCaptura" ADD COLUMN "telefonoNorm" TEXT;
|
||||
|
||||
-- Backfill: últimos 10 dígitos cuando hay al menos 10; si no, todos los
|
||||
-- dígitos (coincide con normalizaTelefono() del servicio).
|
||||
UPDATE "LeadCaptura"
|
||||
SET "telefonoNorm" = CASE
|
||||
WHEN length(replace(replace(replace(replace(replace(coalesce("telefono", ''), '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')) >= 10
|
||||
THEN substr(replace(replace(replace(replace(replace("telefono", '+', ''), ' ', ''), '-', ''), '(', ''), ')', ''), -10)
|
||||
WHEN length(replace(replace(replace(replace(replace(coalesce("telefono", ''), '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')) >= 7
|
||||
THEN replace(replace(replace(replace(replace("telefono", '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "LeadCaptura_telefonoNorm_idx" ON "LeadCaptura"("telefonoNorm");
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Esquema de cotización único FRASA 2026 (aprobado por el cliente el 28 sep 2026):
|
||||
-- apartado libre + enganche de firma 2.5% + enganche 7.5% diferible +
|
||||
-- escrituración 5.5% (a la firma o diferida con el enganche). Las columnas
|
||||
-- legacy (tipoPropuesta, descuento, precioFinal, enganche, mensualidad,
|
||||
-- pagoFinal) se siguen llenando para compatibilidad de reportes.
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "esquema" TEXT NOT NULL DEFAULT 'FRASA_2026';
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "saldoFirma" REAL;
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "pctEngancheFirma" REAL;
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "engancheDiferible" REAL;
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "pctEscrituracion" REAL;
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "escrituracionDiferida" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "escrituracion" REAL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Opciones de credito del saldo (84.5%) sobre el esquema FRASA 2026: las 4
|
||||
-- propuestas del Excel del cliente (Bancario, Infonavit-Cofinavit, Semi
|
||||
-- Contado, Contado) con la opcion elegida y los ajustes del vendedor.
|
||||
-- Se guarda como JSON crudo {elegida, ajustes}; null en filas anteriores.
|
||||
-- Patron manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "opcionesCredito" TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Enganche adicional (anticipo extra) del esquema FRASA 2026: el cliente puede
|
||||
-- dar hasta 90% de anticipo total (esquema + adicional), quedando un mínimo
|
||||
-- del 10% del precio por financiar. 0 por default (no aplica a filas viejas).
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "engancheAdicional" REAL NOT NULL DEFAULT 0;
|
||||
@@ -21,7 +21,7 @@ model Cotizacion {
|
||||
clienteNombre String
|
||||
clienteEmail String?
|
||||
clienteTelefono String?
|
||||
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO
|
||||
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO | FRASA_2026 (esquema único)
|
||||
acabados Boolean
|
||||
apartado Float // TODO prod: Decimal en Postgres
|
||||
descuento Float // fracción (0.02 = 2%)
|
||||
@@ -31,6 +31,20 @@ model Cotizacion {
|
||||
numMensualidades Int
|
||||
mensualidad Float? // null => N/A (Contado)
|
||||
pagoFinal Float
|
||||
// Esquema único FRASA 2026 (apartado + firma 2.5% + enganche 7.5% +
|
||||
// escrituración 5.5%). Las filas legacy tienen esquema='FRASA_2026' por el
|
||||
// DEFAULT pero saldoFirma/engancheDiferible/escrituracion null.
|
||||
esquema String @default("FRASA_2026")
|
||||
saldoFirma Float? // lo que se paga a la firma por el enganche de firma (>= 0)
|
||||
pctEngancheFirma Float? // 0.025 (constante del esquema; se persiste por si cambia)
|
||||
engancheDiferible Float? // enganche del 7.5%, neto del excedente del apartado
|
||||
pctEscrituracion Float? // 0.055
|
||||
escrituracionDiferida Boolean @default(false)
|
||||
escrituracion Float? // gastos de escrituración (5.5% del precio)
|
||||
engancheAdicional Float? @default(0) // anticipo extra; el total no puede pasar del 90% del precio
|
||||
// Opciones de credito del saldo (84.5%): JSON {elegida, ajustes} serializado;
|
||||
// el cotizador manda el ajuste del vendedor y se persiste crudo.
|
||||
opcionesCredito String?
|
||||
mondayItemId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -173,6 +187,10 @@ model LeadCaptura {
|
||||
usuarioId String // Usuario.id del capturista
|
||||
nombreLead String
|
||||
telefono String?
|
||||
// Teléfono normalizado (solo dígitos, últimos 10) para el dedup de
|
||||
// capturas: la búsqueda por `telefono contains` fallaba con formatos
|
||||
// con espacios. Se mantiene en cada escritura vía normalizaTelefono().
|
||||
telefonoNorm String?
|
||||
// Board del embudo donde vive el registro: LEADS | OPORTUNIDADES | APARTADO
|
||||
board String @default("LEADS")
|
||||
// Etapa/estado local actual (NUEVO al capturar)
|
||||
@@ -198,6 +216,7 @@ model LeadCaptura {
|
||||
@@index([usuarioId])
|
||||
@@index([mondayItemId])
|
||||
@@index([nombreLead])
|
||||
@@index([telefonoNorm])
|
||||
}
|
||||
|
||||
// Espejo local del inventario (tablero Unidades de Monday). Lo siembra
|
||||
@@ -211,6 +230,7 @@ model Unidad {
|
||||
modelo String @default("")
|
||||
manzana String @default("")
|
||||
lote String @default("")
|
||||
nivel String @default("") // piso (desarrollos verticales); '' si no aplica
|
||||
supConstruccion Float @default(0)
|
||||
supExcedente Float @default(0)
|
||||
supEstacionamiento Float @default(0)
|
||||
@@ -298,6 +318,9 @@ model Usuario {
|
||||
// User ID de Monday (people). null = el usuario aún no tiene cuenta Monday;
|
||||
// los filtros "míos" de /api/mi-dia responden vacíos con sinMapeoMonday: true
|
||||
mondayUserId Int?
|
||||
// ID del usuario en WA CRM (auth.users del Supabase de wacrm). null = sin
|
||||
// vínculo; el módulo WhatsApp del CRM lo usa para la bandeja y el round-robin
|
||||
wacrmUserId String?
|
||||
activo Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -530,3 +553,61 @@ model SolicitudSoporte {
|
||||
@@index([usuarioId])
|
||||
@@index([departamento])
|
||||
}
|
||||
|
||||
// ── Módulo Desarrollos: catálogo de desarrollos nuevos ────────────────
|
||||
// La BD es la fuente de verdad: la ficha del desarrollo, sus modelos y las
|
||||
// reglas del cotizador (antes constantes fijas en el código de las apps).
|
||||
|
||||
model Desarrollo {
|
||||
id String @id @default(uuid())
|
||||
clave String @unique // SENDA, DIAGONAL… (forma los códigos de unidad)
|
||||
nombre String // nombre comercial
|
||||
estado String @default("ACTIVO") // ACTIVO | PREVENTA | CERRADO
|
||||
tipo String @default("VERTICAL") // VERTICAL | HORIZONTAL
|
||||
constructora String?
|
||||
direccion String?
|
||||
zona String?
|
||||
descripcion String?
|
||||
amenidades String? // texto libre o lista separada por comas
|
||||
fechaInicioObra DateTime?
|
||||
fechaEntregaProgramada DateTime?
|
||||
fechaEntregaReprogramada DateTime?
|
||||
activoEnCotizador Boolean @default(true) // aparece en el cotizador
|
||||
activoEnAlta Boolean @default(true) // aparece en altas de lead/afluencia/cliente
|
||||
prefijoUnidadPdf String? // p. ej. CASA (lo usa el PDF del cotizador)
|
||||
imagenUrl String?
|
||||
logoUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
modelos ModeloDesarrollo[]
|
||||
reglas ReglaCotizadorDesarrollo?
|
||||
|
||||
@@index([estado])
|
||||
}
|
||||
|
||||
model ModeloDesarrollo {
|
||||
id String @id @default(uuid())
|
||||
desarrolloId String
|
||||
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
|
||||
nombre String
|
||||
recamaras Int?
|
||||
banos Float?
|
||||
estacionamientos Int?
|
||||
supConstruccion Float?
|
||||
activo Boolean @default(true)
|
||||
|
||||
@@unique([desarrolloId, nombre])
|
||||
@@index([desarrolloId])
|
||||
}
|
||||
|
||||
model ReglaCotizadorDesarrollo {
|
||||
// `desarrolloId` guarda el uuid de `Desarrollo.id` (es lo que escriben el seed
|
||||
// y PUT /desarrollos/:id/reglas). El endpoint que consume el cotizador
|
||||
// (`/desarrollos/reglas-cotizador`) traduce uuid → clave antes de responder.
|
||||
desarrolloId String @id
|
||||
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
|
||||
parametrosJson String // JSON con la forma ParametrosDesarrollo
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedPorId String?
|
||||
}
|
||||
|
||||
130
api-gateway/scripts/descartar-duplicados.ts
Normal file
130
api-gateway/scripts/descartar-duplicados.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Limpieza de leads/oportunidades/apartados duplicados (septiembre 2026).
|
||||
*
|
||||
* Los duplicados se originaron ANTES del fix del dedup normalizado
|
||||
* (telefonoNorm, 2026-09-26): la búsqueda `telefono contains` no encontraba
|
||||
* teléfonos guardados con espacios y dejaba pasar capturas repetidas.
|
||||
*
|
||||
* Regla aplicada: conservar la cadena MÁS AVANZADA de cada teléfono
|
||||
* (APARTADO > OPORTUNIDADES > LEADS; dentro del mismo tablero, la etapa más
|
||||
* avanzada / la de la cadena ligada) y descartar el resto SIN borrarlo:
|
||||
* - LEADS → etapa 'Descartado', motivo 'Duplicado'
|
||||
* - OPORTUNIDADES→ etapa 'Perdido', motivo 'Duplicado'
|
||||
* - APARTADO → etapa 'Cancelacion Apartado', motivo 'Duplicado'
|
||||
* Cada descarte queda en AuditoriaLog (usuario admin del sistema).
|
||||
*
|
||||
* Seguridad: sólo toca la fila si el board y la etapa ACTUALES coinciden con
|
||||
* lo esperado; si ya avanzó o ya está cerrada, la omite y avisa. Idempotente.
|
||||
*
|
||||
* Uso: npx tsx scripts/descartar-duplicados.ts
|
||||
*/
|
||||
import dotenv from 'dotenv'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
dotenv.config()
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const ADMIN_ID = process.env.ADMIN_USUARIO_ID ?? 'c882ed06-7891-439a-9cd9-f33dd36378b3'
|
||||
const MOTIVO = 'Duplicado'
|
||||
|
||||
/** Etapa terminal por board (mismo criterio que CrmLocalService.descartar) */
|
||||
const ETAPA_TERMINAL: Record<string, string> = {
|
||||
LEADS: 'Descartado',
|
||||
OPORTUNIDADES: 'Perdido',
|
||||
APARTADO: 'Cancelacion Apartado',
|
||||
}
|
||||
|
||||
interface Descarte {
|
||||
leadId: string
|
||||
nombre: string
|
||||
board: string
|
||||
etapaEsperada: string
|
||||
}
|
||||
|
||||
const LEADS: Descarte[] = [
|
||||
{ leadId: 'loc_9a9b444a-6d1b-495f-8a6f-a10c4ae260c5', nombre: 'PaoSan', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_c8eac00b-f7ef-4842-81c8-6d0011aa9531', nombre: 'Nayeli Mota Perez (25/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_b190d1cd-96aa-4abb-8d44-606840bf3ae5', nombre: 'Xenya Dominguez (25/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_758b736f-d186-476f-996f-c95cce0463ea', nombre: 'Gustavo Fernandez de León (jose gregorio)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_80ed24b0-5c7d-4df5-8273-0e1c6b64e2d8', nombre: 'GUADALUPE (25/09)', board: 'LEADS', etapaEsperada: 'Calificado' },
|
||||
{ leadId: 'loc_9e63629d-988f-485c-8b23-47b38115357a', nombre: 'Blanca Monter (17:49)', board: 'LEADS', etapaEsperada: 'Calificado' },
|
||||
{ leadId: 'loc_e2e85982-5a3e-469e-9dc7-57c7225671d8', nombre: 'Wilberth Almanza (00:38)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_6a5f2678-8c5f-468c-b9e4-67a6b845e095', nombre: 'Maytte Lyon (00:48)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_d6d996d9-1ed3-48c7-8eb7-bb1e4890c37f', nombre: 'eduardo velarde gutierrez (tel de Daniel)', board: 'LEADS', etapaEsperada: 'NUEVO' },
|
||||
{ leadId: 'loc_44fe422c-9746-47c8-a8c7-2b3e495630db', nombre: 'Stefano Sandoval (tel compartido con Blanca)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_c26e53c0-5c33-472c-8b66-0d84a7a34b7b', nombre: 'Miguel Ángel Rodríguez (25/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_beeec35a-aa09-44af-ab5a-c7edd522e80e', nombre: 'Joel Benitez (00:36)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_347d7a43-b9e9-4f5c-8319-965353e256a2', nombre: 'Abigail Melendez (00:29)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_237f8b3d-23d7-4206-97de-d69ffab5ce00', nombre: 'Mildred (00:25)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_eab9b179-0883-4ecf-a380-7dba8ae2d256', nombre: 'Eva Vargas Arias (00:31)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_5f1b3b9b-cd37-4353-8b32-d8a49cb2992c', nombre: 'Julio (NUEVO 18:58)', board: 'LEADS', etapaEsperada: 'NUEVO' },
|
||||
{ leadId: 'loc_7c98bdbf-00fb-40ba-be1f-19d78efb8df7', nombre: 'EDGAR MORALES (20:57)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
{ leadId: 'loc_cfacf7dd-eaa7-43c8-aab7-1f388c8477fe', nombre: 'Ivan (21/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
|
||||
]
|
||||
|
||||
const OPORTUNIDADES: Descarte[] = [
|
||||
{ leadId: 'loc_07cbd367-9537-4f8f-9a12-6af840dc548f', nombre: 'EMANUEL GARCIA HERNANDEZ (19/09)', board: 'OPORTUNIDADES', etapaEsperada: 'Aparto' },
|
||||
{ leadId: 'loc_6635cc1e-d160-4a93-bb58-e52a7d704546', nombre: 'GUADALUPE Agendo Cita (25/09)', board: 'OPORTUNIDADES', etapaEsperada: 'Agendo Cita' },
|
||||
{ leadId: 'loc_661acb75-2601-46dc-a314-e903b2d9ad02', nombre: 'GUADALUPE AVILA Afluencia (24/09)', board: 'OPORTUNIDADES', etapaEsperada: 'Afluencia' },
|
||||
{ leadId: 'loc_d234e717-b849-4a09-ab06-3cacc8afcd7b', nombre: 'Blanca Monter Agendo Cita (17:50)', board: 'OPORTUNIDADES', etapaEsperada: 'Agendo Cita' },
|
||||
{ leadId: 'loc_2c18e0ce-c1a7-47e0-b9fc-18562a83b9b7', nombre: 'Rocio Gtz Afluencia (17:50)', board: 'OPORTUNIDADES', etapaEsperada: 'Afluencia' },
|
||||
{ leadId: 'loc_d5f3ec01-cf7e-47f2-9840-a77aada459e7', nombre: 'Nereyda Lopez Afluencia (17:53)', board: 'OPORTUNIDADES', etapaEsperada: 'Afluencia' },
|
||||
{ leadId: 'loc_8873f6e0-7440-4061-aa2e-cc31be47f8f0', nombre: 'Guadalupe Avila Jimenez Cotizador', board: 'OPORTUNIDADES', etapaEsperada: 'Cotizador' },
|
||||
]
|
||||
|
||||
const APARTADOS: Descarte[] = [
|
||||
{ leadId: 'loc_e0751165-95e8-4e1c-b21c-f99e417a6c54', nombre: 'EMANUEL GARCIA HERNANDEZ (03:29)', board: 'APARTADO', etapaEsperada: 'Pago Apartado' },
|
||||
]
|
||||
|
||||
const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado']
|
||||
|
||||
async function main() {
|
||||
const todos = [...LEADS, ...OPORTUNIDADES, ...APARTADOS]
|
||||
let descartados = 0
|
||||
let omitidos = 0
|
||||
|
||||
for (const d of todos) {
|
||||
const row = await prisma.leadCaptura.findUnique({ where: { leadId: d.leadId } })
|
||||
if (!row) {
|
||||
console.log(`OMITIDO (no existe): ${d.leadId} ${d.nombre}`)
|
||||
omitidos++
|
||||
continue
|
||||
}
|
||||
if (row.board !== d.board || ETAPAS_CERRADAS.includes(row.etapa)) {
|
||||
console.log(`OMITIDO (board=${row.board} etapa=${row.etapa}): ${d.nombre}`)
|
||||
omitidos++
|
||||
continue
|
||||
}
|
||||
if (row.etapa !== d.etapaEsperada) {
|
||||
console.log(`OMITIDO (etapa actual "${row.etapa}" != esperada "${d.etapaEsperada}"): ${d.nombre}`)
|
||||
omitidos++
|
||||
continue
|
||||
}
|
||||
await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: d.leadId },
|
||||
data: { etapa: ETAPA_TERMINAL[d.board], motivo: MOTIVO, submotivo: null, pendienteSync: true },
|
||||
}),
|
||||
prisma.auditoriaLog.create({
|
||||
data: {
|
||||
usuarioId: ADMIN_ID,
|
||||
accion: 'LIMPIEZA_DUPLICADO',
|
||||
entidad: 'LeadCaptura',
|
||||
entidadId: d.leadId,
|
||||
detalle: `Descartado como duplicado (${d.board} "${row.nombreLead}" etapa ${row.etapa}). Limpieza post-fix dedup telefonoNorm.`,
|
||||
},
|
||||
}),
|
||||
])
|
||||
console.log(`DESCARTADO [${d.board} → ${ETAPA_TERMINAL[d.board]}]: ${row.nombreLead} (${d.nombre})`)
|
||||
descartados++
|
||||
}
|
||||
|
||||
console.log(`\nListo: ${descartados} descartados, ${omitidos} omitidos de ${todos.length}.`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -17,8 +17,10 @@
|
||||
* - Pagos programados: numPagos mensualidades desde MES INICIO
|
||||
* (montoPagado=0; el módulo deriva VENCIDO por fecha).
|
||||
* - Celda de grid con valor > 0 = abono real del mes → AplicacionPago
|
||||
* (fechaPago = mes de la columna) sobre la mensualidad de ese mes;
|
||||
* si no hay mensualidad programada en ese mes → Pago standalone pagado.
|
||||
* (fechaPago = mes de la columna) sobre la mensualidad de ese mes; si no
|
||||
* hay mensualidad programada en ese mes, o el abono excede lo que falta de
|
||||
* la mensualidad, el excedente se registra como Pago standalone ya cubierto
|
||||
* (así ningún abono supera el monto de su pago).
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
@@ -57,6 +59,7 @@ function norm(h) {
|
||||
}
|
||||
|
||||
const toStr = (v) => (v == null ? null : String(v).trim() || null)
|
||||
const round2 = (x) => Math.round(x * 100) / 100
|
||||
const toFloat = (v) => {
|
||||
if (v == null || v === '') return null
|
||||
if (typeof v === 'number') return v
|
||||
@@ -230,23 +233,27 @@ async function escribir(planesLeidos) {
|
||||
}
|
||||
plan.engancheTotal = pagosProg.reduce((a, p) => a + p.monto, 0)
|
||||
|
||||
// 2) abonos del grid sobre la mensualidad del mismo mes; sobrantes → standalone
|
||||
// 2) abonos del grid sobre la mensualidad del mismo mes. Un abono nunca
|
||||
// puede exceder el monto de su pago: lo que sobra (y los meses sin
|
||||
// mensualidad programada) se registra como pago nuevo ya cubierto, así
|
||||
// Σ montoPagado == dinero recibido y ningún pago queda sobrepagado.
|
||||
const aplicaciones = [] // { idxPago, fechaPago, monto }
|
||||
const standalone = []
|
||||
for (const ab of abonosGrid) {
|
||||
for (const ab of [...abonosGrid].sort((a, b) => a.fecha - b.fecha)) {
|
||||
// idx del arreglo == numPago (el inicial ocupa numPago 0 en el índice 0)
|
||||
const idx = pagosProg.findIndex((p) => p.numPago >= 1 && claveMes(p.fechaVencimiento) === claveMes(ab.fecha))
|
||||
let restante = ab.monto
|
||||
if (idx !== -1) {
|
||||
aplicaciones.push({ idx, fechaPago: ab.fecha, monto: ab.monto })
|
||||
} else {
|
||||
standalone.push({ fecha: ab.fecha, monto: ab.monto })
|
||||
const p = pagosProg[idx]
|
||||
const falta = Math.max(0, round2(p.monto - p.montoPagado))
|
||||
const aplica = Math.min(restante, falta)
|
||||
if (aplica > 0) {
|
||||
aplicaciones.push({ idx, fechaPago: ab.fecha, monto: round2(aplica) })
|
||||
p.montoPagado = round2(p.montoPagado + aplica)
|
||||
restante = round2(restante - aplica)
|
||||
}
|
||||
}
|
||||
|
||||
// aplicar abonos al montoPagado (sin pasar del monto programado)
|
||||
for (const ap of aplicaciones) {
|
||||
const p = pagosProg[ap.idx]
|
||||
p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto)
|
||||
if (restante > 0) standalone.push({ fecha: ab.fecha, monto: round2(restante) })
|
||||
}
|
||||
|
||||
// Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal
|
||||
|
||||
157
api-gateway/scripts/seed-desarrollos.js
Normal file
157
api-gateway/scripts/seed-desarrollos.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Siembra el catálogo `Desarrollo` + `ModeloDesarrollo` + `ReglaCotizadorDesarrollo`
|
||||
* desde la realidad actual del sistema:
|
||||
*
|
||||
* - Desarrollos y modelos: lo que hoy hay en la tabla `Unidad` (espejo del
|
||||
* inventario), más SOLE y MUTUO, que existen en captura y altas pero todavía
|
||||
* no tienen inventario local.
|
||||
* - Reglas del cotizador: copiadas (replicadas, no importadas) de las
|
||||
* constantes de apps/cotizador/src/data/desarrollos.ts, que es la config con
|
||||
* la que hoy cotiza producción.
|
||||
*
|
||||
* Idempotente: upsert por `clave`. NUNCA pisa lo ya editado: si la ficha o las
|
||||
* reglas existen, el seed no las toca (el admin manda).
|
||||
*
|
||||
* Uso: npm run seed:desarrollos (tsx: la fuente de las reglas es TypeScript)
|
||||
*/
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import dotenv from 'dotenv'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { DESARROLLOS as PARAMETROS_COTIZADOR } from '../../apps/cotizador/src/data/desarrollos.ts'
|
||||
|
||||
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
/**
|
||||
* Desarrollos sin inventario local que sí existen en el ecosistema (captura y
|
||||
* altas de lead/afluencia/cliente). Los modelos de SOLE vienen de la lista
|
||||
* real del alta de clientes (apps/alta-clientes/src/data/constantes.ts);
|
||||
* MUTUO captura el modelo en texto libre, así que se siembra sin modelos.
|
||||
*/
|
||||
const SIN_INVENTARIO = [
|
||||
{ clave: 'SOLE', nombre: 'Sole', tipo: 'VERTICAL', modelos: ['ALBA', 'HORIZONTE', 'OCASO'] },
|
||||
{ clave: 'MUTUO', nombre: 'Mutuo', tipo: 'VERTICAL', modelos: [] },
|
||||
]
|
||||
|
||||
const normalizar = (v) => String(v ?? '').trim().toUpperCase()
|
||||
|
||||
/** Nombre comercial: el del cotizador si existe; si no, la clave capitalizada */
|
||||
function nombreComercial(clave) {
|
||||
const delCotizador = PARAMETROS_COTIZADOR[clave]?.nombre
|
||||
if (typeof delCotizador === 'string' && delCotizador !== '') return delCotizador
|
||||
return clave.charAt(0) + clave.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [porDesarrollo, modelosInventario] = await Promise.all([
|
||||
prisma.unidad.groupBy({ by: ['desarrollo'], _count: { codigo: true } }),
|
||||
prisma.unidad.groupBy({ by: ['desarrollo', 'modelo'], _count: { codigo: true } }),
|
||||
])
|
||||
|
||||
if (porDesarrollo.length === 0) {
|
||||
throw new Error('La tabla Unidad está vacía: corre primero `npm run seed:unidades`')
|
||||
}
|
||||
|
||||
const claves = new Set(porDesarrollo.map((d) => normalizar(d.desarrollo)))
|
||||
for (const extra of SIN_INVENTARIO) claves.add(extra.clave)
|
||||
|
||||
const modelosPorClave = new Map()
|
||||
for (const fila of modelosInventario) {
|
||||
const clave = normalizar(fila.desarrollo)
|
||||
const modelo = String(fila.modelo ?? '').trim()
|
||||
if (modelo === '') continue
|
||||
if (!modelosPorClave.has(clave)) modelosPorClave.set(clave, new Set())
|
||||
modelosPorClave.get(clave).add(modelo)
|
||||
}
|
||||
for (const extra of SIN_INVENTARIO) {
|
||||
if (!modelosPorClave.has(extra.clave)) modelosPorClave.set(extra.clave, new Set())
|
||||
for (const modelo of extra.modelos) modelosPorClave.get(extra.clave).add(modelo)
|
||||
}
|
||||
|
||||
let fichasNuevas = 0
|
||||
let fichasExistentes = 0
|
||||
let modelosNuevos = 0
|
||||
let reglasNuevas = 0
|
||||
|
||||
for (const clave of [...claves].sort()) {
|
||||
const extra = SIN_INVENTARIO.find((e) => e.clave === clave)
|
||||
const parametros = PARAMETROS_COTIZADOR[clave]
|
||||
|
||||
const previa = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
|
||||
const ficha = await prisma.desarrollo.upsert({
|
||||
where: { clave },
|
||||
// No pisa lo ya editado por el admin: si existe, se deja como está
|
||||
update: {},
|
||||
create: {
|
||||
clave,
|
||||
nombre: extra?.nombre ?? nombreComercial(clave),
|
||||
// `tipo` no se deduce del inventario actual (los códigos y el prefijo
|
||||
// del PDF no lo distinguen): queda el default VERTICAL y el admin lo
|
||||
// ajusta desde el módulo.
|
||||
tipo: extra?.tipo ?? 'VERTICAL',
|
||||
prefijoUnidadPdf: typeof parametros?.prefijoUnidadPdf === 'string' ? parametros.prefijoUnidadPdf : null,
|
||||
},
|
||||
})
|
||||
if (previa) fichasExistentes++
|
||||
else fichasNuevas++
|
||||
|
||||
for (const nombre of [...(modelosPorClave.get(clave) ?? [])].sort()) {
|
||||
const modeloPrevio = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
|
||||
select: { id: true },
|
||||
})
|
||||
await prisma.modeloDesarrollo.upsert({
|
||||
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
|
||||
update: {},
|
||||
create: { desarrolloId: ficha.id, nombre },
|
||||
})
|
||||
if (!modeloPrevio) modelosNuevos++
|
||||
}
|
||||
|
||||
if (parametros) {
|
||||
const reglaPrevia = await prisma.reglaCotizadorDesarrollo.findUnique({
|
||||
where: { desarrolloId: ficha.id },
|
||||
select: { desarrolloId: true },
|
||||
})
|
||||
if (!reglaPrevia) {
|
||||
await prisma.reglaCotizadorDesarrollo.create({
|
||||
data: { desarrolloId: ficha.id, parametrosJson: JSON.stringify(parametros) },
|
||||
})
|
||||
reglasNuevas++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = await prisma.desarrollo.count()
|
||||
const conReglas = await prisma.reglaCotizadorDesarrollo.count()
|
||||
const totalModelos = await prisma.modeloDesarrollo.count()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[seed-desarrollos] fichas: ${fichasNuevas} nueva(s), ${fichasExistentes} ya existían; ` +
|
||||
`${modelosNuevos} modelo(s) nuevo(s); ${reglasNuevas} regla(s) de cotizador copiada(s)`,
|
||||
)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[seed-desarrollos] total en BD: ${total} desarrollos, ${totalModelos} modelos, ${conReglas} con reglas`)
|
||||
|
||||
for (const d of await prisma.desarrollo.findMany({
|
||||
orderBy: { clave: 'asc' },
|
||||
include: { modelos: { orderBy: { nombre: 'asc' } }, reglas: { select: { desarrolloId: true } } },
|
||||
})) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
` ${d.clave} (${d.estado}/${d.tipo}) — ${d.modelos.length} modelo(s)` +
|
||||
`${d.reglas ? '' : ' — SIN reglas de cotizador'}: ${d.modelos.map((m) => m.nombre).join(', ') || '—'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('[seed-desarrollos] error:', err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
48
api-gateway/src/config/campos-departamento.test.ts
Normal file
48
api-gateway/src/config/campos-departamento.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEPARTAMENTOS, DEPARTAMENTOS_POR_ROL, departamentosDeRol, rolPuedeDepartamento } from './campos-departamento.js'
|
||||
|
||||
/**
|
||||
* Matriz de permisos de Captura (fuente de verdad del backend; el front la
|
||||
* espeja en apps/landing/src/services/captura.ts para ocultar pestañas).
|
||||
*/
|
||||
describe('departamentosDeRol', () => {
|
||||
it('los roles operativos ven exactamente su departamento', () => {
|
||||
expect(departamentosDeRol('vendedor')).toEqual(['VENTAS'])
|
||||
expect(departamentosDeRol('escrituracion')).toEqual(['ESCRITURACION'])
|
||||
expect(departamentosDeRol('cobranza')).toEqual(['FINANZAS'])
|
||||
expect(departamentosDeRol('postventa')).toEqual(['POSTVENTA'])
|
||||
expect(departamentosDeRol('obra')).toEqual(['OBRA'])
|
||||
})
|
||||
|
||||
it('dirección y administración ven todos', () => {
|
||||
for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador', 'director']) {
|
||||
expect(departamentosDeRol(rol)).toEqual([...DEPARTAMENTOS])
|
||||
}
|
||||
})
|
||||
|
||||
it('tolera mayúsculas y espacios; sin token no restringe', () => {
|
||||
expect(departamentosDeRol(' Vendedor ')).toEqual(['VENTAS'])
|
||||
expect(departamentosDeRol(undefined)).toEqual([...DEPARTAMENTOS])
|
||||
expect(departamentosDeRol('')).toEqual([...DEPARTAMENTOS])
|
||||
})
|
||||
|
||||
it('un rol desconocido cae al fallback seguro (vendedor)', () => {
|
||||
expect(departamentosDeRol('otro')).toEqual(['VENTAS'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rolPuedeDepartamento', () => {
|
||||
it('cada clave de la matriz ve sus departamentos y ninguno más', () => {
|
||||
for (const [rol, departamentos] of Object.entries(DEPARTAMENTOS_POR_ROL)) {
|
||||
for (const departamento of DEPARTAMENTOS) {
|
||||
expect(rolPuedeDepartamento(rol, departamento)).toBe(
|
||||
(departamentos as readonly string[]).includes(departamento),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('un departamento inexistente nunca se permite', () => {
|
||||
expect(rolPuedeDepartamento('admin', 'INVENTADO')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const
|
||||
export type Departamento = (typeof DEPARTAMENTOS)[number]
|
||||
|
||||
export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: string; label: string; options?: string[]; conFecha?: string }[]> = {
|
||||
OBRA: [
|
||||
@@ -15,6 +16,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
|
||||
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
|
||||
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
||||
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
||||
// Fechas de obra (las llena el área de obra; rigen el arranque y la entrega)
|
||||
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
||||
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
||||
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
||||
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
||||
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
||||
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
||||
{ campo: 'desviacion', tipo: 'number', label: 'Desviación (días)' },
|
||||
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
||||
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
||||
],
|
||||
VENTAS: [
|
||||
@@ -30,13 +40,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
|
||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||
],
|
||||
ESCRITURACION: [
|
||||
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
||||
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
||||
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
||||
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
||||
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
||||
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
||||
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
||||
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
||||
@@ -59,3 +62,44 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
|
||||
}
|
||||
|
||||
export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const
|
||||
|
||||
// ── Permisos de captura por rol ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Departamentos que cada rol puede ver y capturar. Fuente de verdad del
|
||||
* ecosistema: el front espeja esta misma matriz en services/captura.ts para
|
||||
* ocultar pestañas, pero quien la hace cumplir es el API.
|
||||
*
|
||||
* Dirección y administración ven todos (incluye los roles legacy de la matriz
|
||||
* de módulos: 'director', 'coordinador'); cada rol operativo sólo el suyo.
|
||||
*/
|
||||
export const DEPARTAMENTOS_POR_ROL: Record<string, readonly Departamento[]> = {
|
||||
admin: DEPARTAMENTOS,
|
||||
manager: DEPARTAMENTOS,
|
||||
direccion_general: DEPARTAMENTOS,
|
||||
director_ventas: DEPARTAMENTOS,
|
||||
administracion_venta: DEPARTAMENTOS,
|
||||
director: DEPARTAMENTOS,
|
||||
coordinador: DEPARTAMENTOS,
|
||||
vendedor: ['VENTAS'],
|
||||
escrituracion: ['ESCRITURACION'],
|
||||
cobranza: ['FINANZAS'],
|
||||
postventa: ['POSTVENTA'],
|
||||
obra: ['OBRA'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Departamentos del rol. Sin token (AUTH_ENABLED=false, modo local) no hay
|
||||
* restricción; un rol desconocido cae a vendedor, igual que modulosDeRol
|
||||
* (fallback seguro: es el conjunto más chico).
|
||||
*/
|
||||
export function departamentosDeRol(rol: string | undefined): readonly Departamento[] {
|
||||
const clave = (rol ?? '').trim().toLowerCase()
|
||||
if (clave === '') return DEPARTAMENTOS
|
||||
return DEPARTAMENTOS_POR_ROL[clave] ?? DEPARTAMENTOS_POR_ROL['vendedor']!
|
||||
}
|
||||
|
||||
/** ¿El rol puede ver/capturar ese departamento? */
|
||||
export function rolPuedeDepartamento(rol: string | undefined, departamento: string): boolean {
|
||||
return (departamentosDeRol(rol) as readonly string[]).includes(departamento)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
// modulosDeRol lee RolConfiguracion (BD) y cae a la MATRIZ estática cuando no
|
||||
// hay fila para el rol. Los permisos reales se editan desde la UI, así que la
|
||||
// BD viva cambia con el tiempo: estos tests fijan el contrato por defecto
|
||||
// (sin override en BD), que es lo que recibe una instalación nueva.
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
rolConfiguracion: { findUnique: vi.fn() },
|
||||
}))
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
prismaMock.rolConfiguracion.findUnique.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
const ESPERADOS: Record<string, string[]> = {
|
||||
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
administracion_venta: [
|
||||
|
||||
@@ -12,9 +12,11 @@ export const MODULOS = [
|
||||
'cobranza',
|
||||
'contactos',
|
||||
'chat',
|
||||
'whatsapp',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
'desarrollos',
|
||||
] as const
|
||||
export type Modulo = (typeof MODULOS)[number]
|
||||
|
||||
@@ -38,6 +40,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
|
||||
'mi-tablero',
|
||||
'contactos',
|
||||
'chat',
|
||||
'whatsapp',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
@@ -48,9 +51,10 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'whatsapp', 'clientes-maestro', 'captura', 'soporte'],
|
||||
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
|
||||
manager: MODULOS,
|
||||
// ni dar de alta desarrollos (módulo exclusivo del admin)
|
||||
manager: MODULOS.filter((m) => m !== 'desarrollos'),
|
||||
admin: MODULOS,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
export const VOCABULARIO = {
|
||||
// Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del
|
||||
// API + NUEVO (captura) y DESCARTADO (terminal)
|
||||
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
|
||||
FASES_LEAD: ['NUEVO', 'Intento de contacto', 'CONTACTADO', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
|
||||
// Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado')
|
||||
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la
|
||||
@@ -60,8 +60,27 @@ export const VOCABULARIO = {
|
||||
// Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO
|
||||
// agregados 2026-09 — existen en captura e inventario)
|
||||
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'],
|
||||
// Campos de calificación que hacen "completa" una afluencia: sin ellos la
|
||||
// oportunidad NO puede avanzar a 'Aparto' (regla de negocio del cliente)
|
||||
CAMPOS_AFLUENCIA_REQUERIDOS: ['estadoCivil', 'tipoEmpleo', 'rangoSalarial', 'tipoEconomia', 'califica', 'loQuierePara'],
|
||||
} as const
|
||||
|
||||
/** Campos de afluencia faltantes en un datosJson ('[]' = afluencia completa) */
|
||||
export function camposAfluenciaFaltantes(datosJson: string | null): string[] {
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (datosJson) {
|
||||
try { datos = JSON.parse(datosJson) as Record<string, unknown> } catch { datos = {} }
|
||||
}
|
||||
return VOCABULARIO.CAMPOS_AFLUENCIA_REQUERIDOS.filter(
|
||||
(c) => typeof datos[c] !== 'string' || (datos[c] as string).trim() === '',
|
||||
)
|
||||
}
|
||||
|
||||
/** ¿La fila tiene la afluencia completa? (gate de 'Aparto' y flag de Mi día) */
|
||||
export function afluenciaCompleta(datosJson: string | null): boolean {
|
||||
return camposAfluenciaFaltantes(datosJson).length === 0
|
||||
}
|
||||
|
||||
export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
|
||||
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
|
||||
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number]
|
||||
|
||||
@@ -114,6 +114,12 @@ export async function obtenerConversacion(req: Request, res: Response): Promise<
|
||||
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||
// Propiedad: misma regla que al borrar — sólo el dueño o un rol de
|
||||
// supervisión puede leer la conversación.
|
||||
const tokenLectura = req.usuarioToken
|
||||
if (tokenLectura && !esAdminOManager(tokenLectura.rol) && conversacion.usuarioId !== tokenLectura.sub) {
|
||||
throw new AppError(403, 'Solo puedes ver tus conversaciones')
|
||||
}
|
||||
res.json({
|
||||
id: conversacion.id,
|
||||
createdAt: conversacion.createdAt,
|
||||
|
||||
@@ -22,6 +22,8 @@ const cambiarEstadoSchema = z.object({
|
||||
motivo: z.string().optional(),
|
||||
subcategoria: z.string().optional(),
|
||||
comentario: z.string().optional(),
|
||||
// En 'Cita Clientes' puede setear la fecha y hora de la cita
|
||||
fecha: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -33,9 +35,32 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
||||
fecha: body.fecha,
|
||||
usuarioId: req.usuarioToken?.sub,
|
||||
sombra: !esIdLocal(itemId),
|
||||
})
|
||||
// 'Cita Clientes' con fecha: agenda la cita en el calendario del vendedor del
|
||||
// apartado (ActividadLocal ligada a la captura; reaperturas reagendan: se
|
||||
// cierran las citas abiertas previas de ese apartado y se crea la nueva).
|
||||
if (body.estado === 'Cita Clientes' && body.fecha !== undefined && body.fecha !== '') {
|
||||
const fila = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
const nombreCliente = fila?.nombreLead ?? 'Cliente'
|
||||
const usuarioDestino = fila?.usuarioId ?? req.usuarioToken?.sub ?? 'desconocido'
|
||||
await prisma.actividadLocal.updateMany({
|
||||
where: { leadCapturaId: itemId, tipo: 'CITA', estado: 'ABIERTA' },
|
||||
data: { estado: 'CERRADA', fechaCierre: new Date() },
|
||||
})
|
||||
await prisma.actividadLocal.create({
|
||||
data: {
|
||||
titulo: `Cita cliente: ${nombreCliente}`,
|
||||
descripcion: `Cita de cliente del apartado (${itemId})`,
|
||||
usuarioId: usuarioDestino,
|
||||
leadCapturaId: itemId,
|
||||
fechaVencimiento: new Date(body.fecha),
|
||||
tipo: 'CITA',
|
||||
},
|
||||
})
|
||||
}
|
||||
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
|
||||
// para los reportes de cancelación (Cliente/Motivo).
|
||||
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }),
|
||||
createdAt: expect.objectContaining({ gte: expect.any(Date), lt: expect.any(Date) }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
await buscarEndpoint(req({ q: 'María' }), r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('María', 20)
|
||||
expect(buscarMock).toHaveBeenCalledWith('María', 20, undefined)
|
||||
expect(r.json).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
@@ -34,25 +34,47 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
await buscarEndpoint(req({}), r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('', 20)
|
||||
expect(buscarMock).toHaveBeenCalledWith('', 20, undefined)
|
||||
})
|
||||
|
||||
it('limite válido del query string', async () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
await buscarEndpoint(req({ q: 'x', limite: '10' }), r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('x', 10)
|
||||
expect(buscarMock).toHaveBeenCalledWith('x', 10, undefined)
|
||||
})
|
||||
|
||||
it('limite inválido o > 50 se clampa a 20', async () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('x', 20)
|
||||
expect(buscarMock).toHaveBeenCalledWith('x', 20, undefined)
|
||||
|
||||
const r2 = res()
|
||||
await buscarEndpoint(req({ q: 'x', limite: '100' }), r2)
|
||||
expect(buscarMock).toHaveBeenCalledWith('x', 50)
|
||||
expect(buscarMock).toHaveBeenCalledWith('x', 50, undefined)
|
||||
})
|
||||
|
||||
it('vendedor: pasa alcance soloPropio (sólo ve sus propios registros)', async () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
const reqVendedor = {
|
||||
query: { q: 'Ana' },
|
||||
usuarioToken: { sub: 'u-9', username: 'v', rol: 'vendedor' },
|
||||
} as unknown as Request
|
||||
await buscarEndpoint(reqVendedor, r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-9', soloPropio: true })
|
||||
})
|
||||
|
||||
it('admin: pasa alcance sin filtro (sigue viendo todo el universo)', async () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
const reqAdmin = {
|
||||
query: { q: 'Ana' },
|
||||
usuarioToken: { sub: 'u-1', username: 'a', rol: 'admin' },
|
||||
} as unknown as Request
|
||||
await buscarEndpoint(reqAdmin, r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-1', soloPropio: false })
|
||||
})
|
||||
|
||||
it('devuelve los resultados del service como JSON', async () => {
|
||||
|
||||
@@ -8,6 +8,13 @@ import { buscar } from '../services/buscar.service.js'
|
||||
export async function buscarEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : ''
|
||||
const limite = typeof req.query.limite === 'string' ? Math.min(parseInt(req.query.limite, 10) || 20, 50) : 20
|
||||
const resultados = await buscar(q, limite)
|
||||
// Privacidad: el vendedor sólo encuentra sus propios leads, contactos y
|
||||
// cotizaciones; los roles de supervisión siguen viendo todo el universo.
|
||||
const soloPropio = req.usuarioToken?.rol === 'vendedor'
|
||||
const resultados = await buscar(
|
||||
q,
|
||||
limite,
|
||||
req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined,
|
||||
)
|
||||
res.json(resultados)
|
||||
}
|
||||
|
||||
@@ -194,6 +194,111 @@ describe('bulkEndpoint (POST /api/captura/bulk)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('permisos por rol (DEPARTAMENTOS_POR_ROL)', () => {
|
||||
const bulk = (departamento: string, rol: string, campo: string) =>
|
||||
reqFake({
|
||||
body: { desarrollo: 'SENDA', departamento, registros: [{ unidad: 'U-101', campo, valor: null }] },
|
||||
token: { sub: 'u1', rol },
|
||||
})
|
||||
|
||||
it('vendedor: 403 al guardar fuera de VENTAS (POST /captura)', async () => {
|
||||
await expect(
|
||||
guardarEndpoint(
|
||||
reqFake({
|
||||
body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'OBRA', campo: 'habitabilidad', valor: '2026-09-01' },
|
||||
token: { sub: 'u1', rol: 'vendedor' },
|
||||
}),
|
||||
resFake().res,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 403 })
|
||||
expect(capturaService.guardarCampo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vendedor: 403 en FINANZAS y 204 en VENTAS (POST /captura/bulk)', async () => {
|
||||
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
|
||||
|
||||
await expect(bulkEndpoint(bulk('FINANZAS', 'vendedor', 'bancario'), resFake().res)).rejects.toMatchObject({ status: 403 })
|
||||
expect(capturaService.guardarBulk).not.toHaveBeenCalled()
|
||||
|
||||
const { res, status } = resFake()
|
||||
await bulkEndpoint(bulk('VENTAS', 'vendedor', 'estatus'), res)
|
||||
expect(status).toHaveBeenCalledWith(204)
|
||||
expect(capturaService.guardarBulk).toHaveBeenCalledWith('SENDA', 'VENTAS', [{ unidad: 'U-101', campo: 'estatus', valor: null }], 'u1', 'vendedor')
|
||||
})
|
||||
|
||||
it('cada rol operativo conserva su departamento y sólo ése', async () => {
|
||||
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
|
||||
const casos: [string, string, string][] = [
|
||||
['vendedor', 'VENTAS', 'estatus'],
|
||||
['escrituracion', 'ESCRITURACION', 'notaria'],
|
||||
['cobranza', 'FINANZAS', 'bancario'],
|
||||
['postventa', 'POSTVENTA', 'entrega_sac'],
|
||||
['obra', 'OBRA', 'comentarios_obra'],
|
||||
]
|
||||
|
||||
for (const [rol, departamento, campo] of casos) {
|
||||
const { res, status } = resFake()
|
||||
await bulkEndpoint(bulk(departamento, rol, campo), res)
|
||||
expect(status).toHaveBeenCalledWith(204)
|
||||
}
|
||||
|
||||
// El mismo rol no puede escribir en el departamento de otro
|
||||
const { res } = resFake()
|
||||
await expect(bulkEndpoint(bulk('VENTAS', 'obra', 'estatus'), res)).rejects.toMatchObject({ status: 403 })
|
||||
})
|
||||
|
||||
it('admin, manager, dirección y administración de venta pueden con todos los departamentos', async () => {
|
||||
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
|
||||
|
||||
for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador']) {
|
||||
await bulkEndpoint(bulk('OBRA', rol, 'comentarios_obra'), resFake().res)
|
||||
}
|
||||
expect(capturaService.guardarBulk).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('sin token (AUTH_ENABLED=false, modo local) no se restringe', async () => {
|
||||
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
|
||||
const { res, status } = resFake()
|
||||
|
||||
await bulkEndpoint(
|
||||
reqFake({ body: { desarrollo: 'SENDA', departamento: 'OBRA', registros: [{ unidad: 'U-101', campo: 'comentarios_obra', valor: 'ok' }] } }),
|
||||
res,
|
||||
)
|
||||
expect(status).toHaveBeenCalledWith(204)
|
||||
})
|
||||
|
||||
it('GET: 403 si el rol pide un departamento que no le toca', async () => {
|
||||
await expect(
|
||||
listarEndpoint(reqFake({ query: { desarrollo: 'SENDA', departamento: 'OBRA' }, token: { sub: 'u1', rol: 'vendedor' } }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 403 })
|
||||
expect(capturaService.obtenerCapturas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('GET: filtra los campos de los demás departamentos', async () => {
|
||||
vi.mocked(capturaService.obtenerCapturas).mockResolvedValue({
|
||||
'U-101': { estatus: 'VENDIDO', comentarios_obra: 'en proceso' },
|
||||
'U-102': { comentarios_obra: 'sin ventas' },
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res)
|
||||
|
||||
expect(json).toHaveBeenCalledWith({ 'U-101': { estatus: 'VENDIDO' } })
|
||||
})
|
||||
|
||||
it('GET /resumen: sólo devuelve los departamentos del rol', async () => {
|
||||
vi.mocked(capturaService.resumenAvance).mockResolvedValue([
|
||||
{ departamento: 'OBRA', camposTotales: 14, camposLlenos: 2, porcentaje: 14 },
|
||||
{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res)
|
||||
|
||||
expect(json).toHaveBeenCalledWith([{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 }])
|
||||
})
|
||||
})
|
||||
|
||||
// ── importarEndpoint ────────────────────────────────────────────────
|
||||
|
||||
describe('importarEndpoint (POST /api/captura/importar)', () => {
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import * as capturaService from '../services/captura-departamento.service.js'
|
||||
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||
import {
|
||||
CAMPOS_POR_DEPARTAMENTO,
|
||||
DEPARTAMENTOS,
|
||||
departamentosDeRol,
|
||||
rolPuedeDepartamento,
|
||||
} from '../config/campos-departamento.js'
|
||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
|
||||
// ── Permisos por rol ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* El rol debe tener permitido el departamento (matriz DEPARTAMENTOS_POR_ROL):
|
||||
* un vendedor sólo captura VENTAS aunque el front la oculte; sin esta guarda
|
||||
* un token de vendedor podía escribir en OBRA o FINANZAS.
|
||||
*/
|
||||
function requireDepartamentoPermitido(rol: string | undefined, departamento: string): void {
|
||||
if (!rolPuedeDepartamento(rol, departamento)) {
|
||||
throw new AppError(403, `Tu rol no tiene acceso al departamento ${departamento}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Campos visibles para el rol (unión de los campos de sus departamentos) */
|
||||
function camposPermitidos(rol: string | undefined): Set<string> {
|
||||
const campos = departamentosDeRol(rol).flatMap((d) => (CAMPOS_POR_DEPARTAMENTO[d] ?? []).map((c) => c.campo))
|
||||
return new Set(campos)
|
||||
}
|
||||
|
||||
/**
|
||||
* La lectura también respeta la matriz: con un token de rol limitado sólo se
|
||||
* devuelven los campos de sus departamentos (el front ya filtra las pestañas,
|
||||
* esto evita que los datos lleguen al cliente).
|
||||
*/
|
||||
function filtrarPorRol(
|
||||
datos: Record<string, Record<string, string | null>>,
|
||||
rol: string | undefined,
|
||||
): Record<string, Record<string, string | null>> {
|
||||
const permitidos = camposPermitidos(rol)
|
||||
const salida: Record<string, Record<string, string | null>> = {}
|
||||
for (const [unidad, campos] of Object.entries(datos)) {
|
||||
const visibles = Object.fromEntries(Object.entries(campos).filter(([campo]) => permitidos.has(campo)))
|
||||
if (Object.keys(visibles).length > 0) salida[unidad] = visibles
|
||||
}
|
||||
return salida
|
||||
}
|
||||
|
||||
// ── Validación de campos por departamento ────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -44,8 +86,15 @@ const listarSchema = z.object({
|
||||
|
||||
export async function listarEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const query = listarSchema.parse(req.query)
|
||||
const rol = req.usuarioToken?.rol
|
||||
if (query.departamento !== undefined) {
|
||||
if (!DEPARTAMENTOS.includes(query.departamento as (typeof DEPARTAMENTOS)[number])) {
|
||||
throw new BadRequestError(`Departamento inválido: ${query.departamento}`)
|
||||
}
|
||||
requireDepartamentoPermitido(rol, query.departamento)
|
||||
}
|
||||
const capturas = await capturaService.obtenerCapturas(query.desarrollo, query.departamento)
|
||||
res.json(capturas)
|
||||
res.json(filtrarPorRol(capturas, rol))
|
||||
}
|
||||
|
||||
// ── GET /api/captura/unidades?desarrollo=X ───────────────────────────
|
||||
@@ -69,7 +118,8 @@ const resumenSchema = z.object({
|
||||
export async function resumenEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const query = resumenSchema.parse(req.query)
|
||||
const resumen = await capturaService.resumenAvance(query.desarrollo)
|
||||
res.json(resumen)
|
||||
const rol = req.usuarioToken?.rol
|
||||
res.json(resumen.filter((r) => rolPuedeDepartamento(rol, r.departamento)))
|
||||
}
|
||||
|
||||
// ── POST /api/captura — guardar campo individual ─────────────────────
|
||||
@@ -89,6 +139,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise<void
|
||||
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
|
||||
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
|
||||
}
|
||||
requireDepartamentoPermitido(req.usuarioToken?.rol, body.departamento)
|
||||
validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
|
||||
|
||||
await capturaService.guardarCampo(
|
||||
@@ -124,6 +175,7 @@ export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
|
||||
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
|
||||
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
|
||||
}
|
||||
requireDepartamentoPermitido(req.usuarioToken?.rol, body.departamento)
|
||||
validarCamposDelDepartamento(
|
||||
body.departamento,
|
||||
body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })),
|
||||
|
||||
@@ -6,6 +6,7 @@ const servicioMock = vi.hoisted(() => ({
|
||||
listar: vi.fn(),
|
||||
obtener: vi.fn(),
|
||||
contar: vi.fn(),
|
||||
catalogos: vi.fn(),
|
||||
exportar: vi.fn(),
|
||||
importarDesdeExcel: vi.fn(),
|
||||
}))
|
||||
@@ -14,6 +15,7 @@ vi.mock('../services/cliente-maestro.service.js', () => servicioMock)
|
||||
|
||||
import {
|
||||
listarEndpoint,
|
||||
catalogosEndpoint,
|
||||
exportarEndpoint,
|
||||
obtenerEndpoint,
|
||||
importarEndpoint,
|
||||
@@ -77,6 +79,23 @@ describe('listarEndpoint', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/clientes-maestro/catalogos ──────────────────────────────
|
||||
|
||||
describe('catalogosEndpoint', () => {
|
||||
it('200 con desarrollos y estatus reales', async () => {
|
||||
servicioMock.catalogos.mockResolvedValue({
|
||||
desarrollos: ['MUTUO - EDIFICACION', 'SOLE'],
|
||||
estatuses: ['ENTREGA'],
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
await catalogosEndpoint(reqFake(), res)
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
desarrollos: ['MUTUO - EDIFICACION', 'SOLE'],
|
||||
estatuses: ['ENTREGA'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/clientes-maestro/exportar ───────────────────────────────
|
||||
|
||||
describe('exportarEndpoint', () => {
|
||||
|
||||
@@ -22,6 +22,12 @@ export async function listarEndpoint(req: Request, res: Response): Promise<void>
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
// ── GET /api/clientes-maestro/catalogos ──────────────────────────────
|
||||
|
||||
export async function catalogosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await clienteMaestroService.catalogos())
|
||||
}
|
||||
|
||||
// ── GET /api/clientes-maestro/exportar ───────────────────────────────
|
||||
|
||||
export async function exportarEndpoint(req: Request, res: Response): Promise<void> {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('formaPagoLabel (tipos de crédito del alta)', () => {
|
||||
|
||||
describe('crearClienteSchema', () => {
|
||||
const base = {
|
||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
|
||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
|
||||
viviendaCredito: { proyecto: 'SENDA' },
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('crearClienteSchema', () => {
|
||||
// ---------- Alta local (POST /api/clientes) ----------
|
||||
|
||||
const altaBase = {
|
||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
|
||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
|
||||
viviendaCredito: { proyecto: 'SENDA' },
|
||||
}
|
||||
|
||||
@@ -124,7 +124,15 @@ const oportunidadLocal = {
|
||||
email: null,
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'SOHO',
|
||||
datosJson: null,
|
||||
// Afluencia completa: el gate de negocio exige el perfil para apartar
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado(a)',
|
||||
tipoEmpleo: 'Asalariado',
|
||||
rangoSalarial: '20,000-40,000',
|
||||
tipoEconomia: 'Economía mexicana',
|
||||
califica: 'Sí',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
}
|
||||
|
||||
describe('crearCliente (POST /api/clientes) — alta local', () => {
|
||||
@@ -352,7 +360,7 @@ describe('alta progresiva — local', () => {
|
||||
mondayApartadoId: null,
|
||||
})
|
||||
const altaCompleta = {
|
||||
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' },
|
||||
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', celular: '6649990003' },
|
||||
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
|
||||
leadMondayId: 'loc_op_9',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import multer from 'multer'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
@@ -20,7 +21,8 @@ export const crearClienteSchema = z
|
||||
curp: z.string().min(1),
|
||||
rfc: z.string().min(1),
|
||||
email: z.string().optional(),
|
||||
celular: z.string().optional(),
|
||||
// Teléfono obligatorio (regla de negocio: sin celular no hay alta)
|
||||
celular: z.string().trim().min(8, 'El celular es obligatorio'),
|
||||
direccion: z.string().optional(),
|
||||
// Perfil demográfico (opcionales; viajan en el payload del alta)
|
||||
fechaNacimiento: z.string().optional(),
|
||||
@@ -525,6 +527,22 @@ async function prefillApartadoLocal(itemId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// Datos del alta (payloadJson) que el prefill del front consume con claves
|
||||
// legacy de Monday: dirección, estado civil y fecha de nacimiento
|
||||
const contactoExtra: Record<string, string> = {}
|
||||
if (cliente?.payloadJson) {
|
||||
try {
|
||||
const payload = JSON.parse(cliente.payloadJson) as { datosGenerales?: Record<string, unknown> }
|
||||
const g = payload.datosGenerales ?? {}
|
||||
const str = (v: unknown): string => (typeof v === 'string' && v.trim() !== '' ? v : '')
|
||||
if (str(g['direccion']) !== '') contactoExtra['Dirección'] = str(g['direccion'])
|
||||
if (str(g['estadoCivil']) !== '') contactoExtra['Estado Civil'] = str(g['estadoCivil'])
|
||||
if (str(g['fechaNacimiento']) !== '') contactoExtra['Fecha Nacimiento'] = str(g['fechaNacimiento'])
|
||||
} catch {
|
||||
// payload inválido: el prefill sale parcial, como antes
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apartado: {
|
||||
id: captura.leadId,
|
||||
@@ -545,6 +563,7 @@ async function prefillApartadoLocal(itemId: string) {
|
||||
Email: cliente.email ?? '',
|
||||
CURP: cliente.curp,
|
||||
RFC: cliente.rfc,
|
||||
...contactoExtra,
|
||||
}
|
||||
: null,
|
||||
oportunidad: oportunidadLocal
|
||||
@@ -558,3 +577,132 @@ async function prefillApartadoLocal(itemId: string) {
|
||||
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Importación de información desde Enkontrol ----------
|
||||
|
||||
/** Upload en memoria del documento de Enkontrol (Excel o CSV, máx 10 MB) */
|
||||
export const uploadImportacionEnkontrol = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
})
|
||||
|
||||
/** Normaliza un encabezado para compararlo: minúsculas, sin acentos, espacios→' ' */
|
||||
function normalizarEncabezado(h: string): string {
|
||||
return h
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/** Encabezados aceptados como ID de Enkontrol (el campo determinante del documento) */
|
||||
const COLUMNAS_ID_ENKONTROL = new Set([
|
||||
'id enkontrol',
|
||||
'cliente enkontrol',
|
||||
'usuario enkontrol',
|
||||
'enkontrol id',
|
||||
'id de enkontrol',
|
||||
'cliente enkontrol id',
|
||||
])
|
||||
|
||||
/** Encabezado normalizado → columna del Cliente que actualiza */
|
||||
const COLUMNAS_CLIENTE: Record<string, 'nombreCompleto' | 'curp' | 'rfc' | 'email' | 'celular' | 'proyecto' | 'unidadCodigo'> = {
|
||||
'nombre': 'nombreCompleto',
|
||||
'nombre completo': 'nombreCompleto',
|
||||
'cliente': 'nombreCompleto',
|
||||
'curp': 'curp',
|
||||
'rfc': 'rfc',
|
||||
'email': 'email',
|
||||
'correo': 'email',
|
||||
'correo electronico': 'email',
|
||||
'celular': 'celular',
|
||||
'telefono': 'celular',
|
||||
'telefono celular': 'celular',
|
||||
'proyecto': 'proyecto',
|
||||
'desarrollo': 'proyecto',
|
||||
'unidad': 'unidadCodigo',
|
||||
'ubicacion': 'unidadCodigo',
|
||||
'unidad codigo': 'unidadCodigo',
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clientes/importar-enkontrol — importa un documento (Excel/CSV) de
|
||||
* Enkontrol usando el ID de Enkontrol como campo determinante de cada cliente:
|
||||
* cada fila se empareja con la ficha local cuyo alta tenga ese usuarioEnkontrol
|
||||
* y su información completa se guarda en el payload de la ficha. Las columnas
|
||||
* conocidas (nombre, CURP, RFC, email, celular, proyecto, unidad) actualizan
|
||||
* también los campos directos del Cliente.
|
||||
*/
|
||||
export async function importarEnkontrolEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const archivo = (req as Request & { file?: Express.Multer.File }).file
|
||||
if (!archivo) throw new BadRequestError('Adjunta el archivo Excel o CSV de Enkontrol')
|
||||
|
||||
let filas: Record<string, unknown>[]
|
||||
try {
|
||||
const libro = XLSX.read(archivo.buffer, { type: 'buffer' })
|
||||
const hoja = libro.Sheets[libro.SheetNames[0] ?? '']
|
||||
if (!hoja) throw new Error('sin hojas')
|
||||
filas = XLSX.utils.sheet_to_json<Record<string, unknown>>(hoja, { defval: '' })
|
||||
} catch {
|
||||
throw new BadRequestError('No se pudo leer el documento: usa un Excel (.xlsx) o CSV válido')
|
||||
}
|
||||
if (filas.length === 0) throw new BadRequestError('El documento no contiene filas de datos')
|
||||
|
||||
const encabezados = Object.keys(filas[0]!)
|
||||
const columnaId = encabezados.find((h) => COLUMNAS_ID_ENKONTROL.has(normalizarEncabezado(h)))
|
||||
if (!columnaId) {
|
||||
throw new BadRequestError(
|
||||
`No encontré la columna del ID de Enkontrol (encabezados: ${encabezados.slice(0, 8).join(', ')})`,
|
||||
)
|
||||
}
|
||||
|
||||
// Índice de clientes locales por ID de Enkontrol (payloadJson.datosGenerales.usuarioEnkontrol)
|
||||
const clientes = await prisma.cliente.findMany({ select: { id: true, nombreCompleto: true, payloadJson: true } })
|
||||
const porEnkontrol = new Map<string, { id: string; nombreCompleto: string; payloadJson: string }>()
|
||||
for (const c of clientes) {
|
||||
try {
|
||||
const payload = JSON.parse(c.payloadJson) as { datosGenerales?: { usuarioEnkontrol?: unknown } }
|
||||
const idE = payload.datosGenerales?.usuarioEnkontrol
|
||||
if (typeof idE === 'string' && idE.trim() !== '') {
|
||||
porEnkontrol.set(idE.trim().toUpperCase(), c)
|
||||
}
|
||||
} catch { /* payload ilegible: se omite del índice */ }
|
||||
}
|
||||
|
||||
let actualizados = 0
|
||||
const noEncontrados: string[] = []
|
||||
const ahora = new Date().toISOString()
|
||||
for (const fila of filas) {
|
||||
const idE = String(fila[columnaId] ?? '').trim()
|
||||
if (idE === '') continue
|
||||
const cliente = porEnkontrol.get(idE.toUpperCase())
|
||||
if (!cliente) {
|
||||
noEncontrados.push(idE)
|
||||
continue
|
||||
}
|
||||
let payload: Record<string, unknown> = {}
|
||||
try { payload = JSON.parse(cliente.payloadJson) as Record<string, unknown> } catch { payload = {} }
|
||||
const data: Record<string, unknown> = {
|
||||
payloadJson: JSON.stringify({ ...payload, importacionEnkontrol: { fecha: ahora, datos: fila } }),
|
||||
}
|
||||
for (const [encabezado, valor] of Object.entries(fila)) {
|
||||
const columna = COLUMNAS_CLIENTE[normalizarEncabezado(encabezado)]
|
||||
const texto = String(valor ?? '').trim()
|
||||
if (columna !== undefined && texto !== '') data[columna] = texto
|
||||
}
|
||||
await prisma.cliente.update({ where: { id: cliente.id }, data })
|
||||
actualizados++
|
||||
}
|
||||
|
||||
res.json({ totalFilas: filas.length, actualizados, noEncontrados })
|
||||
auditoria.registrar(
|
||||
req.usuarioToken?.sub ?? 'sistema',
|
||||
'IMPORTAR_ENKONTROL',
|
||||
'cliente',
|
||||
archivo.originalname,
|
||||
`${actualizados} actualizados, ${noEncontrados.length} sin ficha local`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
unidad: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
capturaDepartamento: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
leadCaptura: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
@@ -30,12 +33,17 @@ const prismaMock = vi.hoisted(() => ({
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
// Reglas comerciales configurables por el admin (vacías = constantes)
|
||||
reglaCotizadorDesarrollo: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
|
||||
import { DESARROLLOS, calcularNumMensualidades } from '../services/cotizacion.service.js'
|
||||
|
||||
/**
|
||||
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
|
||||
@@ -61,7 +69,28 @@ const bodyBase = {
|
||||
acabados: false,
|
||||
clienteNombre: 'Cliente Demo',
|
||||
clienteTelefono: '4771234567',
|
||||
tipoPropuesta: 'BANCARIO' as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Esquema único FRASA 2026 esperado para filaUnidad (precio $1,000,000) con
|
||||
* los defaults del body: apartado $15,000, N=14, escrituración a la firma.
|
||||
* Caso dorado del cliente: hoy $15,000 · firma $65,000 · 14 × $5,357.14.
|
||||
*/
|
||||
const esquemaDorado = {
|
||||
esquema: 'FRASA_2026',
|
||||
apartado: 15000,
|
||||
saldoFirma: 10000,
|
||||
engancheDiferible: 75000,
|
||||
escrituracion: 55000,
|
||||
escrituracionDiferida: false,
|
||||
tipoPropuesta: 'FRASA_2026',
|
||||
descuento: 0,
|
||||
precioLista: 1_000_000,
|
||||
precioFinal: 1_000_000,
|
||||
enganche: 75000,
|
||||
numMensualidades: 14,
|
||||
mensualidad: 75000 / 14,
|
||||
pagoFinal: 65000,
|
||||
}
|
||||
|
||||
function reqFake(body: unknown): Request {
|
||||
@@ -77,6 +106,7 @@ function resFake() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
@@ -87,6 +117,7 @@ beforeEach(() => {
|
||||
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
})
|
||||
|
||||
@@ -100,10 +131,54 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
|
||||
expect(respuesta['propuesta']).toBeDefined()
|
||||
expect(respuesta['esquema']).toBeDefined()
|
||||
expect(respuesta['folio']).toBeDefined()
|
||||
})
|
||||
|
||||
it('caso dorado: recalcula el esquema FRASA 2026 server-side y persiste columnas nuevas y legacy', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining(esquemaDorado) })
|
||||
const respuesta = json.mock.calls[0]?.[0] as {
|
||||
esquema: { saldoFirma: number; pagoUnicoFirma: number; mensualidad: number; baseDiferible: number }
|
||||
}
|
||||
expect(respuesta.esquema.saldoFirma).toBe(10000)
|
||||
expect(respuesta.esquema.pagoUnicoFirma).toBe(65000)
|
||||
expect(respuesta.esquema.baseDiferible).toBe(75000)
|
||||
expect(respuesta.esquema.mensualidad).toBeCloseTo(75000 / 14, 2)
|
||||
})
|
||||
|
||||
it('escrituración diferida: entra al diferible y baja el pago a la firma', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, escrituracionDiferida: true }), res)
|
||||
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
escrituracionDiferida: true,
|
||||
pagoFinal: 10000,
|
||||
mensualidad: 130000 / 14,
|
||||
}),
|
||||
})
|
||||
const respuesta = json.mock.calls[0]?.[0] as { esquema: { baseDiferible: number } }
|
||||
expect(respuesta.esquema.baseDiferible).toBe(130000)
|
||||
})
|
||||
|
||||
it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, apartado: 30000 }), res)
|
||||
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ apartado: 30000, saldoFirma: 0, enganche: 70000, pagoFinal: 55000 }),
|
||||
})
|
||||
const respuesta = json.mock.calls[0]?.[0] as { esquema: { excedenteApartado: number; totalPagado: number } }
|
||||
expect(respuesta.esquema.excedenteApartado).toBe(5000)
|
||||
expect(respuesta.esquema.totalPagado).toBeCloseTo(155000, 2)
|
||||
})
|
||||
|
||||
it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(null)
|
||||
const { res } = resFake()
|
||||
@@ -184,6 +259,210 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
|
||||
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sin numMensualidades usa el default (14), acotado a los meses que faltan para la entrega', async () => {
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
const esperadas = Math.min(14, calcularNumMensualidades('2027-10-08'))
|
||||
const respuesta = json.mock.calls[0]?.[0] as {
|
||||
numMensualidades: number
|
||||
mensualidad: number
|
||||
esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number }
|
||||
}
|
||||
expect(respuesta.numMensualidades).toBe(esperadas)
|
||||
expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades)
|
||||
expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / esperadas, 6)
|
||||
})
|
||||
|
||||
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
|
||||
|
||||
// Enganche diferible $75,000 en 12 pagos
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }),
|
||||
})
|
||||
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
|
||||
expect(respuesta.numMensualidades).toBe(12)
|
||||
expect(respuesta.esquema.numMensualidades).toBe(12)
|
||||
})
|
||||
|
||||
it('rechaza mensualidades fuera de rango (1..36) sin persistir', async () => {
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 48 }), res)).rejects.toBeDefined()
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('acota las mensualidades a los meses que faltan para la entrega estimada de la unidad', async () => {
|
||||
// La captura de obra marca la entrega de U-101 a ~6 meses (fija vs hoy)
|
||||
const entrega = new Date(Date.now() + 6 * 30.4 * 86400000).toISOString().slice(0, 10)
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: entrega },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 20 }), res)
|
||||
|
||||
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
|
||||
expect(respuesta.numMensualidades).toBe(6)
|
||||
expect(respuesta.esquema.numMensualidades).toBe(6)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ numMensualidades: 6 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('persiste y devuelve las opciones de crédito que manda el cotizador', async () => {
|
||||
const opcionesCredito = JSON.stringify({
|
||||
elegida: 'CONTADO',
|
||||
ajustes: {
|
||||
CREDITO_BANCARIO: { tasaAnual: 0.105, plazoAnos: 20 },
|
||||
CREDITO_INFONAVIT_COFINAVIT: { tasaAnual: 0.105, plazoAnos: 25 },
|
||||
SEMI_CONTADO: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 },
|
||||
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
|
||||
},
|
||||
})
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, opcionesCredito }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ opcionesCredito }),
|
||||
})
|
||||
// La respuesta del POST incluye el campo (los GET devuelven la fila completa)
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ opcionesCredito })
|
||||
})
|
||||
|
||||
it('sin opciones de crédito persiste null', async () => {
|
||||
const { res } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ opcionesCredito: null }),
|
||||
})
|
||||
})
|
||||
|
||||
it('rechaza opciones de crédito que exceden 4000 caracteres', async () => {
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(
|
||||
crearCotizacion(reqFake({ ...bodyBase, opcionesCredito: 'x'.repeat(4001) }), res),
|
||||
).rejects.toBeDefined()
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => {
|
||||
prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' })
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(crearCotizacion(reqFake({ ...bodyBase, unidadCodigo: 'ATELIER-10' }), res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
})
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearCotizacion — reglas comerciales del admin', () => {
|
||||
/** Reglas guardadas del desarrollo, con los ajustes del test */
|
||||
function reglasGuardadas(
|
||||
desarrollo: string,
|
||||
ajustes: { propuestaInactiva?: string; activoEnCotizador?: boolean; descuentoContado?: number } = {},
|
||||
) {
|
||||
const base = DESARROLLOS[desarrollo]!
|
||||
return [
|
||||
{
|
||||
desarrolloId: desarrollo,
|
||||
parametrosJson: JSON.stringify({
|
||||
...base,
|
||||
...(ajustes.activoEnCotizador !== undefined
|
||||
? { activoEnCotizador: ajustes.activoEnCotizador }
|
||||
: {}),
|
||||
propuestas: base.propuestas.map((p) => ({
|
||||
...p,
|
||||
activa: p.tipo !== ajustes.propuestaInactiva,
|
||||
...(p.tipo === 'CONTADO' && ajustes.descuentoContado !== undefined
|
||||
? { descuentoDefault: ajustes.descuentoContado }
|
||||
: {}),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
it('sin reglas guardadas cotiza con el esquema único (respaldo en constantes)', async () => {
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0, precioFinal: 1_000_000 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('los descuentos que configure el admin YA NO aplican: el esquema único no los usa', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
|
||||
)
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('propuesta desactivada por el admin ya NO bloquea: el esquema único no usa propuestas', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
|
||||
)
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||
reglasGuardadas('DIAGONAL', { activoEnCotizador: false }),
|
||||
)
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining('no está disponible en el cotizador'),
|
||||
})
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('JSON corrupto en la BD: cotiza con las constantes (un warning, sin fallar)', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: '{corrupto' },
|
||||
])
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ descuento: 0, precioLista: 1_000_000 }),
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
|
||||
@@ -237,3 +516,27 @@ describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario)
|
||||
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearCotizacion — enganche adicional', () => {
|
||||
it('persiste el enganche adicional y recalcula el saldo a financiar', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, engancheAdicional: 400000 }), res)
|
||||
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ engancheAdicional: 400000 }),
|
||||
})
|
||||
const respuesta = json.mock.calls[0]?.[0] as { esquema: { engancheAdicional: number; saldoAFinanciar: number } }
|
||||
expect(respuesta.esquema.engancheAdicional).toBe(400000)
|
||||
expect(respuesta.esquema.saldoAFinanciar).toBeCloseTo(445000, 2)
|
||||
})
|
||||
|
||||
it('rechaza con 400 un adicional que pase del 90% de enganche total', async () => {
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(
|
||||
crearCotizacion(reqFake({ ...bodyBase, engancheAdicional: 900000 }), res),
|
||||
).rejects.toBeDefined()
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { getUnidadLocal } from '../services/unidades-local.service.js'
|
||||
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import { calcularNumMensualidades, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import {
|
||||
calcularEsquemaFrasa,
|
||||
MENSUALIDADES_DEFAULT,
|
||||
MENSUALIDADES_MAX,
|
||||
MENSUALIDADES_MIN,
|
||||
PCT_ENGANCHE_FIRMA,
|
||||
PCT_ESCRITURACION,
|
||||
type EsquemaFrasaResultado,
|
||||
} from '../services/cotizacion-esquema.service.js'
|
||||
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
|
||||
import {
|
||||
procesarAlertasInventario,
|
||||
type ResultadoAlertas,
|
||||
@@ -9,7 +19,7 @@ import {
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||
import { actividadLocalService } from '../services/actividad-local.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
import { NotFoundError, AppError } from '../utils/errors.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
|
||||
|
||||
@@ -21,16 +31,42 @@ const crearCotizacionSchema = z.object({
|
||||
clienteNombre: z.string().min(1),
|
||||
clienteEmail: z.string().email().optional(),
|
||||
clienteTelefono: z.string().min(7).optional(),
|
||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
||||
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
|
||||
// esquemas con crédito (bancario/Infonavit)
|
||||
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
|
||||
// Esquema único FRASA 2026: mensualidades del diferimiento (elección del
|
||||
// vendedor, 1..36; sin ellas va el default de 14) y si la escrituración
|
||||
// (5.5%) se difiere con el enganche o se paga a la firma
|
||||
numMensualidades: z
|
||||
.number()
|
||||
.int('Las mensualidades deben ser un número entero')
|
||||
.min(MENSUALIDADES_MIN, `Mínimo ${MENSUALIDADES_MIN} mensualidad`)
|
||||
.max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`)
|
||||
.optional(),
|
||||
escrituracionDiferida: z.boolean().optional(),
|
||||
// Enganche adicional (anticipo extra): el total de anticipos (esquema +
|
||||
// adicional) no puede superar el 90% del precio; el espejo lo valida
|
||||
engancheAdicional: z.number().min(0, 'El enganche adicional no puede ser negativo').optional(),
|
||||
// Opciones de credito del saldo (84.5%): JSON serializado {elegida, ajustes}
|
||||
// que manda el cotizador; se persiste crudo y se devuelve en los GET
|
||||
opcionesCredito: z.string().max(4000).optional(),
|
||||
// Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi
|
||||
// día manda el id; sin él se busca por teléfono)
|
||||
oportunidadId: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
/** Apartado por defecto del esquema único (el cotizador arranca con el mismo valor) */
|
||||
const APARTADO_DEFAULT = 15000
|
||||
|
||||
/** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */
|
||||
function validarUnidadDisponible(unidad: { codigo: string; status: string }): void {
|
||||
const status = unidad.status.trim().toUpperCase()
|
||||
if (status !== 'DISPONIBLE') {
|
||||
throw new AppError(
|
||||
409,
|
||||
`La unidad ${unidad.codigo} no está disponible (estatus: ${unidad.status.trim() || 'desconocido'}). ` +
|
||||
'Elige otra unidad del inventario disponible.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo
|
||||
* criterio que el funnel: primero el mapping histórico (lead → su
|
||||
@@ -57,10 +93,48 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
// Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe
|
||||
const unidad = await getUnidadLocal(body.unidadCodigo)
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
||||
// Solo se cotiza inventario disponible: evita cotizar (y apartar) dos veces
|
||||
// el mismo inmueble ya apartado/vendido
|
||||
validarUnidadDisponible(unidad)
|
||||
|
||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
||||
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct)
|
||||
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
|
||||
// respaldo en las constantes históricas. Un desarrollo desactivado se
|
||||
// rechaza con el mensaje que muestra el cotizador. El esquema único
|
||||
// FRASA 2026 ya no depende de propuestas (las 4 propuestas desaparecieron).
|
||||
const reglas = await getReglasDesarrollo(unidad.desarrollo)
|
||||
if (reglas.activoEnCotizador === false) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
|
||||
)
|
||||
}
|
||||
|
||||
// El API RECALCULA el esquema server-side con el precio de lista de la
|
||||
// unidad: no se confía en los montos que manda el cliente.
|
||||
const apartado = body.apartado ?? APARTADO_DEFAULT
|
||||
// El diferimiento no puede rebasar la entrega estimada de la unidad (captura
|
||||
// de obra): se acota a los meses que faltan (36 sigue siendo el tope global)
|
||||
const topeMensualidades = unidad.fechaEntrega
|
||||
? Math.min(MENSUALIDADES_MAX, Math.max(MENSUALIDADES_MIN, calcularNumMensualidades(unidad.fechaEntrega)))
|
||||
: MENSUALIDADES_MAX
|
||||
const numMensualidades = Math.min(body.numMensualidades ?? MENSUALIDADES_DEFAULT, topeMensualidades)
|
||||
const escrituracionDiferida = body.escrituracionDiferida ?? false
|
||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
||||
// Enganche adicional: el total de anticipos no puede pasar del 90% del
|
||||
// precio (espejo valida; se traduce a 400 con el mensaje del negocio)
|
||||
const esquema: EsquemaFrasaResultado = (() => {
|
||||
try {
|
||||
return calcularEsquemaFrasa({
|
||||
precio: precioLista,
|
||||
apartado,
|
||||
numMensualidades,
|
||||
escrituracionDiferida,
|
||||
...(body.engancheAdicional !== undefined ? { engancheAdicional: body.engancheAdicional } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new AppError(400, err instanceof Error ? err.message : 'Datos del esquema inválidos')
|
||||
}
|
||||
})()
|
||||
|
||||
const cotizacion = await prisma.cotizacion.create({
|
||||
data: {
|
||||
@@ -70,16 +144,30 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
clienteNombre: body.clienteNombre,
|
||||
clienteEmail: body.clienteEmail ?? null,
|
||||
clienteTelefono: body.clienteTelefono ?? null,
|
||||
tipoPropuesta: body.tipoPropuesta,
|
||||
acabados: body.acabados,
|
||||
apartado,
|
||||
descuento: propuesta.descuento,
|
||||
precioLista,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
enganche: propuesta.enganche,
|
||||
numMensualidades: propuesta.numMensualidades ?? 0, // 0 => plazo desconocido (sin fecha de entrega)
|
||||
mensualidad: propuesta.mensualidad,
|
||||
pagoFinal: propuesta.pagoFinal,
|
||||
// Columnas nuevas del esquema único
|
||||
esquema: 'FRASA_2026',
|
||||
saldoFirma: esquema.saldoFirma,
|
||||
pctEngancheFirma: PCT_ENGANCHE_FIRMA,
|
||||
engancheDiferible: esquema.enganche,
|
||||
pctEscrituracion: PCT_ESCRITURACION,
|
||||
escrituracionDiferida,
|
||||
escrituracion: esquema.escrituracion,
|
||||
engancheAdicional: esquema.engancheAdicional,
|
||||
// Opciones de credito elegidas por el vendedor (JSON crudo)
|
||||
opcionesCredito: body.opcionesCredito ?? null,
|
||||
// Columnas legacy, rellenadas para compatibilidad de reportes:
|
||||
// sin descuentos en el esquema único, el precio final es el de lista y
|
||||
// el "pago final" es todo lo que se liquida a la firma.
|
||||
tipoPropuesta: 'FRASA_2026',
|
||||
descuento: 0,
|
||||
precioFinal: precioLista,
|
||||
enganche: esquema.enganche,
|
||||
numMensualidades,
|
||||
mensualidad: esquema.mensualidad,
|
||||
pagoFinal: esquema.pagoUnicoFirma,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -96,8 +184,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
mondayItemId = opExistente
|
||||
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
||||
try {
|
||||
// sombra:false: sólo se marca la etapa si el id corresponde a una
|
||||
// oportunidad real. Antes (sombra para ids no locales) elegir un cliente
|
||||
// del maestro en el buscador del cotizador creaba un LeadCaptura
|
||||
// fantasma ("Registro previo (CRM anterior)") con pendienteSync.
|
||||
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
|
||||
sombra: !esIdLocal(opExistente),
|
||||
sombra: false,
|
||||
usuarioId: req.usuarioToken?.sub,
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -106,7 +198,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, esquema })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
|
||||
}
|
||||
|
||||
@@ -162,9 +254,18 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro
|
||||
folio: folioCotizacion(cotizacion.id),
|
||||
desarrollo: cotizacion.desarrollo,
|
||||
unidadCodigo: cotizacion.unidadCodigo,
|
||||
// Esquema único FRASA 2026 (filas nuevas) + legacy (filas BANCARIO, …)
|
||||
esquema: cotizacion.esquema,
|
||||
tipoPropuesta: cotizacion.tipoPropuesta,
|
||||
precioLista: cotizacion.precioLista,
|
||||
apartado: cotizacion.apartado,
|
||||
saldoFirma: cotizacion.saldoFirma,
|
||||
engancheDiferible: cotizacion.engancheDiferible,
|
||||
escrituracion: cotizacion.escrituracion,
|
||||
escrituracionDiferida: cotizacion.escrituracionDiferida,
|
||||
precioFinal: cotizacion.precioFinal,
|
||||
enganche: cotizacion.enganche,
|
||||
numMensualidades: cotizacion.numMensualidades,
|
||||
mensualidad: cotizacion.mensualidad,
|
||||
pagoFinal: cotizacion.pagoFinal,
|
||||
vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(),
|
||||
|
||||
524
api-gateway/src/controllers/desarrollos.controller.test.ts
Normal file
524
api-gateway/src/controllers/desarrollos.controller.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// ---------- Prisma mockeado (el servicio corre de verdad) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
desarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
modeloDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
reglaCotizadorDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
unidad: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
cotizacion: { count: vi.fn(), findMany: vi.fn() },
|
||||
leadCaptura: { count: vi.fn(), findMany: vi.fn() },
|
||||
cliente: { count: vi.fn(), findMany: vi.fn() },
|
||||
planPago: { count: vi.fn(), findMany: vi.fn() },
|
||||
auditoriaLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { requireAdminEstricto } from './config.controller.js'
|
||||
import {
|
||||
actualizarDesarrolloEndpoint,
|
||||
agregarModeloEndpoint,
|
||||
crearDesarrolloEndpoint,
|
||||
crearUnidadesEndpoint,
|
||||
eliminarDesarrolloEndpoint,
|
||||
eliminarUnidadEndpoint,
|
||||
guardarReglasEndpoint,
|
||||
listarDesarrollosEndpoint,
|
||||
listarDesarrollosPublicosEndpoint,
|
||||
obtenerDesarrolloPublicoEndpoint,
|
||||
} from './desarrollos.controller.js'
|
||||
|
||||
function reqFake(opts: { body?: unknown; params?: Record<string, string>; query?: Record<string, string>; token?: { sub: string; rol: string } }): Request {
|
||||
return {
|
||||
body: opts.body ?? {},
|
||||
params: opts.params ?? {},
|
||||
query: opts.query ?? {},
|
||||
usuarioToken: opts.token,
|
||||
} as unknown as Request
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const end = vi.fn()
|
||||
const statusFn = vi.fn().mockReturnValue({ json, end })
|
||||
return { res: { json, end, status: statusFn } as unknown as Response, status: statusFn, json, end }
|
||||
}
|
||||
|
||||
const ADMIN = { sub: 'u-admin', rol: 'admin' }
|
||||
|
||||
function filaDesarrollo(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd-1',
|
||||
clave: 'SENDA',
|
||||
nombre: 'Senda',
|
||||
estado: 'ACTIVO',
|
||||
tipo: 'VERTICAL',
|
||||
constructora: null,
|
||||
direccion: null,
|
||||
zona: 'Zapopan',
|
||||
descripcion: null,
|
||||
amenidades: null,
|
||||
fechaInicioObra: null,
|
||||
fechaEntregaProgramada: null,
|
||||
fechaEntregaReprogramada: null,
|
||||
activoEnCotizador: true,
|
||||
activoEnAlta: true,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
imagenUrl: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
modelos: [],
|
||||
reglas: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function filaUnidad(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'u-1',
|
||||
codigo: 'SENDA-1',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'HIMALAYA',
|
||||
manzana: '',
|
||||
lote: '',
|
||||
nivel: '',
|
||||
supConstruccion: 120,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 1,
|
||||
precioLista: 3000000,
|
||||
precioAcabados: 3200000,
|
||||
status: 'DISPONIBLE',
|
||||
etapa: '',
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.count.mockResolvedValue(0)
|
||||
prismaMock.cotizacion.count.mockResolvedValue(0)
|
||||
prismaMock.leadCaptura.count.mockResolvedValue(0)
|
||||
prismaMock.cliente.count.mockResolvedValue(0)
|
||||
prismaMock.planPago.count.mockResolvedValue(0)
|
||||
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: `id-${String(data['codigo'])}`,
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...data,
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Permisos ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdminEstricto', () => {
|
||||
it('deja pasar al admin', () => {
|
||||
const next = vi.fn()
|
||||
requireAdminEstricto(reqFake({ token: ADMIN }), {} as Response, next)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('403 para manager y para vendedor', () => {
|
||||
for (const rol of ['manager', 'vendedor', 'coordinador', 'corporativo']) {
|
||||
expect(() => requireAdminEstricto(reqFake({ token: { sub: 'u-1', rol } }), {} as Response, vi.fn())).toThrow(
|
||||
'Solo un administrador puede hacer esta operación',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('403 sin token', () => {
|
||||
expect(() => requireAdminEstricto(reqFake({}), {} as Response, vi.fn())).toThrow()
|
||||
})
|
||||
|
||||
it('los endpoints de escritura quedan detrás del guard (403 con rol manager)', async () => {
|
||||
// El guard se monta en las rutas; aquí se comprueba que corta antes del servicio
|
||||
const req = reqFake({ token: { sub: 'u-1', rol: 'manager' }, body: { clave: 'NUEVO', nombre: 'Nuevo' } })
|
||||
expect(() => requireAdminEstricto(req, {} as Response, vi.fn())).toThrow()
|
||||
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Fichas ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/desarrollos', () => {
|
||||
it('201 con la ficha cuando la clave es nueva', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(filaDesarrollo())
|
||||
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ accion: 'CREAR_DESARROLLO', entidad: 'desarrollo', usuarioId: 'u-admin' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('409 con clave duplicada y sin crear', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
})
|
||||
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si la clave no es mayúsculas sin espacios', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(
|
||||
crearDesarrolloEndpoint(reqFake({ body: { clave: 'senda nueva', nombre: 'Senda' }, token: ADMIN }), res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/desarrollos y detalle', () => {
|
||||
it('devuelve el contrato del listado', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({
|
||||
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
|
||||
reglas: { desarrolloId: 'd-1', parametrosJson: '{}', updatedAt: new Date(), updatedPorId: null },
|
||||
}),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
|
||||
{ desarrollo: 'SENDA', status: 'APARTADO' },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarDesarrollosEndpoint(reqFake({ token: ADMIN }), res)
|
||||
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
desarrollos: [
|
||||
expect.objectContaining({
|
||||
clave: 'SENDA',
|
||||
unidadesTotales: 2,
|
||||
unidadesDisponibles: 1,
|
||||
tieneReglas: true,
|
||||
modelos: [
|
||||
{ id: 'm-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/desarrollos/:id', () => {
|
||||
it('404 si no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(actualizarDesarrolloEndpoint(reqFake({ params: { id: 'nope' }, body: { zona: 'X' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('actualiza solo los campos enviados', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' }).mockResolvedValueOnce(filaDesarrollo({ zona: 'Tlajomulco' }))
|
||||
prismaMock.desarrollo.update.mockResolvedValue(filaDesarrollo())
|
||||
const { res, json } = resFake()
|
||||
|
||||
await actualizarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, body: { zona: 'Tlajomulco' }, token: ADMIN }), res)
|
||||
|
||||
expect(prismaMock.desarrollo.update).toHaveBeenCalledWith({ where: { id: 'd-1' }, data: { zona: 'Tlajomulco' } })
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ zona: 'Tlajomulco' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/desarrollos/:id', () => {
|
||||
it('409 con inventario y operación; 204 sin dependencias', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
|
||||
prismaMock.cotizacion.count.mockResolvedValue(3)
|
||||
|
||||
await expect(eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: 'El desarrollo tiene 2 unidades y 3 registros de operación; desactívalo en vez de borrarlo',
|
||||
})
|
||||
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('204 y auditoría cuando el desarrollo está limpio', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
const { res, status } = resFake()
|
||||
|
||||
await eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(204)
|
||||
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ accion: 'ELIMINAR_DESARROLLO', entidadId: 'd-1' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Modelos y reglas ──────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/desarrollos/:id/modelos', () => {
|
||||
it('201 con el modelo creado', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue(null)
|
||||
prismaMock.modeloDesarrollo.create.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'PANORAMA' })
|
||||
const { res, status } = resFake()
|
||||
|
||||
await agregarModeloEndpoint(reqFake({ params: { id: 'd-1' }, body: { nombre: 'PANORAMA', recamaras: 3 }, token: ADMIN }), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/desarrollos/:id/reglas', () => {
|
||||
const reglas = {
|
||||
precioUnico: false,
|
||||
fuenteDeN: 'fechaEntrega',
|
||||
nDefault: 13,
|
||||
cuotaMantenimiento: 1500,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
propuestas: [
|
||||
{
|
||||
tipo: 'CREDITO_BANCARIO',
|
||||
claveDescuento: 'creditoBancario',
|
||||
titulo: 'Crédito Bancario',
|
||||
descuentoDefault: 0,
|
||||
pctEnganche: 0.025,
|
||||
engancheBase: 'lista',
|
||||
pctPagoFinal: 0.9,
|
||||
pagoFinalBase: 'final',
|
||||
modoMensualidades: 'remanente',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('guarda y devuelve las reglas parseadas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
|
||||
desarrolloId: 'd-1',
|
||||
parametrosJson: JSON.stringify(reglas),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedPorId: 'u-admin',
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
|
||||
await guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: reglas, token: ADMIN }), res)
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ desarrolloId: 'd-1', parametros: expect.objectContaining({ nDefault: 13 }) }))
|
||||
})
|
||||
|
||||
it('400 si un porcentaje se sale de 0..1', async () => {
|
||||
const malas = { ...reglas, propuestas: [{ ...reglas.propuestas[0], pctPagoFinal: 1.2 }] }
|
||||
await expect(guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: malas, token: ADMIN }), resFake().res)).rejects.toThrow()
|
||||
expect(prismaMock.reglaCotizadorDesarrollo.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/desarrollos/:id/unidades', () => {
|
||||
it('genera el rango con {clave} y {n}: 201 con las creadas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearUnidadesEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'd-1' },
|
||||
body: { generar: { patron: 'SENDA-401-{n}', desde: 1, hasta: 3, precioLista: 2000000 } },
|
||||
token: ADMIN,
|
||||
}),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
creadas: 3,
|
||||
unidades: expect.arrayContaining([expect.objectContaining({ codigo: 'SENDA-401-1', status: 'DISPONIBLE' })]),
|
||||
})
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ accion: 'CREAR_UNIDADES', entidad: 'unidad' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('{clave} se sustituye por la clave del desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
const { json } = resFake()
|
||||
|
||||
await crearUnidadesEndpoint(
|
||||
reqFake({ params: { id: 'd-1' }, body: { generar: { patron: '{clave}-{n}', desde: 7, hasta: 8, precioLista: 1 } }, token: ADMIN }),
|
||||
resFake().res,
|
||||
)
|
||||
void json
|
||||
const creadas = prismaMock.unidad.create.mock.calls.map((c) => (c[0] as { data: { codigo: string } }).data.codigo)
|
||||
expect(creadas).toEqual(['SENDA-7', 'SENDA-8'])
|
||||
})
|
||||
|
||||
it('409 sin crear nada si algún código ya existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ codigo: 'SENDA-5' }])
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(
|
||||
crearUnidadesEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'd-1' },
|
||||
body: {
|
||||
unidades: [
|
||||
{ codigo: 'SENDA-5', precioLista: 1 },
|
||||
{ codigo: 'SENDA-6', precioLista: 1 },
|
||||
],
|
||||
},
|
||||
token: ADMIN,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('409 si el mismo código se repite en la alta', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
|
||||
await expect(
|
||||
crearUnidadesEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'd-1' },
|
||||
body: { unidades: [{ codigo: 'SENDA-1', precioLista: 1 }, { codigo: 'SENDA-1', precioLista: 2 }] },
|
||||
token: ADMIN,
|
||||
}),
|
||||
resFake().res,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
|
||||
it('404 si el desarrollo no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(
|
||||
crearUnidadesEndpoint(reqFake({ params: { id: 'nope' }, body: { generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }, token: ADMIN }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/desarrollos/:id/unidades/:codigo', () => {
|
||||
it('409 si la unidad tiene cotizaciones o ventas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
prismaMock.planPago.count.mockResolvedValue(1)
|
||||
|
||||
await expect(
|
||||
eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('204 si la unidad está libre', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
const { res, status } = resFake()
|
||||
|
||||
await eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), res)
|
||||
expect(status).toHaveBeenCalledWith(204)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Público (sin auth) ────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/publico/desarrollos', () => {
|
||||
it('publica solo el desarrollo activo con inventario disponible y sin datos personales', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({
|
||||
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
|
||||
}),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
filaUnidad({ codigo: 'SENDA-1', nivel: '3' }),
|
||||
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 5000000 }),
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarDesarrollosPublicosEndpoint(reqFake({}), res)
|
||||
|
||||
const payload = (json.mock.calls[0] as [ { desarrollos: Record<string, unknown>[] } ])[0]
|
||||
const desarrollo = payload.desarrollos[0] as Record<string, unknown>
|
||||
|
||||
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
|
||||
)
|
||||
expect(Object.keys(desarrollo).sort()).toEqual(
|
||||
[
|
||||
'amenidades',
|
||||
'clave',
|
||||
'descripcion',
|
||||
'direccion',
|
||||
'estado',
|
||||
'fechaEntregaProgramada',
|
||||
'imagenUrl',
|
||||
'logoUrl',
|
||||
'modelos',
|
||||
'nombre',
|
||||
'tipo',
|
||||
'unidadesDisponibles',
|
||||
'zona',
|
||||
].sort(),
|
||||
)
|
||||
expect(desarrollo['unidadesDisponibles']).toEqual([
|
||||
{ codigo: 'SENDA-1', modelo: 'HIMALAYA', nivel: '3', precioLista: 3000000, precioAcabados: 3200000, supConstruccion: 120 },
|
||||
])
|
||||
|
||||
// El camino público nunca consulta clientes, cotizaciones ni planes
|
||||
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.cliente.findMany).not.toHaveBeenCalled()
|
||||
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.planPago.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.leadCaptura.count).not.toHaveBeenCalled()
|
||||
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado', 'rfc']) {
|
||||
expect(JSON.stringify(payload).toLowerCase()).not.toContain(prohibido)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/publico/desarrollos/:clave', () => {
|
||||
it('404 si no existe o no es público', async () => {
|
||||
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
|
||||
await expect(obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'NOEXISTE' } }), resFake().res)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('resuelve la clave sin importar mayúsculas', async () => {
|
||||
prismaMock.desarrollo.findFirst.mockResolvedValue(filaDesarrollo())
|
||||
const { res, json } = resFake()
|
||||
|
||||
await obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'senda' } }), res)
|
||||
|
||||
expect(prismaMock.desarrollo.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clave: 'SENDA', activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
|
||||
)
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
|
||||
})
|
||||
})
|
||||
151
api-gateway/src/controllers/desarrollos.controller.ts
Normal file
151
api-gateway/src/controllers/desarrollos.controller.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Endpoints del módulo Desarrollos.
|
||||
*
|
||||
* Todo el CRUD y el inventario son solo admin (`requireAdminEstricto`, montado
|
||||
* en las rutas); el micrositio público lee /api/publico/desarrollos sin auth,
|
||||
* con información exclusivamente comercial (nunca clientes ni cotizaciones).
|
||||
*/
|
||||
import type { Request, Response } from 'express'
|
||||
import * as service from '../services/desarrollos.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/** Usuario del token para la bitácora (modo local sin auth: 'desconocido') */
|
||||
function usuarioDe(req: Request): string {
|
||||
return req.usuarioToken?.sub ?? 'desconocido'
|
||||
}
|
||||
|
||||
function idDe(req: Request): string {
|
||||
const id = String(req.params['id'] ?? '')
|
||||
if (id === '') throw new AppError(400, 'Falta el parámetro :id')
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Fichas ────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/desarrollos */
|
||||
export async function listarDesarrollosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.listarDesarrollos())
|
||||
}
|
||||
|
||||
/** GET /api/desarrollos/:id */
|
||||
export async function obtenerDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.obtenerDesarrollo(idDe(req)))
|
||||
}
|
||||
|
||||
/** POST /api/desarrollos */
|
||||
export async function crearDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = service.crearDesarrolloSchema.parse(req.body)
|
||||
const ficha = await service.crearDesarrollo(body)
|
||||
res.status(201).json(ficha)
|
||||
auditoria.registrar(usuarioDe(req), 'CREAR_DESARROLLO', 'desarrollo', ficha.id, ficha.clave)
|
||||
}
|
||||
|
||||
/** PATCH /api/desarrollos/:id */
|
||||
export async function actualizarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = service.actualizarDesarrolloSchema.parse(req.body)
|
||||
const ficha = await service.actualizarDesarrollo(idDe(req), body)
|
||||
res.json(ficha)
|
||||
auditoria.registrar(
|
||||
usuarioDe(req),
|
||||
'ACTUALIZAR_DESARROLLO',
|
||||
'desarrollo',
|
||||
ficha.id,
|
||||
Object.keys(req.body as Record<string, unknown>).join(', '),
|
||||
)
|
||||
}
|
||||
|
||||
/** DELETE /api/desarrollos/:id — 409 si tiene inventario u operación */
|
||||
export async function eliminarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = idDe(req)
|
||||
await service.eliminarDesarrollo(id)
|
||||
res.status(204).end()
|
||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_DESARROLLO', 'desarrollo', id)
|
||||
}
|
||||
|
||||
// ── Modelos ───────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/desarrollos/:id/modelos */
|
||||
export async function agregarModeloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const body = service.crearModeloSchema.parse(req.body)
|
||||
const modelo = await service.agregarModelo(desarrolloId, body)
|
||||
res.status(201).json(modelo)
|
||||
auditoria.registrar(usuarioDe(req), 'AGREGAR_MODELO', 'desarrollo', desarrolloId, modelo.nombre)
|
||||
}
|
||||
|
||||
/** PATCH /api/desarrollos/modelos/:modeloId */
|
||||
export async function actualizarModeloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const modeloId = String(req.params['modeloId'])
|
||||
const body = service.actualizarModeloSchema.parse(req.body)
|
||||
const modelo = await service.actualizarModelo(modeloId, body)
|
||||
res.json(modelo)
|
||||
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_MODELO', 'desarrollo', modelo.desarrolloId, modelo.nombre)
|
||||
}
|
||||
|
||||
/** DELETE /api/desarrollos/modelos/:modeloId — 409 si el modelo tiene unidades */
|
||||
export async function eliminarModeloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const modeloId = String(req.params['modeloId'])
|
||||
await service.eliminarModelo(modeloId)
|
||||
res.status(204).end()
|
||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_MODELO', 'desarrollo', modeloId)
|
||||
}
|
||||
|
||||
// ── Reglas del cotizador ──────────────────────────────────────────────
|
||||
|
||||
/** PUT /api/desarrollos/:id/reglas */
|
||||
export async function guardarReglasEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const parametros = service.parametrosDesarrolloSchema.parse(req.body)
|
||||
const reglas = await service.guardarReglas(desarrolloId, parametros, req.usuarioToken?.sub ?? null)
|
||||
res.json(reglas)
|
||||
auditoria.registrar(usuarioDe(req), 'GUARDAR_REGLAS_COTIZADOR', 'desarrollo', desarrolloId)
|
||||
}
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/desarrollos/:id/unidades — alta manual o por generador */
|
||||
export async function crearUnidadesEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const body = service.crearUnidadesSchema.parse(req.body)
|
||||
const resultado = await service.crearUnidades(desarrolloId, body)
|
||||
res.status(201).json(resultado)
|
||||
auditoria.registrar(
|
||||
usuarioDe(req),
|
||||
'CREAR_UNIDADES',
|
||||
'unidad',
|
||||
desarrolloId,
|
||||
`${resultado.creadas} unidad(es): ${resultado.unidades.map((u) => u.codigo).join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** DELETE /api/desarrollos/:id/unidades/:codigo — 409 si la unidad tiene operación */
|
||||
export async function eliminarUnidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const codigo = String(req.params['codigo'])
|
||||
await service.eliminarUnidad(desarrolloId, codigo)
|
||||
res.status(204).end()
|
||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
|
||||
}
|
||||
|
||||
/** PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m², modelo y estatus */
|
||||
export async function actualizarUnidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const codigo = String(req.params['codigo'])
|
||||
const body = service.actualizarUnidadSchema.parse(req.body)
|
||||
const unidad = await service.actualizarUnidad(desarrolloId, codigo, body)
|
||||
res.json(unidad)
|
||||
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_UNIDAD', 'unidad', codigo)
|
||||
}
|
||||
|
||||
// ── Público (micrositio) ──────────────────────────────────────────────
|
||||
|
||||
/** GET /api/publico/desarrollos */
|
||||
export async function listarDesarrollosPublicosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.listarDesarrollosPublicos())
|
||||
}
|
||||
|
||||
/** GET /api/publico/desarrollos/:clave */
|
||||
export async function obtenerDesarrolloPublicoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.obtenerDesarrolloPublico(String(req.params['clave'])))
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
automatizacionState: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
@@ -83,6 +84,7 @@ beforeEach(() => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
})
|
||||
@@ -212,6 +214,7 @@ describe('crearAfluenciaEndpoint (POST /api/afluencias) — la afluencia ES la o
|
||||
reqCon({
|
||||
body: {
|
||||
nombre: 'Lead Prueba',
|
||||
telefono: '4771234567',
|
||||
oportunidadId: 'loc_op_3',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'SOHO',
|
||||
@@ -448,17 +451,30 @@ describe('admin CRUD de leads — local', () => {
|
||||
expect(json).toHaveBeenCalledWith({ eliminado: true, itemId: 'loc_1' })
|
||||
})
|
||||
|
||||
it('asignarLeadEndpoint reasigna el lead localmente y responde con el nombre', async () => {
|
||||
it('asignarLeadEndpoint reasigna la cadena completa y responde con el nombre', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ nombre: 'Martin Zavala' })
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS', oportunidadId: null })
|
||||
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 1 })
|
||||
const { res, json } = resFake()
|
||||
|
||||
await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res)
|
||||
|
||||
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
|
||||
where: { leadId: 'loc_1' },
|
||||
where: { leadId: { in: ['loc_1'] } },
|
||||
data: { usuarioId: 'usr-9' },
|
||||
})
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', usuarioId: 'usr-9', nombre: 'Martin Zavala' })
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', usuarioId: 'usr-9', nombre: 'Martin Zavala', movidas: 1 })
|
||||
})
|
||||
|
||||
it('asignarLeadEndpoint con lead inexistente → 404 (ya no es exito silencioso)', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ nombre: 'Martin Zavala' })
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(
|
||||
asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_fantasma' }, body: { usuarioId: 'usr-9' } }), res),
|
||||
).rejects.toThrow('Lead no encontrado')
|
||||
expect(prismaMock.leadCaptura.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asignarLeadEndpoint con usuario inexistente → 404', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { VOCABULARIO } from '../config/vocabulario.js'
|
||||
import { VOCABULARIO, afluenciaCompleta } from '../config/vocabulario.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
@@ -60,7 +60,8 @@ const crearLeadSchema = z.object({
|
||||
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
|
||||
const crearAfluenciaSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
telefono: z.string().trim().min(1).optional(),
|
||||
// Teléfono obligatorio (regla de negocio: sin teléfono no hay afluencia)
|
||||
telefono: z.string().trim().min(7, 'El teléfono es obligatorio'),
|
||||
email: z.string().trim().email('Email inválido').optional(),
|
||||
origen: z.string().trim().min(1).optional(),
|
||||
desarrollo: z.string().trim().min(1).optional(),
|
||||
@@ -226,6 +227,26 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/afluencias/:leadId — perfil de afluencia capturado en el datosJson
|
||||
* de la fila (lead/oportunidad/apartado). Solo valores de texto no vacíos.
|
||||
* Alimenta la pestaña "Afluencia" de la bitácora del cliente en Mi día.
|
||||
*/
|
||||
export async function obtenerAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const leadId = String(req.params['leadId'])
|
||||
const fila = await prisma.leadCaptura.findUnique({ where: { leadId } })
|
||||
if (!fila) throw new NotFoundError('No existe el registro solicitado')
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (fila.datosJson) {
|
||||
try { datos = JSON.parse(fila.datosJson) as Record<string, unknown> } catch { datos = {} }
|
||||
}
|
||||
const perfil: Record<string, string> = {}
|
||||
for (const [clave, valor] of Object.entries(datos)) {
|
||||
if (typeof valor === 'string' && valor.trim() !== '') perfil[clave] = valor
|
||||
}
|
||||
res.json({ leadId, nombre: fila.nombreLead, perfil, afluenciaCompleta: afluenciaCompleta(fila.datosJson) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambio de estado en modo local: guarda la etapa (label del board) en la fila
|
||||
* local. Con id heredado sin fila local (items viejos del CRM anterior)
|
||||
@@ -319,7 +340,9 @@ const asignarLeadSchema = z.object({
|
||||
usuarioId: z.string().trim().min(1, 'usuarioId es obligatorio'),
|
||||
})
|
||||
|
||||
/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin) */
|
||||
/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin).
|
||||
* Reasigna TODA la cadena del cliente (lead → oportunidad → apartado) y sus
|
||||
* actividades pendientes, para no dejar el registro partido entre vendedores. */
|
||||
export async function asignarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
const { usuarioId } = asignarLeadSchema.parse(req.body)
|
||||
@@ -328,13 +351,9 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise<
|
||||
select: { nombre: true },
|
||||
})
|
||||
if (!usuario) throw new NotFoundError('Usuario no encontrado')
|
||||
// La asignación local es la verdad: se actualiza la fila del lead
|
||||
await prisma.leadCaptura.updateMany({
|
||||
where: { leadId: itemId },
|
||||
data: { usuarioId },
|
||||
})
|
||||
res.json({ itemId, usuarioId, nombre: usuario.nombre })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
|
||||
const movidas = await crmLocal.reasignarCadena(itemId, usuarioId)
|
||||
res.json({ itemId, usuarioId, nombre: usuario.nombre, movidas })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, `${usuario.nombre} (${movidas} fila(s))`)
|
||||
}
|
||||
|
||||
const moverLeadSchema = z.object({
|
||||
@@ -363,5 +382,13 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const q = String(req.query['q'] ?? '').trim()
|
||||
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
|
||||
res.json(await crmLocal.buscarLocales(q))
|
||||
// Privacidad: el vendedor sólo encuentra sus propios leads/contactos; los
|
||||
// roles de supervisión siguen viendo todo el universo.
|
||||
const soloPropio = req.usuarioToken?.rol === 'vendedor'
|
||||
res.json(
|
||||
await crmLocal.buscarLocales(
|
||||
q,
|
||||
req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
cambiarEtapaOportunidadEndpoint,
|
||||
ETAPAS_OPORTUNIDAD_PERMITIDAS,
|
||||
perderOportunidadEndpoint,
|
||||
STATUS_NO_BLOQUEABLES,
|
||||
} from './oportunidades.controller.js'
|
||||
|
||||
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
|
||||
@@ -17,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
unidad: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
cotizacion: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
automatizacionState: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
@@ -53,6 +57,7 @@ beforeEach(() => {
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
|
||||
prismaMock.cotizacion.findFirst.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
@@ -149,7 +154,61 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' })
|
||||
})
|
||||
|
||||
it("'Aparto': crea el apartado local (crmLocal.apartar) y bloquea la unidad del inventario", async () => {
|
||||
it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => {
|
||||
// La oportunidad YA tiene afluencia completa (gate de negocio: sin
|
||||
// afluencia no se puede apartar)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_op',
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Luis Demo',
|
||||
telefono: '6641112233',
|
||||
email: null,
|
||||
desarrollo: 'ATELIER',
|
||||
modelo: 'SOHO',
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
unidadCodigo: 'U-101',
|
||||
}),
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
|
||||
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
|
||||
|
||||
// Oportunidad en Aparto + apartado local en Pago Apartado con la unidad + cola
|
||||
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
|
||||
where: { leadId: 'loc_op' },
|
||||
data: { etapa: 'Aparto', pendienteSync: true },
|
||||
})
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
board: 'APARTADO',
|
||||
etapa: 'Pago Apartado',
|
||||
nombreLead: 'Luis Demo',
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
unidadCodigo: 'U-101',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
// La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
|
||||
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
|
||||
where: { codigo: 'U-101', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
|
||||
data: { status: 'APARTADO' },
|
||||
})
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
|
||||
})
|
||||
|
||||
it("'Aparto' SIN afluencia completa: se rechaza con 400 antes de crear nada", async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_op',
|
||||
usuarioId: 'u-1',
|
||||
@@ -160,24 +219,60 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
modelo: 'SOHO',
|
||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(
|
||||
cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res),
|
||||
).rejects.toThrow(/afluencia/i)
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_op',
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Luis Demo',
|
||||
telefono: null,
|
||||
email: null,
|
||||
desarrollo: 'SENDA',
|
||||
modelo: null,
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
})
|
||||
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
|
||||
const { res } = resFake()
|
||||
|
||||
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
|
||||
|
||||
// Oportunidad en Aparto + apartado local en Pago Apartado + cola
|
||||
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
|
||||
where: { leadId: 'loc_op' },
|
||||
data: { etapa: 'Aparto', pendienteSync: true },
|
||||
expect(prismaMock.cotizacion.findFirst).toHaveBeenCalledWith({
|
||||
where: { mondayItemId: 'loc_op' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { unidadCodigo: true },
|
||||
})
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }),
|
||||
data: expect.objectContaining({
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
unidadCodigo: 'SENDA-411-1-7',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
// La unidad asignada queda APARTADA en el espejo local
|
||||
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
|
||||
where: { codigo: 'U-101' },
|
||||
data: { status: 'APARTADA' },
|
||||
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
|
||||
data: { status: 'APARTADO' },
|
||||
})
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
|
||||
})
|
||||
|
||||
it("'Aparto' sin unidad asignada no toca el inventario", async () => {
|
||||
@@ -189,7 +284,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
email: null,
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
datosJson: null,
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
})
|
||||
const { res } = resFake()
|
||||
|
||||
|
||||
@@ -52,9 +52,40 @@ const cambiarEtapaSchema = z.object({
|
||||
fecha: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Estatus de inventario que un apartado nunca debe pisar: la unidad ya se
|
||||
* vendió, se escrituró o no está a la venta (BLOQUEADO).
|
||||
*/
|
||||
export const STATUS_NO_BLOQUEABLES = ['VENDIDO', 'ESCRITURADO', 'BLOQUEADO']
|
||||
|
||||
/**
|
||||
* Unidad asignada a la oportunidad, leída de su `datosJson` (la deja el alta de
|
||||
* clientes / la afluencia) o, si no está ahí, de la cotización más reciente
|
||||
* ligada a ella (`Cotizacion.mondayItemId`): el vendedor cotiza y después
|
||||
* aparta, así que la unidad cotizada ES la que se está apartando.
|
||||
*/
|
||||
async function unidadDeOportunidad(itemId: string, datosJson: string | null): Promise<string | null> {
|
||||
if (datosJson) {
|
||||
try {
|
||||
const datos = JSON.parse(datosJson) as Record<string, unknown>
|
||||
if (typeof datos['unidadCodigo'] === 'string' && datos['unidadCodigo'].trim() !== '') {
|
||||
return datos['unidadCodigo'].trim()
|
||||
}
|
||||
} catch { /* datosJson inválido: se ignora */ }
|
||||
}
|
||||
const cotizacion = await prisma.cotizacion.findFirst({
|
||||
where: { mondayItemId: itemId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { unidadCodigo: true },
|
||||
})
|
||||
const codigo = cotizacion?.unidadCodigo.trim()
|
||||
return codigo !== undefined && codigo !== '' ? codigo : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambio de etapa local: guarda la etapa en la fila local y encola
|
||||
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar).
|
||||
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
|
||||
* dejando la unidad apartada en su `datosJson`.
|
||||
* Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad).
|
||||
*/
|
||||
async function cambiarEtapaLocal(
|
||||
@@ -64,17 +95,12 @@ async function cambiarEtapaLocal(
|
||||
usuarioId: string | undefined,
|
||||
): Promise<string | null> {
|
||||
if (etapa === 'Aparto') {
|
||||
await crmLocal.apartar(itemId, usuarioId)
|
||||
// Leer la oportunidad local para obtener el unidadCodigo y bloquear la unidad
|
||||
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
let unidadCodigo = ''
|
||||
if (captura?.datosJson) {
|
||||
try {
|
||||
const datos = JSON.parse(captura.datosJson) as Record<string, unknown>
|
||||
if (typeof datos['unidadCodigo'] === 'string') unidadCodigo = datos['unidadCodigo']
|
||||
} catch { /* datosJson inválido: se ignora */ }
|
||||
}
|
||||
return unidadCodigo.trim() !== '' ? unidadCodigo.trim() : null
|
||||
const unidadCodigo = await unidadDeOportunidad(itemId, captura?.datosJson ?? null)
|
||||
await crmLocal.apartar(itemId, usuarioId, {
|
||||
...(unidadCodigo !== null ? { unidadCodigo } : {}),
|
||||
})
|
||||
return unidadCodigo
|
||||
}
|
||||
await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', {
|
||||
fecha,
|
||||
@@ -89,10 +115,15 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons
|
||||
const body = cambiarEtapaSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub)
|
||||
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada
|
||||
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada.
|
||||
// Nunca se degrada una unidad ya VENDIDA/ESCRITURADA/BLOQUEADA: el apartado
|
||||
// solo bloquea inventario vendible.
|
||||
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
|
||||
try {
|
||||
await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } })
|
||||
await prisma.unidad.updateMany({
|
||||
where: { codigo: unidadCodigo, status: { notIn: STATUS_NO_BLOQUEABLES } },
|
||||
data: { status: 'APARTADO' },
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolverFiltroMios } from './planes.controller.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import { ROLES_EDITAN_PLAN, requireEditaPlan, resolverFiltroMios } from './planes.controller.js'
|
||||
|
||||
describe('resolverFiltroMios (?mios=1)', () => {
|
||||
it('sin ?mios=1 no filtra nada', () => {
|
||||
@@ -20,3 +21,35 @@ describe('resolverFiltroMios (?mios=1)', () => {
|
||||
expect(resolverFiltroMios('1', undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const reqCon = (rol?: string): Request =>
|
||||
({
|
||||
body: {},
|
||||
params: { id: 'plan-1' },
|
||||
...(rol ? { usuarioToken: { sub: 'u-1', username: 'x', rol } } : {}),
|
||||
}) as unknown as Request
|
||||
|
||||
describe('requireEditaPlan (PATCH /planes/:id)', () => {
|
||||
it('admin, manager, dirección y sus roles legacy pasan', () => {
|
||||
for (const rol of ROLES_EDITAN_PLAN) {
|
||||
const next = vi.fn()
|
||||
requireEditaPlan(reqCon(rol), {} as Response, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
}
|
||||
expect(ROLES_EDITAN_PLAN).toEqual(
|
||||
expect.arrayContaining(['admin', 'manager', 'direccion_general', 'director_ventas']),
|
||||
)
|
||||
})
|
||||
|
||||
it('los roles de cobranza que no son dirección quedan fuera', () => {
|
||||
for (const rol of ['vendedor', 'administracion_venta', 'escrituracion']) {
|
||||
expect(() => requireEditaPlan(reqCon(rol), {} as Response, vi.fn())).toThrow(
|
||||
'Solo admin, manager o dirección pueden modificar el plan',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('sin token también rechaza (403)', () => {
|
||||
expect(() => requireEditaPlan(reqCon(), {} as Response, vi.fn())).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
validarAbono,
|
||||
type PlanPago as PlanPagoDominio,
|
||||
} from '../services/cobranza.service.js'
|
||||
import { BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
|
||||
|
||||
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
|
||||
@@ -319,3 +319,48 @@ export async function cancelarPlan(req: Request, res: Response): Promise<void> {
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
|
||||
// ── Edición de parámetros del plan (tasa de mora) ────────────────────
|
||||
|
||||
/** Roles que pueden editar el plan: admin, manager y dirección (y sus legacy) */
|
||||
export const ROLES_EDITAN_PLAN: readonly string[] = [
|
||||
'admin',
|
||||
'manager',
|
||||
'direccion_general',
|
||||
'director_ventas',
|
||||
// Legacy: la matriz de roles los homologa a dirección de ventas
|
||||
'coordinador',
|
||||
'corporativo',
|
||||
]
|
||||
|
||||
/** Middleware: sin rol autorizado no se edita el plan (403) */
|
||||
export function requireEditaPlan(req: Request, _res: Response, next: NextFunction): void {
|
||||
const rol = req.usuarioToken?.rol
|
||||
if (!rol || !ROLES_EDITAN_PLAN.includes(rol)) {
|
||||
throw new AppError(403, 'Solo admin, manager o dirección pueden modificar el plan')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
const actualizarPlanSchema = z.object({
|
||||
// Tasa mensual en % sobre el restante de cada pago vencido (0 = sin mora)
|
||||
tasaMoraMensual: z
|
||||
.number()
|
||||
.min(0, 'La tasa de mora no puede ser negativa')
|
||||
.max(100, 'La tasa de mora mensual no puede exceder 100%'),
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /api/planes/:id — actualiza la tasa de mora mensual del plan.
|
||||
* (El calendario de pagos no se edita: cambiarlo requiere recrear el plan.)
|
||||
*/
|
||||
export async function actualizarPlan(req: Request, res: Response): Promise<void> {
|
||||
const body = actualizarPlanSchema.parse(req.body)
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
const actualizado = await prisma.planPago.update({
|
||||
where: { id: plan.id },
|
||||
data: { tasaMoraMensual: body.tasaMoraMensual },
|
||||
include: includeCompleto,
|
||||
})
|
||||
res.json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const svcMock = vi.hoisted(() => ({
|
||||
crear: vi.fn(),
|
||||
listar: vi.fn(),
|
||||
listarPorCliente: vi.fn(),
|
||||
completar: vi.fn(),
|
||||
eliminar: vi.fn(),
|
||||
proximos: vi.fn(),
|
||||
@@ -72,6 +73,33 @@ describe('recordatorio-vendedor.controller', () => {
|
||||
expect(res.json).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('bitácora: con leadId devuelve las notas de TODOS los usuarios (listarPorCliente)', async () => {
|
||||
svcMock.listarPorCliente.mockResolvedValue([{ id: 'n1', titulo: 'Nota de otro asesor' }])
|
||||
const res = resJson()
|
||||
|
||||
await controller.listar(
|
||||
reqConToken('u-1', { query: { leadId: 'loc_9' } }),
|
||||
res as never,
|
||||
)
|
||||
|
||||
expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: 'loc_9', clienteClave: null })
|
||||
expect(svcMock.listar).not.toHaveBeenCalled()
|
||||
expect(res.json).toHaveBeenCalledWith([{ id: 'n1', titulo: 'Nota de otro asesor' }])
|
||||
})
|
||||
|
||||
it('bitácora: con clienteClave (y sin leadId) también usa listarPorCliente', async () => {
|
||||
svcMock.listarPorCliente.mockResolvedValue([])
|
||||
const res = resJson()
|
||||
|
||||
await controller.listar(
|
||||
reqConToken('u-1', { query: { clienteClave: 'ana prueba|4771234567' } }),
|
||||
res as never,
|
||||
)
|
||||
|
||||
expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: null, clienteClave: 'ana prueba|4771234567' })
|
||||
expect(svcMock.listar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('completar: llama al service con el id', async () => {
|
||||
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
|
||||
const res = resJson()
|
||||
|
||||
@@ -39,6 +39,17 @@ export async function listarRecordatoriosEndpoint(req: Request, res: Response):
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new Error('Auth requerida')
|
||||
|
||||
// Bitácora: cuando piden las notas de un lead/cliente concreto, se devuelven
|
||||
// TODAS las notas de ese cliente (de todos los usuarios). Así cualquier
|
||||
// usuario que abra la bitácora ve el seguimiento completo, no sólo las
|
||||
// notas que él mismo escribió.
|
||||
const leadId = (req.query['leadId'] as string) ?? null
|
||||
const clienteClave = (req.query['clienteClave'] as string) ?? null
|
||||
if (leadId !== null || clienteClave !== null) {
|
||||
res.json(await recordatorioVendedorService.listarPorCliente({ leadId, clienteClave }))
|
||||
return
|
||||
}
|
||||
|
||||
const completadosStr = req.query['completados'] as string | undefined
|
||||
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = {
|
||||
desde: (req.query['desde'] as string) ?? null,
|
||||
@@ -89,6 +100,14 @@ export function crearController(svc: RecordatorioVendedorService) {
|
||||
listar: async (req: Request, res: Response): Promise<void> => {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new Error('Auth requerida')
|
||||
// Bitácora: con leadId/clienteClave devuelve las notas de TODOS los
|
||||
// usuarios (el seguimiento del cliente es compartido)
|
||||
const leadId = (req.query['leadId'] as string) ?? null
|
||||
const clienteClave = (req.query['clienteClave'] as string) ?? null
|
||||
if (leadId !== null || clienteClave !== null) {
|
||||
res.json(await svc.listarPorCliente({ leadId, clienteClave }))
|
||||
return
|
||||
}
|
||||
const completadosStr = req.query['completados'] as string | undefined
|
||||
const opts: {
|
||||
completados?: boolean
|
||||
|
||||
28
api-gateway/src/controllers/recordatorios.controller.test.ts
Normal file
28
api-gateway/src/controllers/recordatorios.controller.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { exigirRolCobranza } from './recordatorios.controller.js'
|
||||
|
||||
describe('exigirRolCobranza (motor de recordatorios)', () => {
|
||||
it('permite los roles con el módulo de cobranza', () => {
|
||||
for (const rol of [
|
||||
'admin',
|
||||
'manager',
|
||||
'administracion_venta',
|
||||
'direccion_general',
|
||||
'director_ventas',
|
||||
'escrituracion',
|
||||
'coordinador', // legacy → director_ventas
|
||||
'corporativo', // legacy → director_ventas
|
||||
]) {
|
||||
expect(() => exigirRolCobranza(rol)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rechaza al vendedor (no tiene el módulo) y a "cobranza", que es módulo y no rol', () => {
|
||||
expect(() => exigirRolCobranza('vendedor')).toThrow('Tu rol no tiene acceso a la cobranza')
|
||||
expect(() => exigirRolCobranza('cobranza')).toThrow('Tu rol no tiene acceso a la cobranza')
|
||||
})
|
||||
|
||||
it('sin token (AUTH_ENABLED=false) no bloquea el modo sin autenticación', () => {
|
||||
expect(() => exigirRolCobranza(undefined)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -3,11 +3,35 @@ import { prisma } from '../services/prisma.js'
|
||||
import { recordatoriosCobranzaService } from '../services/recordatorios-cobranza.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
// Roles que pueden disparar una pasada manual del motor de recordatorios
|
||||
const ROLES_RUN: readonly string[] = ['admin', 'cobranza']
|
||||
// Roles del ecosistema cuyo perfil incluye el módulo de cobranza (matriz de
|
||||
// config/roles.ts): los que pueden ver y disparar el motor de recordatorios.
|
||||
// 'cobranza' es un MÓDULO, no un rol: no existe como rol de usuario.
|
||||
const ROLES_COBRANZA: readonly string[] = [
|
||||
'admin',
|
||||
'manager',
|
||||
'administracion_venta',
|
||||
'direccion_general',
|
||||
'director_ventas',
|
||||
'escrituracion',
|
||||
// Roles legacy (se homologan a director_ventas en la matriz)
|
||||
'coordinador',
|
||||
'corporativo',
|
||||
]
|
||||
|
||||
/**
|
||||
* Exige rol con cobranza. Sin token (AUTH_ENABLED=false) se permite, igual que
|
||||
* el resto de la API en ese modo.
|
||||
*/
|
||||
export function exigirRolCobranza(rol: string | undefined): void {
|
||||
if (!rol) return
|
||||
if (!ROLES_COBRANZA.includes(rol)) {
|
||||
throw new AppError(403, 'Tu rol no tiene acceso a la cobranza')
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/cobranza/recordatorios — historial del log (más recientes primero) */
|
||||
export async function listarRecordatorios(req: Request, res: Response): Promise<void> {
|
||||
exigirRolCobranza(req.usuarioToken?.rol)
|
||||
const limite = Math.min(Number(req.query['limite'] ?? 200) || 200, 500)
|
||||
const registros = await prisma.recordatorioLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -17,15 +41,12 @@ export async function listarRecordatorios(req: Request, res: Response): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/cobranza/recordatorios/run — pasada manual del motor (admin/cobranza).
|
||||
* Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir en el log.
|
||||
* POST /api/cobranza/recordatorios/run — pasada manual del motor (roles con
|
||||
* cobranza). Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir
|
||||
* en el log.
|
||||
*/
|
||||
export async function correrRecordatorios(req: Request, res: Response): Promise<void> {
|
||||
const token = req.usuarioToken
|
||||
// Sin token (AUTH_ENABLED=false) se permite; con token se exige rol admin/cobranza
|
||||
if (token && !ROLES_RUN.includes(token.rol)) {
|
||||
throw new AppError(403, 'Solo admin o cobranza pueden ejecutar el motor de recordatorios')
|
||||
}
|
||||
exigirRolCobranza(req.usuarioToken?.rol)
|
||||
const dryRun = req.query['dry'] === '1'
|
||||
res.json(await recordatoriosCobranzaService.ejecutarPasada({ dryRun }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
reglaCotizadorDesarrollo: { findMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { reglasCotizadorEndpoint } from './reglas-cotizador.controller.js'
|
||||
import { DESARROLLOS } from '../services/cotizacion.service.js'
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
return { res: { json } as unknown as Response, json }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('GET /api/desarrollos/reglas-cotizador', () => {
|
||||
it('devuelve el mapa reglasPorDesarrollo con las reglas efectivas', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'SENDA',
|
||||
parametrosJson: JSON.stringify(DESARROLLOS['SENDA']),
|
||||
activoEnCotizador: false,
|
||||
},
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await reglasCotizadorEndpoint({} as Request, res)
|
||||
|
||||
const cuerpo = json.mock.calls[0]?.[0] as {
|
||||
reglasPorDesarrollo: Record<string, { activoEnCotizador?: boolean; propuestas: unknown[] }>
|
||||
}
|
||||
expect(Object.keys(cuerpo.reglasPorDesarrollo).sort()).toEqual(Object.keys(DESARROLLOS).sort())
|
||||
expect(cuerpo.reglasPorDesarrollo['SENDA']?.activoEnCotizador).toBe(false)
|
||||
expect(cuerpo.reglasPorDesarrollo['SENDA']?.propuestas).toHaveLength(4)
|
||||
// Los desarrollos sin reglas guardadas salen con sus constantes
|
||||
expect(cuerpo.reglasPorDesarrollo['DIAGONAL']).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
})
|
||||
})
|
||||
14
api-gateway/src/controllers/reglas-cotizador.controller.ts
Normal file
14
api-gateway/src/controllers/reglas-cotizador.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { getReglasEfectivas } from '../services/reglas-desarrollo.service.js'
|
||||
|
||||
/**
|
||||
* GET /api/desarrollos/reglas-cotizador — reglas comerciales EFECTIVAS por
|
||||
* desarrollo (las configuradas por el admin en la BD, con respaldo en las
|
||||
* constantes históricas para los desarrollos sin reglas guardadas).
|
||||
*
|
||||
* Autenticado y disponible para cualquier rol que cotice (el middleware de auth
|
||||
* cubre todo /api salvo las rutas públicas). Devuelve el mapa `reglasPorDesarrollo`.
|
||||
*/
|
||||
export async function reglasCotizadorEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json({ reglasPorDesarrollo: await getReglasEfectivas() })
|
||||
}
|
||||
478
api-gateway/src/controllers/soporte.controller.test.ts
Normal file
478
api-gateway/src/controllers/soporte.controller.test.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// UPLOADS_DIR propio del test: se fija antes de que config lo lea (dotenv no
|
||||
// pisa una variable ya definida), así la carpeta queda aislada del repo.
|
||||
const uploadsDir = vi.hoisted(() => {
|
||||
const dir = `${process.env['TMPDIR'] ?? '/tmp'}/frasa-soporte-test-${Date.now()}`
|
||||
process.env['UPLOADS_DIR'] = dir
|
||||
return dir
|
||||
})
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
solicitudSoporte: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const auditoriaMock = vi.hoisted(() => ({ registrar: vi.fn() }))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../services/auditoria.service.js', () => auditoriaMock)
|
||||
|
||||
import { config } from '../config/index.js'
|
||||
import {
|
||||
cambiarEstadoSolicitudEndpoint,
|
||||
crearSolicitudSoporteEndpoint,
|
||||
descargarArchivoSoporteEndpoint,
|
||||
listarSolicitudesSoporteEndpoint,
|
||||
requireAdminOManager,
|
||||
uploadArchivosSoporte,
|
||||
} from './soporte.controller.js'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type ReqConId = Request & { solicitudSoporteId?: string }
|
||||
|
||||
function reqFake(overrides: Partial<ReqConId> = {}): ReqConId {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
usuarioToken: { sub: 'u-1', username: 'vendedor', rol: 'vendedor' },
|
||||
...overrides,
|
||||
} as ReqConId
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
const setHeader = vi.fn()
|
||||
const sendFile = vi.fn()
|
||||
return {
|
||||
res: { json, status, setHeader, sendFile } as unknown as Response,
|
||||
status,
|
||||
json,
|
||||
setHeader,
|
||||
sendFile,
|
||||
}
|
||||
}
|
||||
|
||||
function filaFake(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'sol-1',
|
||||
usuarioId: 'u-1',
|
||||
titulo: 'Impresora',
|
||||
descripcion: 'No imprime',
|
||||
departamento: 'Sistemas',
|
||||
prioridad: 'Media',
|
||||
responsable: null,
|
||||
fechaSolicitud: new Date('2026-09-21T00:00:00.000Z'),
|
||||
fechaLimite: null,
|
||||
notas: null,
|
||||
archivos: null,
|
||||
solicitante: 'Vendedor',
|
||||
solicitanteEmail: null,
|
||||
estado: 'ABIERTA',
|
||||
createdAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Request multipart real (stream) para ejercitar el middleware de multer */
|
||||
function reqMultipart(partes: Array<{ name: string; filename?: string; tipo: string; contenido: string }>): ReqConId {
|
||||
const boundary = '----frasaTestBoundary'
|
||||
const trozos = partes.map((p) => {
|
||||
const disposicion = p.filename
|
||||
? `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n`
|
||||
: `Content-Disposition: form-data; name="${p.name}"\r\n`
|
||||
return Buffer.from(
|
||||
`--${boundary}\r\n${disposicion}Content-Type: ${p.tipo}\r\n\r\n${p.contenido}\r\n`,
|
||||
'binary',
|
||||
)
|
||||
})
|
||||
const cuerpo = Buffer.concat([...trozos, Buffer.from(`--${boundary}--\r\n`, 'binary')])
|
||||
const req = Readable.from([cuerpo]) as unknown as ReqConId
|
||||
// type-is (usado por multer) exige content-type + content-length para
|
||||
// considerar la petición "multipart con body"
|
||||
req.headers = {
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
'content-length': String(cuerpo.length),
|
||||
} as ReqConId['headers']
|
||||
return req
|
||||
}
|
||||
|
||||
const bodyValido = {
|
||||
titulo: 'Impresora',
|
||||
descripcion: 'No imprime',
|
||||
departamento: 'Sistemas',
|
||||
prioridad: 'Media',
|
||||
fechaSolicitud: '2026-09-21',
|
||||
email: 'vendedor@frasa.mx',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.solicitudSoporte.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) =>
|
||||
filaFake(data),
|
||||
)
|
||||
prismaMock.solicitudSoporte.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) =>
|
||||
filaFake(data),
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// ── Subida real de adjuntos (multer + carpeta por solicitud) ──────────
|
||||
|
||||
describe('uploadArchivosSoporte', () => {
|
||||
function subir(req: ReqConId): Promise<Error | null> {
|
||||
return new Promise((resolve) => {
|
||||
uploadArchivosSoporte(req, {} as Response, (err?: unknown) => resolve((err as Error) ?? null))
|
||||
})
|
||||
}
|
||||
|
||||
it('guarda el archivo en uploads/soporte/<idSolicitud>/ conservando el nombre', async () => {
|
||||
const req = reqMultipart([
|
||||
{ name: 'archivos', filename: 'carta poder.pdf', tipo: 'application/pdf', contenido: '%PDF-1.4 hola' },
|
||||
])
|
||||
req.solicitudSoporteId = 'sol-abc'
|
||||
|
||||
expect(await subir(req)).toBeNull()
|
||||
|
||||
const archivos = req.files as Express.Multer.File[]
|
||||
expect(archivos).toHaveLength(1)
|
||||
const relativa = path.relative(config.uploadsDir, archivos[0]!.path)
|
||||
expect(relativa.startsWith(`soporte${path.sep}sol-abc${path.sep}`)).toBe(true)
|
||||
expect(relativa.endsWith('-carta poder.pdf')).toBe(true)
|
||||
expect(fs.readFileSync(archivos[0]!.path, 'utf8')).toBe('%PDF-1.4 hola')
|
||||
})
|
||||
|
||||
it('rechaza tipos no permitidos (texto plano)', async () => {
|
||||
const req = reqMultipart([
|
||||
{ name: 'archivos', filename: 'notas.txt', tipo: 'text/plain', contenido: 'hola' },
|
||||
])
|
||||
req.solicitudSoporteId = 'sol-txt'
|
||||
|
||||
const err = await subir(req)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err?.message).toContain('Tipo de archivo no permitido')
|
||||
})
|
||||
|
||||
it('si multer corta (demasiados archivos) borra la carpeta de la solicitud', async () => {
|
||||
const partes = Array.from({ length: 11 }, (_, i) => ({
|
||||
name: 'archivos',
|
||||
filename: `doc-${i}.pdf`,
|
||||
tipo: 'application/pdf',
|
||||
contenido: 'pdf',
|
||||
}))
|
||||
const req = reqMultipart(partes)
|
||||
req.solicitudSoporteId = 'sol-limite'
|
||||
|
||||
const err = await subir(req)
|
||||
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err?.message).toContain('Too many files')
|
||||
expect(fs.existsSync(path.join(config.uploadsDir, 'soporte', 'sol-limite'))).toBe(false)
|
||||
})
|
||||
|
||||
it('sanea el nombre original (no permite rutas)', async () => {
|
||||
const req = reqMultipart([
|
||||
{ name: 'archivos', filename: '../../evil.png', tipo: 'image/png', contenido: 'PNG' },
|
||||
])
|
||||
req.solicitudSoporteId = 'sol-evil'
|
||||
|
||||
expect(await subir(req)).toBeNull()
|
||||
const archivos = req.files as Express.Multer.File[]
|
||||
const relativa = path.relative(config.uploadsDir, archivos[0]!.path)
|
||||
expect(relativa).not.toContain('..')
|
||||
expect(relativa.startsWith(`soporte${path.sep}sol-evil${path.sep}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /api/soporte ────────────────────────────────────────────────
|
||||
|
||||
describe('crearSolicitudSoporteEndpoint', () => {
|
||||
it('201 guardando las rutas relativas de los adjuntos subidos', async () => {
|
||||
const solicitado = 'a1b2c3d4-factura.pdf'
|
||||
const rutaAbsoluta = path.join(config.uploadsDir, 'soporte', 'sol-1', solicitado)
|
||||
fs.mkdirSync(path.dirname(rutaAbsoluta), { recursive: true })
|
||||
fs.writeFileSync(rutaAbsoluta, 'pdf')
|
||||
|
||||
const req = reqFake({
|
||||
body: bodyValido,
|
||||
solicitudSoporteId: 'sol-1',
|
||||
files: [
|
||||
{
|
||||
path: rutaAbsoluta,
|
||||
originalname: 'factura.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
size: 3,
|
||||
} as Express.Multer.File,
|
||||
],
|
||||
})
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearSolicitudSoporteEndpoint(req, res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record<string, unknown> }
|
||||
expect(data.data['id']).toBe('sol-1')
|
||||
expect(JSON.parse(String(data.data['archivos']))).toEqual([
|
||||
path.join('soporte', 'sol-1', solicitado),
|
||||
])
|
||||
expect(data.data['solicitanteEmail']).toBe('vendedor@frasa.mx')
|
||||
expect(json).toHaveBeenCalled()
|
||||
expect(auditoriaMock.registrar).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('si el body no valida borra la carpeta de adjuntos y propaga el error', async () => {
|
||||
const carpeta = path.join(config.uploadsDir, 'soporte', 'sol-mala')
|
||||
fs.mkdirSync(carpeta, { recursive: true })
|
||||
fs.writeFileSync(path.join(carpeta, 'x.pdf'), 'pdf')
|
||||
|
||||
const req = reqFake({ body: { ...bodyValido, fechaSolicitud: '' }, solicitudSoporteId: 'sol-mala' })
|
||||
|
||||
await expect(crearSolicitudSoporteEndpoint(req, resFake().res)).rejects.toThrow()
|
||||
expect(fs.existsSync(carpeta)).toBe(false)
|
||||
expect(prismaMock.solicitudSoporte.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('acepta JSON sin adjuntos (compatibilidad) y guarda archivos: null', async () => {
|
||||
const req = reqFake({ body: bodyValido, solicitudSoporteId: 'sol-json' })
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearSolicitudSoporteEndpoint(req, res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record<string, unknown> }
|
||||
expect(data.data['archivos']).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/soporte (visibilidad + filtros) ─────────────────────────
|
||||
|
||||
describe('listarSolicitudesSoporteEndpoint', () => {
|
||||
it('el manager ve TODAS las solicitudes (sin filtro por usuario)', async () => {
|
||||
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
|
||||
const { res } = resFake()
|
||||
|
||||
await listarSolicitudesSoporteEndpoint(
|
||||
reqFake({ usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' } }),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('el admin ve TODAS las solicitudes', async () => {
|
||||
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
|
||||
await listarSolicitudesSoporteEndpoint(
|
||||
reqFake({ usuarioToken: { sub: 'a-1', username: 'admin', rol: 'admin' } }),
|
||||
resFake().res,
|
||||
)
|
||||
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: {} }),
|
||||
)
|
||||
})
|
||||
|
||||
it('el vendedor solo ve las suyas', async () => {
|
||||
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
|
||||
await listarSolicitudesSoporteEndpoint(reqFake({ usuarioToken: { sub: 'v-9', username: 'v', rol: 'vendedor' } }), resFake().res)
|
||||
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
|
||||
where: { usuarioId: 'v-9' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('aplica filtros de estado y departamento', async () => {
|
||||
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
|
||||
await listarSolicitudesSoporteEndpoint(
|
||||
reqFake({
|
||||
query: { estado: 'EN_PROCESO', departamento: 'Cobranza' },
|
||||
usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' },
|
||||
}),
|
||||
resFake().res,
|
||||
)
|
||||
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
|
||||
where: { estado: 'EN_PROCESO', departamento: 'Cobranza' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignora filtros vacíos y rechaza estados inválidos', async () => {
|
||||
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
|
||||
await listarSolicitudesSoporteEndpoint(
|
||||
reqFake({ query: { estado: '', departamento: '' } }),
|
||||
resFake().res,
|
||||
)
|
||||
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { usuarioId: 'u-1' } }),
|
||||
)
|
||||
|
||||
await expect(
|
||||
listarSolicitudesSoporteEndpoint(reqFake({ query: { estado: 'LOQUESEA' } }), resFake().res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('serializa archivos JSON a array', async () => {
|
||||
prismaMock.solicitudSoporte.findMany.mockResolvedValue([
|
||||
filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }),
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
await listarSolicitudesSoporteEndpoint(reqFake(), res)
|
||||
expect((json.mock.calls[0]![0] as Array<{ archivos: string[] }>)[0]!.archivos).toEqual(['soporte/sol-1/a.pdf'])
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/soporte/:id/archivos/:indice ────────────────────────────
|
||||
|
||||
describe('descargarArchivoSoporteEndpoint', () => {
|
||||
function crearArchivo(relativa: string, contenido = 'contenido'): string {
|
||||
const absoluta = path.join(config.uploadsDir, relativa)
|
||||
fs.mkdirSync(path.dirname(absoluta), { recursive: true })
|
||||
fs.writeFileSync(absoluta, contenido)
|
||||
return absoluta
|
||||
}
|
||||
|
||||
it('el dueño descarga su adjunto', async () => {
|
||||
const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.pdf'))
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
|
||||
filaFake({ usuarioId: 'u-1', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }),
|
||||
)
|
||||
const { res, setHeader, sendFile } = resFake()
|
||||
|
||||
await descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), res)
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
|
||||
expect(sendFile).toHaveBeenCalledWith(absoluta)
|
||||
})
|
||||
|
||||
it('el manager descarga el adjunto de otro usuario', async () => {
|
||||
const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.png'))
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
|
||||
filaFake({ usuarioId: 'otro', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }),
|
||||
)
|
||||
const { res, sendFile } = resFake()
|
||||
|
||||
await descargarArchivoSoporteEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'sol-1', indice: '0' },
|
||||
usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' },
|
||||
}),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(sendFile).toHaveBeenCalledWith(absoluta)
|
||||
})
|
||||
|
||||
it('403 si la solicitud es de otro usuario y no es admin/manager', async () => {
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
|
||||
filaFake({ usuarioId: 'otro', archivos: JSON.stringify(['soporte/otro/x.pdf']) }),
|
||||
)
|
||||
await expect(
|
||||
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res),
|
||||
).rejects.toThrow('Sin permiso')
|
||||
})
|
||||
|
||||
it('404 si la solicitud o el índice no existen', async () => {
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null)
|
||||
await expect(
|
||||
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'nope', indice: '0' } }), resFake().res),
|
||||
).rejects.toThrow('Solicitud no encontrada')
|
||||
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
|
||||
filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }),
|
||||
)
|
||||
await expect(
|
||||
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '5' } }), resFake().res),
|
||||
).rejects.toThrow('Archivo no encontrado')
|
||||
})
|
||||
|
||||
it('400 si la ruta guardada apunta fuera de uploads (path traversal)', async () => {
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
|
||||
filaFake({ archivos: JSON.stringify(['../../etc/passwd']) }),
|
||||
)
|
||||
await expect(
|
||||
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res),
|
||||
).rejects.toThrow('Ruta de archivo inválida')
|
||||
})
|
||||
|
||||
it('400 si el índice no es un entero válido', async () => {
|
||||
await expect(
|
||||
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: 'x' } }), resFake().res),
|
||||
).rejects.toThrow('Índice de archivo inválido')
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /api/soporte/:id ───────────────────────────────────────────
|
||||
|
||||
describe('cambiarEstadoSolicitudEndpoint', () => {
|
||||
it('cambia el estado y deja auditoría', async () => {
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(filaFake({ estado: 'ABIERTA' }))
|
||||
const { res, json } = resFake()
|
||||
|
||||
await cambiarEstadoSolicitudEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'sol-1' },
|
||||
body: { estado: 'EN_PROCESO' },
|
||||
usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' },
|
||||
}),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(prismaMock.solicitudSoporte.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sol-1' },
|
||||
data: { estado: 'EN_PROCESO' },
|
||||
})
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ estado: 'EN_PROCESO' }))
|
||||
expect(auditoriaMock.registrar).toHaveBeenCalledWith(
|
||||
'm-1',
|
||||
'CAMBIAR_ESTADO_SOPORTE',
|
||||
'soporte',
|
||||
'sol-1',
|
||||
'ABIERTA→EN_PROCESO',
|
||||
)
|
||||
})
|
||||
|
||||
it('404 si la solicitud no existe', async () => {
|
||||
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null)
|
||||
await expect(
|
||||
cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'nope' }, body: { estado: 'CERRADA' } }), resFake().res),
|
||||
).rejects.toThrow('Solicitud no encontrada')
|
||||
})
|
||||
|
||||
it('400 si el estado no es válido', async () => {
|
||||
await expect(
|
||||
cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'sol-1' }, body: { estado: 'VOLANDO' } }), resFake().res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireAdminOManager', () => {
|
||||
it('deja pasar a admin y manager', () => {
|
||||
for (const rol of ['admin', 'manager']) {
|
||||
const next = vi.fn()
|
||||
requireAdminOManager(reqFake({ usuarioToken: { sub: 'x', username: 'x', rol } }), resFake().res, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('rechaza a un vendedor', () => {
|
||||
expect(() =>
|
||||
requireAdminOManager(reqFake(), resFake().res, vi.fn()),
|
||||
).toThrow('Se requiere rol admin o manager')
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,44 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import multer from 'multer'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
|
||||
/**
|
||||
* Módulo Soporte: solicitudes dirigidas a los departamentos internos
|
||||
* (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección).
|
||||
*
|
||||
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado).
|
||||
* - GET /api/soporte — listar solicitudes: propias para cualquier usuario,
|
||||
* TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA).
|
||||
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado). Acepta
|
||||
* multipart con los adjuntos reales en el campo `archivos` (PDF/JPG/PNG,
|
||||
* máx 10 MB c/u, hasta 10) o JSON sin adjuntos.
|
||||
* - GET /api/soporte — listar solicitudes: propias para un vendedor, TODAS
|
||||
* para admin y manager, con filtros opcionales ?estado= y ?departamento=.
|
||||
* - GET /api/soporte/:id/archivos/:indice — descargar un adjunto (dueño,
|
||||
* admin o manager).
|
||||
* - PATCH /api/soporte/:id — cambiar el estado (solo admin/manager).
|
||||
*/
|
||||
|
||||
const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const
|
||||
const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
|
||||
const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
|
||||
|
||||
const MIME_PERMITIDOS_SOPORTE = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
||||
const EXT_POR_MIME_SOPORTE: Record<string, string> = {
|
||||
'application/pdf': 'pdf',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
}
|
||||
|
||||
const MAX_ARCHIVOS_SOPORTE = 10
|
||||
const MAX_BYTES_SOPORTE = 10 * 1024 * 1024
|
||||
|
||||
const crearSolicitudSchema = z.object({
|
||||
titulo: z.string().trim().min(1, 'El título es obligatorio'),
|
||||
descripcion: z.string().trim().min(1, 'La descripción es obligatoria'),
|
||||
@@ -29,16 +52,104 @@ const crearSolicitudSchema = z.object({
|
||||
// username del token si viene vacío
|
||||
solicitante: z.string().trim().min(1).optional(),
|
||||
email: z.string().trim().email('Email inválido').optional(),
|
||||
// Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda
|
||||
// nombres; se guardan como JSON array)
|
||||
// Rutas relativas de adjuntos YA subidos; con multipart se ignoran y se toman
|
||||
// los archivos reales que entregó multer (nadie declara rutas a mano)
|
||||
archivos: z.array(z.string().trim().min(1)).optional(),
|
||||
})
|
||||
|
||||
const listarSolicitudSchema = z.object({
|
||||
estado: z.enum(ESTADOS_SOPORTE).optional(),
|
||||
// '' = sin filtro: el front a veces manda el select vacío
|
||||
estado: z.enum(ESTADOS_SOPORTE).or(z.literal('')).optional(),
|
||||
departamento: z.enum(DEPARTAMENTOS_SOPORTE).or(z.literal('')).optional(),
|
||||
})
|
||||
|
||||
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */
|
||||
const cambiarEstadoSchema = z.object({
|
||||
estado: z.enum(ESTADOS_SOPORTE),
|
||||
})
|
||||
|
||||
/** Request con el id de la solicitud ya reservado para la carpeta de adjuntos */
|
||||
type RequestConIdSoporte = Request & { solicitudSoporteId?: string }
|
||||
|
||||
/**
|
||||
* Id de la solicitud en curso. Se reserva ANTES de multer para que los adjuntos
|
||||
* aterricen en uploads/soporte/<id>/ con el mismo id que tendrá la fila.
|
||||
*/
|
||||
function idSolicitudDe(req: Request): string {
|
||||
const conId = req as RequestConIdSoporte
|
||||
conId.solicitudSoporteId ??= randomUUID()
|
||||
return conId.solicitudSoporteId
|
||||
}
|
||||
|
||||
/** Carpeta de adjuntos de una solicitud: uploads/soporte/<id>/ */
|
||||
function carpetaSoporte(id: string): string {
|
||||
return path.join(config.uploadsDir, 'soporte', id)
|
||||
}
|
||||
|
||||
/** Nombre seguro y único: uuid corto + nombre original saneado (sin rutas) */
|
||||
function nombreSeguro(original: string): string {
|
||||
const base = path.basename(original).replace(/[^\w.\- ]+/g, '_').slice(-80).trim()
|
||||
return base.length > 0 ? base : 'archivo'
|
||||
}
|
||||
|
||||
const storageSoporte = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const dir = carpetaSoporte(idSolicitudDe(req))
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
cb(null, dir)
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = EXT_POR_MIME_SOPORTE[file.mimetype] ?? 'bin'
|
||||
const base = nombreSeguro(file.originalname)
|
||||
cb(null, `${randomUUID().slice(0, 8)}-${base.endsWith(`.${ext}`) ? base : `${base}.${ext}`}`)
|
||||
},
|
||||
})
|
||||
|
||||
const multerArchivosSoporte = multer({
|
||||
storage: storageSoporte,
|
||||
limits: { fileSize: MAX_BYTES_SOPORTE, files: MAX_ARCHIVOS_SOPORTE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!MIME_PERMITIDOS_SOPORTE.has(file.mimetype)) {
|
||||
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype} (solo PDF, JPG o PNG)`))
|
||||
return
|
||||
}
|
||||
cb(null, true)
|
||||
},
|
||||
}).array('archivos', MAX_ARCHIVOS_SOPORTE)
|
||||
|
||||
/**
|
||||
* Middleware de subida: recibe hasta 10 adjuntos en el campo `archivos`. Si
|
||||
* multer corta a mitad (tipo no permitido, archivo > 10 MB, demasiados
|
||||
* archivos) borra lo que haya quedado en la carpeta de la solicitud.
|
||||
*/
|
||||
export function uploadArchivosSoporte(req: Request, res: Response, next: NextFunction): void {
|
||||
multerArchivosSoporte(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
borrarCarpetaSoporte(idSolicitudDe(req))
|
||||
next(err)
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
/** Borra la carpeta de adjuntos de una solicitud que no llegó a crearse */
|
||||
function borrarCarpetaSoporte(id: string): void {
|
||||
fs.rmSync(carpetaSoporte(id), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** archivos JSON → array de rutas relativas (tolerante a basura) */
|
||||
function parsearArchivos(archivos: string | null): string[] {
|
||||
if (!archivos) return []
|
||||
try {
|
||||
const parseado: unknown = JSON.parse(archivos)
|
||||
if (Array.isArray(parseado)) return parseado.filter((a): a is string => typeof a === 'string')
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializa la fila para el front: archivos JSON → array de rutas */
|
||||
function aPublico(fila: {
|
||||
id: string
|
||||
usuarioId: string
|
||||
@@ -57,24 +168,40 @@ function aPublico(fila: {
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}) {
|
||||
let archivos: string[] = []
|
||||
if (fila.archivos) {
|
||||
try {
|
||||
const parseado: unknown = JSON.parse(fila.archivos)
|
||||
if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string')
|
||||
} catch {
|
||||
archivos = []
|
||||
}
|
||||
}
|
||||
return { ...fila, archivos }
|
||||
return { ...fila, archivos: parsearArchivos(fila.archivos) }
|
||||
}
|
||||
|
||||
/** POST /api/soporte — crear solicitud de soporte */
|
||||
/** MIME por extensión para servir el adjunto descargado */
|
||||
function mimeDeArchivo(ruta: string): string {
|
||||
const ext = path.extname(ruta).toLowerCase()
|
||||
if (ext === '.pdf') return 'application/pdf'
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
/** POST /api/soporte — crear solicitud de soporte (JSON o multipart) */
|
||||
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearSolicitudSchema.parse(req.body)
|
||||
const id = idSolicitudDe(req)
|
||||
let body: z.infer<typeof crearSolicitudSchema>
|
||||
try {
|
||||
body = crearSolicitudSchema.parse(req.body)
|
||||
} catch (err) {
|
||||
// Los adjuntos ya están en disco: sin solicitud que los referencie, sobran
|
||||
borrarCarpetaSoporte(id)
|
||||
throw err
|
||||
}
|
||||
|
||||
const subidos = (req.files ?? []) as Express.Multer.File[]
|
||||
const archivos =
|
||||
subidos.length > 0
|
||||
? subidos.map((f) => path.relative(config.uploadsDir, f.path))
|
||||
: (body.archivos ?? [])
|
||||
|
||||
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
|
||||
const fila = await prisma.solicitudSoporte.create({
|
||||
data: {
|
||||
id,
|
||||
usuarioId,
|
||||
titulo: body.titulo,
|
||||
descripcion: body.descripcion,
|
||||
@@ -84,7 +211,7 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
|
||||
fechaSolicitud: body.fechaSolicitud,
|
||||
fechaLimite: body.fechaLimite ?? null,
|
||||
notas: body.notas ?? null,
|
||||
archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null,
|
||||
archivos: archivos.length > 0 ? JSON.stringify(archivos) : null,
|
||||
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
|
||||
solicitanteEmail: body.email ?? null,
|
||||
},
|
||||
@@ -93,15 +220,18 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
|
||||
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
|
||||
}
|
||||
|
||||
/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */
|
||||
/**
|
||||
* GET /api/soporte — listar solicitudes. Admin y manager ven TODAS (la UI les
|
||||
* dice "Todas las solicitudes"); un vendedor solo ve las suyas.
|
||||
*/
|
||||
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const { estado } = listarSolicitudSchema.parse(req.query)
|
||||
const { estado, departamento } = listarSolicitudSchema.parse(req.query)
|
||||
const where: Record<string, unknown> = {}
|
||||
// Admin ve todas; el resto solo las suyas
|
||||
if (req.usuarioToken?.rol !== 'admin') {
|
||||
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
||||
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
|
||||
}
|
||||
if (estado !== undefined) where['estado'] = estado
|
||||
if (estado) where['estado'] = estado
|
||||
if (departamento) where['departamento'] = departamento
|
||||
|
||||
const filas = await prisma.solicitudSoporte.findMany({
|
||||
where,
|
||||
@@ -109,3 +239,68 @@ export async function listarSolicitudesSoporteEndpoint(req: Request, res: Respon
|
||||
})
|
||||
res.json(filas.map(aPublico))
|
||||
}
|
||||
|
||||
/** Middleware: solo admin/manager pueden mover solicitudes de estado */
|
||||
export function requireAdminOManager(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
||||
throw new AppError(403, 'Se requiere rol admin o manager')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/** PATCH /api/soporte/:id — cambiar estado (admin/manager) */
|
||||
export async function cambiarEstadoSolicitudEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = String(req.params['id'])
|
||||
const { estado } = cambiarEstadoSchema.parse(req.body)
|
||||
|
||||
const existente = await prisma.solicitudSoporte.findUnique({ where: { id } })
|
||||
if (!existente) throw new NotFoundError('Solicitud no encontrada')
|
||||
|
||||
const fila = await prisma.solicitudSoporte.update({ where: { id }, data: { estado } })
|
||||
res.json(aPublico(fila))
|
||||
auditoria.registrar(
|
||||
req.usuarioToken?.sub ?? 'desconocido',
|
||||
'CAMBIAR_ESTADO_SOPORTE',
|
||||
'soporte',
|
||||
fila.id,
|
||||
`${existente.estado}→${estado}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/soporte/:id/archivos/:indice — descarga un adjunto de la solicitud.
|
||||
* Solo el dueño, un admin o un manager. El índice es la posición en
|
||||
* `archivos`, así el cliente nunca propone una ruta.
|
||||
*/
|
||||
export async function descargarArchivoSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = String(req.params['id'])
|
||||
const indice = Number(req.params['indice'])
|
||||
if (!Number.isInteger(indice) || indice < 0) {
|
||||
throw new BadRequestError('Índice de archivo inválido')
|
||||
}
|
||||
|
||||
const fila = await prisma.solicitudSoporte.findUnique({ where: { id } })
|
||||
if (!fila) throw new NotFoundError('Solicitud no encontrada')
|
||||
|
||||
const propio = fila.usuarioId === (req.usuarioToken?.sub ?? 'desconocido')
|
||||
if (!propio && !esAdminOManager(req.usuarioToken?.rol)) {
|
||||
throw new AppError(403, 'Sin permiso para ver los adjuntos de esta solicitud')
|
||||
}
|
||||
|
||||
const archivo = parsearArchivos(fila.archivos)[indice]
|
||||
if (archivo === undefined) throw new NotFoundError('Archivo no encontrado')
|
||||
|
||||
// Guarda anti path-traversal: la ruta guardada debe vivir dentro de uploadsDir
|
||||
const rutaAbsoluta = path.resolve(config.uploadsDir, archivo)
|
||||
if (!rutaAbsoluta.startsWith(config.uploadsDir + path.sep)) {
|
||||
throw new BadRequestError('Ruta de archivo inválida')
|
||||
}
|
||||
if (!fs.existsSync(rutaAbsoluta)) throw new NotFoundError('Archivo no encontrado en disco')
|
||||
|
||||
res.setHeader('Content-Type', mimeDeArchivo(rutaAbsoluta))
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${path.basename(rutaAbsoluta).replace(/[^\w.\- ]+/g, '_')}"`,
|
||||
)
|
||||
res.sendFile(rutaAbsoluta)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
capturaDepartamento: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
@@ -37,6 +40,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findMany.mockResolvedValue([filaLocal])
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaLocal)
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('unidades.controller (local)', () => {
|
||||
@@ -58,6 +62,18 @@ describe('unidades.controller (local)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('la fecha de entrega sale de la captura del departamento (ESCRITURACION/OBRA)', async () => {
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarUnidades({ query: {} } as unknown as Request, res)
|
||||
|
||||
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
|
||||
expect(lista[0]).toMatchObject({ fechaEntrega: '2027-10-08' })
|
||||
})
|
||||
|
||||
it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
filaLocal,
|
||||
|
||||
67
api-gateway/src/controllers/wacrm.controller.ts
Normal file
67
api-gateway/src/controllers/wacrm.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Endpoints del módulo WhatsApp (WA CRM): bandeja del vendedor, estado del
|
||||
* round-robin para administración, configuración y reasignación manual.
|
||||
*/
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import {
|
||||
configurarWacrm,
|
||||
estadoWacrm,
|
||||
ejecutarRoundRobinWacrm,
|
||||
miBandeja,
|
||||
reasignarConversacion,
|
||||
} from '../services/wacrm.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
|
||||
/** Admin/manager: estado, configuración y reasignación. */
|
||||
export function requireRolWacrmAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
const rol = req.usuarioToken?.rol
|
||||
if (rol !== 'admin' && rol !== 'manager') {
|
||||
throw new AppError(403, 'Requiere rol de administrador')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/** Bandeja de WhatsApp del vendedor logueado (vínculo vía wacrmUserId). */
|
||||
export async function miBandejaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new AppError(401, 'Sesión requerida')
|
||||
const resultado = await miBandeja(usuarioId)
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
export async function estadoWacrmEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await estadoWacrm())
|
||||
}
|
||||
|
||||
const configSchema = z
|
||||
.object({
|
||||
cuentaId: z.string().max(64).optional(),
|
||||
activo: z.boolean().optional(),
|
||||
})
|
||||
.refine((o) => Object.keys(o).length > 0, { message: 'Nada que actualizar' })
|
||||
|
||||
export async function configurarWacrmEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = configSchema.parse(req.body)
|
||||
const resultado = await configurarWacrm(body)
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CONFIG_WACRM', 'configuracion', 'wacrm', JSON.stringify(body))
|
||||
res.json(resultado)
|
||||
}
|
||||
|
||||
const reasignarSchema = z.object({
|
||||
conversacionId: z.string().min(10),
|
||||
vendedorId: z.string().min(10),
|
||||
})
|
||||
|
||||
export async function reasignarWacrmEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = reasignarSchema.parse(req.body)
|
||||
await reasignarConversacion(body)
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'REASIGNAR_WACRM', 'conversacion', body.conversacionId, body.vendedorId)
|
||||
res.json({ ok: true })
|
||||
}
|
||||
|
||||
/** Dispara el round-robin a demanda (admin) — útil para pruebas. */
|
||||
export async function roundRobinWacrmEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await ejecutarRoundRobinWacrm())
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { crearApp } from './app.js'
|
||||
import { config } from './config/index.js'
|
||||
import { automatizacionesService } from './services/automatizaciones.service.js'
|
||||
import * as configService from './services/config.service.js'
|
||||
import { ejecutarRoundRobinWacrm } from './services/wacrm.service.js'
|
||||
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
|
||||
|
||||
const app = crearApp()
|
||||
@@ -64,6 +65,18 @@ if (config.automatizaciones.enabled) {
|
||||
})
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
|
||||
|
||||
// Round-robin de conversaciones entrantes de WhatsApp (WA CRM). Cada 30 s
|
||||
// revisa conversaciones sin agente de la cuenta configurada y las turna
|
||||
// entre los vendedores vinculados. Silencioso cuando no hay nada que hacer.
|
||||
cron.schedule('*/30 * * * * *', () => {
|
||||
void ejecutarRoundRobinWacrm().catch((err: unknown) => {
|
||||
// 503 = wacrm aún no configurado: se espera, no es error de log ruidoso
|
||||
const e = err as { status?: number }
|
||||
if (e?.status && e.status < 500) return
|
||||
console.error('[cron wacrm] error en round-robin WhatsApp:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
|
||||
|
||||
@@ -124,6 +124,34 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('deja pasar los endpoints públicos de desarrollos (micrositio sin sesión)', () => {
|
||||
const casos: Partial<Request>[] = [
|
||||
{ method: 'GET', path: '/publico/desarrollos' },
|
||||
{ method: 'GET', path: '/publico/desarrollos/SENDA' },
|
||||
]
|
||||
for (const c of casos) {
|
||||
vi.clearAllMocks()
|
||||
authMiddleware(reqFake(c), resFake().res, next)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
|
||||
it('el patrón público de desarrollos no abre el CRUD de admin', () => {
|
||||
const casos: Partial<Request>[] = [
|
||||
{ method: 'POST', path: '/publico/desarrollos' },
|
||||
{ method: 'DELETE', path: '/publico/desarrollos/SENDA' },
|
||||
{ method: 'GET', path: '/publico/desarrollos/SENDA/unidades' },
|
||||
{ method: 'GET', path: '/desarrollos' },
|
||||
{ method: 'GET', path: '/desarrollos/d-1' },
|
||||
{ method: 'POST', path: '/desarrollos/d-1/unidades' },
|
||||
]
|
||||
for (const c of casos) {
|
||||
const { res, status } = resFake()
|
||||
authMiddleware(reqFake(c), res, next)
|
||||
expect(status).toHaveBeenCalledWith(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => {
|
||||
authMiddleware(
|
||||
reqFake({
|
||||
|
||||
@@ -20,6 +20,10 @@ const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
|
||||
{ metodo: 'GET', patron: /^\/clientes\/publico\/[^/]+$/ },
|
||||
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/firma$/ },
|
||||
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/documentos$/ },
|
||||
// Micrositio: fichas públicas de desarrollos y su inventario disponible
|
||||
// (solo información comercial, jamás datos de clientes ni cotizaciones).
|
||||
{ metodo: 'GET', patron: /^\/publico\/desarrollos$/ },
|
||||
{ metodo: 'GET', patron: /^\/publico\/desarrollos\/[^/]+$/ },
|
||||
]
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppError } from '../utils/errors.js'
|
||||
import { Router } from 'express'
|
||||
import { health } from '../controllers/health.controller.js'
|
||||
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
|
||||
import { reglasCotizadorEndpoint } from '../controllers/reglas-cotizador.controller.js'
|
||||
import {
|
||||
alertasInventarioEndpoint,
|
||||
crearCotizacion,
|
||||
@@ -14,12 +15,14 @@ import {
|
||||
actualizarProgreso,
|
||||
completarProgreso,
|
||||
crearCliente,
|
||||
importarEnkontrolEndpoint,
|
||||
iniciarProgreso,
|
||||
listarDocumentos,
|
||||
obtenerCliente,
|
||||
prefillApartadoEndpoint,
|
||||
subirDocumentos,
|
||||
uploadDocumentos,
|
||||
uploadImportacionEnkontrol,
|
||||
} from '../controllers/clientes.controller.js'
|
||||
import {
|
||||
eliminarBorrador,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
uploadDocumentosPublico,
|
||||
} from '../controllers/liga.controller.js'
|
||||
import {
|
||||
actualizarPlan,
|
||||
agregarNota,
|
||||
cancelarPlan,
|
||||
cobranzaHoy,
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
listarPlanes,
|
||||
obtenerPlan,
|
||||
registrarAbonoEndpoint,
|
||||
requireEditaPlan,
|
||||
timelinePlan,
|
||||
} from '../controllers/planes.controller.js'
|
||||
import {
|
||||
@@ -54,6 +59,7 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda
|
||||
import {
|
||||
cambiarEstadoLeadEndpoint,
|
||||
crearAfluenciaEndpoint,
|
||||
obtenerAfluenciaEndpoint,
|
||||
crearLeadEndpoint,
|
||||
buscarLeadsEndpoint,
|
||||
convertirLeadEndpoint,
|
||||
@@ -90,6 +96,14 @@ import {
|
||||
subirDocumento,
|
||||
} from '../controllers/ai.controller.js'
|
||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
||||
import {
|
||||
configurarWacrmEndpoint,
|
||||
estadoWacrmEndpoint,
|
||||
miBandejaEndpoint,
|
||||
reasignarWacrmEndpoint,
|
||||
requireRolWacrmAdmin,
|
||||
roundRobinWacrmEndpoint,
|
||||
} from '../controllers/wacrm.controller.js'
|
||||
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
|
||||
import {
|
||||
requireRolReportes,
|
||||
@@ -148,14 +162,35 @@ import {
|
||||
import {
|
||||
listarEndpoint as listarMaestroEndpoint,
|
||||
exportarEndpoint as exportarMaestroEndpoint,
|
||||
catalogosEndpoint as catalogosMaestroEndpoint,
|
||||
obtenerEndpoint as obtenerMaestroEndpoint,
|
||||
importarEndpoint as importarMaestroEndpoint,
|
||||
requireAdmin as requireAdminMaestro,
|
||||
} from '../controllers/cliente-maestro.controller.js'
|
||||
import {
|
||||
cambiarEstadoSolicitudEndpoint,
|
||||
crearSolicitudSoporteEndpoint,
|
||||
descargarArchivoSoporteEndpoint,
|
||||
listarSolicitudesSoporteEndpoint,
|
||||
requireAdminOManager,
|
||||
uploadArchivosSoporte,
|
||||
} from '../controllers/soporte.controller.js'
|
||||
import {
|
||||
actualizarDesarrolloEndpoint,
|
||||
actualizarModeloEndpoint,
|
||||
actualizarUnidadEndpoint,
|
||||
agregarModeloEndpoint,
|
||||
crearDesarrolloEndpoint,
|
||||
crearUnidadesEndpoint,
|
||||
eliminarDesarrolloEndpoint,
|
||||
eliminarModeloEndpoint,
|
||||
eliminarUnidadEndpoint,
|
||||
guardarReglasEndpoint,
|
||||
listarDesarrollosEndpoint,
|
||||
listarDesarrollosPublicosEndpoint,
|
||||
obtenerDesarrolloEndpoint,
|
||||
obtenerDesarrolloPublicoEndpoint,
|
||||
} from '../controllers/desarrollos.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
@@ -169,6 +204,11 @@ apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint)
|
||||
apiRouter.get('/unidades', listarUnidades)
|
||||
apiRouter.get('/unidades/:codigo', obtenerUnidad)
|
||||
|
||||
// Reglas comerciales del cotizador (BD del admin con respaldo en constantes).
|
||||
// Ruta fija: debe ir ANTES de cualquier '/desarrollos/:id' que agregue el
|
||||
// módulo de administración para no quedar tapada por el parámetro.
|
||||
apiRouter.get('/desarrollos/reglas-cotizador', reglasCotizadorEndpoint)
|
||||
|
||||
apiRouter.post('/cotizaciones', crearCotizacion)
|
||||
apiRouter.get('/cotizaciones', listarCotizaciones)
|
||||
apiRouter.get('/cotizaciones/mias', listarMisCotizaciones)
|
||||
@@ -178,6 +218,14 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||
|
||||
apiRouter.post('/clientes', crearCliente)
|
||||
// Importación de información desde un documento de Enkontrol (ID Enkontrol =
|
||||
// campo determinante). Solo administración.
|
||||
apiRouter.post(
|
||||
'/clientes/importar-enkontrol',
|
||||
requireAdminOManager,
|
||||
uploadImportacionEnkontrol.single('archivo'),
|
||||
importarEnkontrolEndpoint,
|
||||
)
|
||||
// Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
|
||||
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
|
||||
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
|
||||
@@ -229,6 +277,8 @@ apiRouter.get('/planes/:id/timeline', requireModuloCobranza, timelinePlan)
|
||||
apiRouter.post('/planes/:id/abonos', requireModuloCobranza, registrarAbonoEndpoint)
|
||||
apiRouter.post('/planes/:id/notas', requireModuloCobranza, agregarNota)
|
||||
apiRouter.post('/planes/:id/cancelar', requireModuloCobranza, cancelarPlan)
|
||||
// Editar la tasa de mora del plan: solo admin/manager/dirección
|
||||
apiRouter.patch('/planes/:id', requireModuloCobranza, requireEditaPlan, actualizarPlan)
|
||||
|
||||
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
|
||||
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
|
||||
@@ -244,6 +294,15 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
||||
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
||||
|
||||
// ── Módulo WhatsApp (WA CRM) ─────────────────────────────────────────
|
||||
// Bandeja del vendedor logueado (vínculo Usuario.wacrmUserId)
|
||||
apiRouter.get('/wacrm/mi-bandeja', miBandejaEndpoint)
|
||||
// Administración del round-robin (admin/manager)
|
||||
apiRouter.get('/wacrm/estado', requireRolWacrmAdmin, estadoWacrmEndpoint)
|
||||
apiRouter.post('/wacrm/config', requireRolWacrmAdmin, configurarWacrmEndpoint)
|
||||
apiRouter.post('/wacrm/reasignar', requireRolWacrmAdmin, reasignarWacrmEndpoint)
|
||||
apiRouter.post('/wacrm/round-robin', requireRolWacrmAdmin, roundRobinWacrmEndpoint)
|
||||
// Precarga de documentos del expediente desde "Mis apartados": se guardan en
|
||||
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
||||
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
||||
@@ -275,6 +334,8 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
|
||||
|
||||
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
||||
// Perfil de afluencia capturado de un lead/oportunidad/apartado (bitácora de Mi día)
|
||||
apiRouter.get('/afluencias/:leadId', obtenerAfluenciaEndpoint)
|
||||
|
||||
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
||||
|
||||
@@ -309,9 +370,11 @@ apiRouter.post('/ai/chat/stream', chatIAStream)
|
||||
apiRouter.get('/ai/conversations', listarConversaciones)
|
||||
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
|
||||
apiRouter.post('/ai/documents', subirDocumento)
|
||||
apiRouter.get('/ai/documents', listarDocumentosIA)
|
||||
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
||||
// Mantenimiento de la base de conocimiento: SOLO admin (admin/manager no
|
||||
// basta: borrar o inyectar documentos afecta a todos los usuarios del chat).
|
||||
apiRouter.post('/ai/documents', requireAdminEstricto, subirDocumento)
|
||||
apiRouter.get('/ai/documents', requireAdminEstricto, listarDocumentosIA)
|
||||
apiRouter.delete('/ai/documents/:id', requireAdminEstricto, eliminarDocumento)
|
||||
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
|
||||
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
|
||||
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
|
||||
@@ -345,11 +408,34 @@ apiRouter.post('/push/desuscribir', desuscribirEndpoint)
|
||||
|
||||
// ── Clientes Maestro ──────────────────────────────────────────────
|
||||
apiRouter.get('/clientes-maestro', listarMaestroEndpoint)
|
||||
// Exportar va ANTES de /:numCliente para no colisionar con el parámetro
|
||||
// Rutas fijas ANTES de /:numCliente para no colisionar con el parámetro
|
||||
apiRouter.get('/clientes-maestro/catalogos', catalogosMaestroEndpoint)
|
||||
apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint)
|
||||
apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint)
|
||||
apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint)
|
||||
|
||||
// ── Módulo Soporte: solicitudes a departamentos internos ──────────
|
||||
apiRouter.post('/soporte', crearSolicitudSoporteEndpoint)
|
||||
apiRouter.post('/soporte', uploadArchivosSoporte, crearSolicitudSoporteEndpoint)
|
||||
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)
|
||||
apiRouter.get('/soporte/:id/archivos/:indice', descargarArchivoSoporteEndpoint)
|
||||
apiRouter.patch('/soporte/:id', requireAdminOManager, cambiarEstadoSolicitudEndpoint)
|
||||
|
||||
// ── Módulo Desarrollos: catálogo en BD (solo admin) ─────────────────
|
||||
// Público primero (sin auth, lo consume el micrositio): ver RUTAS_PUBLICAS/
|
||||
// PATRONES_PUBLICOS en middleware/auth.middleware.ts
|
||||
apiRouter.get('/publico/desarrollos', listarDesarrollosPublicosEndpoint)
|
||||
apiRouter.get('/publico/desarrollos/:clave', obtenerDesarrolloPublicoEndpoint)
|
||||
|
||||
apiRouter.get('/desarrollos', requireAdminEstricto, listarDesarrollosEndpoint)
|
||||
apiRouter.post('/desarrollos', requireAdminEstricto, crearDesarrolloEndpoint)
|
||||
// Rutas fijas de modelos ANTES de /desarrollos/:id para no colisionar con el parámetro
|
||||
apiRouter.patch('/desarrollos/modelos/:modeloId', requireAdminEstricto, actualizarModeloEndpoint)
|
||||
apiRouter.delete('/desarrollos/modelos/:modeloId', requireAdminEstricto, eliminarModeloEndpoint)
|
||||
apiRouter.get('/desarrollos/:id', requireAdminEstricto, obtenerDesarrolloEndpoint)
|
||||
apiRouter.patch('/desarrollos/:id', requireAdminEstricto, actualizarDesarrolloEndpoint)
|
||||
apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEndpoint)
|
||||
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
|
||||
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
|
||||
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
|
||||
apiRouter.patch('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, actualizarUnidadEndpoint)
|
||||
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
|
||||
import { prisma } from './prisma.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
import { getUnidadesLocal } from './unidades-local.service.js'
|
||||
import {
|
||||
APARTADO_DEFAULT,
|
||||
@@ -1020,10 +1021,12 @@ export function crearEjecutoresHerramientas(
|
||||
})
|
||||
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
|
||||
const hoy = new Date()
|
||||
// Medianoche de hoy: lo que vence HOY aún no está vencido
|
||||
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||
return planes.map((p) => {
|
||||
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
|
||||
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
|
||||
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto)
|
||||
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < inicioHoy && x.montoPagado < x.monto)
|
||||
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
|
||||
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
|
||||
const abonos = p.pagos
|
||||
@@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas(
|
||||
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
|
||||
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
|
||||
prisma.pago.findMany({
|
||||
where: { fechaVencimiento: { lt: new Date() }, plan: alcance },
|
||||
// Sólo planes activos: el adeudo de planes cancelados no cuenta
|
||||
where: { fechaVencimiento: { lt: new Date() }, plan: { ...alcance, status: 'ACTIVO' } },
|
||||
select: {
|
||||
monto: true,
|
||||
montoPagado: true,
|
||||
@@ -1505,6 +1509,18 @@ export async function chat(
|
||||
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
|
||||
: null
|
||||
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||
// Propiedad: sólo el dueño (o un rol de supervisión) puede reanudar una
|
||||
// conversación existente. Sin esto, cualquiera con el uuid continuaba el
|
||||
// chat ajeno con todo su historial.
|
||||
if (
|
||||
conversacion &&
|
||||
opciones.usuarioId &&
|
||||
conversacion.usuarioId &&
|
||||
conversacion.usuarioId !== opciones.usuarioId &&
|
||||
!esAdminOManager(opciones.contexto?.rol)
|
||||
) {
|
||||
throw new AppError(403, 'No puedes continuar la conversación de otro usuario')
|
||||
}
|
||||
conversacion ??= await prisma.aIConversacion.create({
|
||||
data: { usuarioId: opciones.usuarioId ?? null },
|
||||
})
|
||||
|
||||
@@ -94,7 +94,8 @@ describe('buscar', () => {
|
||||
prismaMock.usuario.findMany.mockResolvedValue([])
|
||||
await buscar({ desde, hasta })
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
||||
where: { createdAt: { gte: desde, lte: hasta } },
|
||||
// "hasta" incluye todo el día: se excluye con lt al día siguiente
|
||||
where: { createdAt: { gte: desde, lt: new Date(hasta.getTime() + 24 * 60 * 60 * 1000) } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
|
||||
@@ -43,7 +43,9 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
|
||||
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
|
||||
where['createdAt'] = {
|
||||
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
|
||||
...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}),
|
||||
// "hasta" es inclusive de todo el día: se excluye con lt al día siguiente
|
||||
// (antes con lte a las 00:00 del día se perdía casi todo el día elegido)
|
||||
...(filtros.hasta !== undefined ? { lt: new Date(filtros.hasta.getTime() + 24 * 60 * 60 * 1000) } : {}),
|
||||
}
|
||||
}
|
||||
const logs = await prisma.auditoriaLog.findMany({
|
||||
|
||||
@@ -155,8 +155,8 @@ describe('buscar (GET /api/buscar)', () => {
|
||||
])
|
||||
|
||||
const r = await buscar('X')
|
||||
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
|
||||
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
|
||||
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/hoy/')
|
||||
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/')
|
||||
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
|
||||
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
|
||||
})
|
||||
|
||||
@@ -13,25 +13,38 @@ export interface BuscadoItem {
|
||||
* (oportunidades con cotización), y Unidad (inventario).
|
||||
* Case-insensitive sobre los campos indicados; limita a `limite` resultados.
|
||||
*/
|
||||
export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[]> {
|
||||
export async function buscar(
|
||||
termino: string,
|
||||
limite = 20,
|
||||
alcance?: { usuarioId: string; soloPropio: boolean },
|
||||
): Promise<BuscadoItem[]> {
|
||||
const q = termino.trim()
|
||||
if (q === '') return []
|
||||
const pat = `%${q}%`
|
||||
|
||||
// Privacidad por vendedor: cada asesor sólo encuentra SUS leads, contactos
|
||||
// y cotizaciones. Los roles de supervisión llegan sin filtro (soloPropio).
|
||||
const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {}
|
||||
const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {}
|
||||
const propiaCotizacion = alcance?.soloPropio ? { vendedorId: alcance.usuarioId } : {}
|
||||
|
||||
const [leads, clientes, cotizaciones, unidades] = await Promise.all([
|
||||
prisma.leadCaptura.findMany({
|
||||
where: {
|
||||
AND: [propioLead, {
|
||||
OR: [
|
||||
{ nombreLead: { contains: q } },
|
||||
{ telefono: { contains: q } },
|
||||
{ email: { contains: q } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
prisma.cliente.findMany({
|
||||
where: {
|
||||
AND: [propioCliente, {
|
||||
OR: [
|
||||
{ nombreCompleto: { contains: q } },
|
||||
{ curp: { contains: q } },
|
||||
@@ -39,17 +52,20 @@ export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[
|
||||
{ email: { contains: q } },
|
||||
{ celular: { contains: q } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
prisma.cotizacion.findMany({
|
||||
where: {
|
||||
AND: [propiaCotizacion, {
|
||||
OR: [
|
||||
{ clienteNombre: { contains: q } },
|
||||
{ clienteEmail: { contains: q } },
|
||||
{ unidadCodigo: { contains: q } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limite,
|
||||
@@ -72,14 +88,14 @@ export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[
|
||||
id: l.leadId,
|
||||
nombre: l.nombreLead,
|
||||
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
|
||||
url: `/app/#leads`,
|
||||
url: `/hoy/`,
|
||||
})),
|
||||
...clientes.map((c) => ({
|
||||
tipo: 'contacto' as const,
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
|
||||
url: `/app/#clientes`,
|
||||
url: `/`,
|
||||
})),
|
||||
...cotizaciones.map((ct) => ({
|
||||
tipo: 'oportunidad' as const,
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('fechas de Obra bloqueadas', () => {
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ESCRITURACION: no-admin no puede modificar una fecha ya establecida', async () => {
|
||||
it('OBRA: no-admin no puede modificar la fecha de entrega programada ya establecida', async () => {
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
|
||||
])
|
||||
@@ -201,11 +201,11 @@ describe('fechas de Obra bloqueadas', () => {
|
||||
guardarCampo(
|
||||
'SENDA',
|
||||
'U-101',
|
||||
'ESCRITURACION',
|
||||
'OBRA',
|
||||
'entrega_programada_fecha',
|
||||
'2026-10-15',
|
||||
'user-1',
|
||||
'escrituracion',
|
||||
'obra',
|
||||
),
|
||||
).rejects.toThrow('solo puede modificarlas un administrador')
|
||||
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
listar,
|
||||
obtener,
|
||||
contar,
|
||||
catalogos,
|
||||
exportar,
|
||||
importarDesdeExcel,
|
||||
} from './cliente-maestro.service.js'
|
||||
@@ -49,23 +50,31 @@ describe('listar', () => {
|
||||
await listar({ busqueda: 'Juan', pagina: 1, limite: 50 })
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: expect.arrayContaining([
|
||||
{ nombre: { contains: 'Juan' } },
|
||||
]),
|
||||
where: {
|
||||
AND: [
|
||||
expect.objectContaining({
|
||||
OR: expect.arrayContaining([{ nombre: { contains: 'Juan' } }]),
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('aplica filtro de desarrollo', async () => {
|
||||
it('aplica filtro de desarrollo (valor exacto o con sufijo " - ...")', async () => {
|
||||
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
|
||||
prismaMock.clienteMaestro.count.mockResolvedValue(0)
|
||||
|
||||
await listar({ desarrollo: 'SENDA', pagina: 1, limite: 50 })
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ desarrollo: 'SENDA' }),
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -77,10 +86,48 @@ describe('listar', () => {
|
||||
await listar({ estatus: 'VENDIDO', pagina: 1, limite: 50 })
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ estatus: 'VENDIDO' }),
|
||||
where: { AND: [{ estatus: 'VENDIDO' }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('combina búsqueda, desarrollo y estatus en la misma consulta', async () => {
|
||||
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
|
||||
prismaMock.clienteMaestro.count.mockResolvedValue(0)
|
||||
|
||||
await listar({ busqueda: 'Ana', desarrollo: 'SOLE', estatus: 'ENTREGA', pagina: 1, limite: 50 })
|
||||
const llamada = prismaMock.clienteMaestro.findMany.mock.calls[0]![0] as { where: { AND: unknown[] } }
|
||||
expect(llamada.where.AND).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('sin filtros no arma condiciones', async () => {
|
||||
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
|
||||
prismaMock.clienteMaestro.count.mockResolvedValue(0)
|
||||
|
||||
await listar({ pagina: 1, limite: 50 })
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: {} }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── catalogos ────────────────────────────────────────────────────────
|
||||
|
||||
describe('catalogos', () => {
|
||||
it('devuelve los desarrollos y estatus reales, sin vacíos ni nulos', async () => {
|
||||
prismaMock.clienteMaestro.findMany
|
||||
.mockResolvedValueOnce([{ desarrollo: 'MUTUO - EDIFICACION' }, { desarrollo: 'SOLE' }, { desarrollo: null }, { desarrollo: ' ' }])
|
||||
.mockResolvedValueOnce([{ estatus: 'ENTREGA' }, { estatus: 'VENDIDO' }, { estatus: null }])
|
||||
|
||||
const result = await catalogos()
|
||||
expect(result.desarrollos).toEqual(['MUTUO - EDIFICACION', 'SOLE'])
|
||||
expect(result.estatuses).toEqual(['ENTREGA', 'VENDIDO'])
|
||||
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith({
|
||||
distinct: ['desarrollo'],
|
||||
select: { desarrollo: true },
|
||||
orderBy: { desarrollo: 'asc' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── obtener ──────────────────────────────────────────────────────────
|
||||
@@ -113,7 +160,11 @@ describe('contar', () => {
|
||||
prismaMock.clienteMaestro.count.mockResolvedValue(5)
|
||||
expect(await contar({ desarrollo: 'SENDA' })).toBe(5)
|
||||
expect(prismaMock.clienteMaestro.count).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { desarrollo: 'SENDA' } }),
|
||||
{
|
||||
where: {
|
||||
AND: [{ OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }] }],
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,26 +44,37 @@ export type ClienteMaestroDTO = {
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function construirWhere(filtros: FiltrosClientesMaestro) {
|
||||
const where: Record<string, unknown> = {}
|
||||
const condiciones: Record<string, unknown>[] = []
|
||||
|
||||
if (filtros.desarrollo) {
|
||||
where.desarrollo = filtros.desarrollo
|
||||
const desarrollo = filtros.desarrollo?.trim()
|
||||
if (desarrollo) {
|
||||
// El mismo desarrollo convive con sufijos en la base ("ATELIER" y
|
||||
// "ATELIER - EDIFICACION"): se acepta el valor exacto o su prefijo, así el
|
||||
// filtro funciona tanto con el valor crudo como con el agrupado.
|
||||
condiciones.push({
|
||||
OR: [{ desarrollo }, { desarrollo: { startsWith: `${desarrollo} -` } }],
|
||||
})
|
||||
}
|
||||
if (filtros.estatus) {
|
||||
where.estatus = filtros.estatus
|
||||
|
||||
const estatus = filtros.estatus?.trim()
|
||||
if (estatus) {
|
||||
condiciones.push({ estatus })
|
||||
}
|
||||
|
||||
if (filtros.busqueda) {
|
||||
const term = filtros.busqueda.trim()
|
||||
where.OR = [
|
||||
condiciones.push({
|
||||
OR: [
|
||||
{ nombre: { contains: term } },
|
||||
{ apellidoPaterno: { contains: term } },
|
||||
{ apellidoMaterno: { contains: term } },
|
||||
{ telefono: { contains: term } },
|
||||
{ numCliente: { contains: term } },
|
||||
]
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return where
|
||||
return condiciones.length > 0 ? { AND: condiciones } : {}
|
||||
}
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────────
|
||||
@@ -102,6 +113,33 @@ export async function obtener(numCliente: string): Promise<ClienteMaestroDTO | n
|
||||
return prisma.clienteMaestro.findUnique({ where: { numCliente } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Catálogos reales de los filtros: los desarrollos y estatus que existen hoy en
|
||||
* la base (no una lista fija). El front arma los selectores con esto.
|
||||
*/
|
||||
export async function catalogos(): Promise<{ desarrollos: string[]; estatuses: string[] }> {
|
||||
const [desarrollos, estatuses] = await Promise.all([
|
||||
prisma.clienteMaestro.findMany({
|
||||
distinct: ['desarrollo'],
|
||||
select: { desarrollo: true },
|
||||
orderBy: { desarrollo: 'asc' },
|
||||
}),
|
||||
prisma.clienteMaestro.findMany({
|
||||
distinct: ['estatus'],
|
||||
select: { estatus: true },
|
||||
orderBy: { estatus: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const limpiar = (valores: Array<string | null>): string[] =>
|
||||
valores.filter((v): v is string => v !== null && v.trim() !== '')
|
||||
|
||||
return {
|
||||
desarrollos: limpiar(desarrollos.map((d) => d.desarrollo)),
|
||||
estatuses: limpiar(estatuses.map((e) => e.estatus)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta clientes con filtros.
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
generarCalendario,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
pagoAtrasado,
|
||||
proximoVencimiento,
|
||||
round2,
|
||||
saldoPlan,
|
||||
@@ -203,6 +204,23 @@ describe('saldo, avance y status del plan', () => {
|
||||
expect(statusPlan(plan)).toBe('CANCELADO')
|
||||
})
|
||||
|
||||
it('sin calendario capturado → SIN_PLAN, no LIQUIDADO', () => {
|
||||
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
|
||||
expect(statusPlan(sinPagos)).toBe('SIN_PLAN')
|
||||
// con pagos pero todos en cero (captura incompleta): tampoco es liquidado
|
||||
const enCeros: PlanPago = {
|
||||
...planDePrueba(),
|
||||
engancheTotal: 0,
|
||||
pagos: planDePrueba().pagos.map((p) => ({ ...p, monto: 0 })),
|
||||
}
|
||||
expect(statusPlan(enCeros)).toBe('SIN_PLAN')
|
||||
})
|
||||
|
||||
it('un plan SIN_PLAN no entra en la cobranza del día', () => {
|
||||
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
|
||||
expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('alCorriente es falso cuando hay pagos vencidos', () => {
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false)
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
|
||||
@@ -233,10 +251,13 @@ describe('proximoVencimiento y diasAtraso', () => {
|
||||
expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0)
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido no cuenta como atraso', () => {
|
||||
it('un pago PARCIAL vencido SÍ cuenta como atraso (corrección: antes se ignoraba)', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
|
||||
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
|
||||
// pago 1 vence 10-ene y sigue sin cubrir (abono de 100) → 22 días al 1-feb
|
||||
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(22)
|
||||
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(false)
|
||||
expect(pagoAtrasado(plan.pagos[0]!, new Date('2026-02-01T00:00:00'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto'
|
||||
*/
|
||||
|
||||
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
|
||||
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
|
||||
/** SIN_PLAN = no hay calendario capturado (no debe confundirse con LIQUIDADO) */
|
||||
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' | 'SIN_PLAN'
|
||||
|
||||
export interface AplicacionPago {
|
||||
id: string
|
||||
@@ -137,7 +138,8 @@ export function generarCalendario(
|
||||
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
|
||||
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
|
||||
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
|
||||
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
|
||||
* PARCIAL (tiene abono aplicado); el atraso se señala con `pagoAtrasado`,
|
||||
* `diasAtraso` y `alCorriente`, que sí lo cuentan como vencido.
|
||||
*/
|
||||
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
|
||||
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
|
||||
@@ -184,10 +186,10 @@ export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date(
|
||||
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
|
||||
}
|
||||
|
||||
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
|
||||
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin cubrir) */
|
||||
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const vencidos = plan.pagos
|
||||
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
.filter((p) => pagoAtrasado(p, hoy))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
@@ -197,15 +199,39 @@ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
|
||||
}
|
||||
|
||||
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
|
||||
/**
|
||||
* ¿El pago está atrasado? Un pago sin cubrir cuya fecha de vencimiento ya
|
||||
* pasó: incluye el PARCIAL vencido (tiene abono pero no cubre el monto), que
|
||||
* es un atraso real aunque `statusPago` lo siga etiquetando PARCIAL.
|
||||
*/
|
||||
export function pagoAtrasado(pago: Pago, hoy: Date = new Date()): boolean {
|
||||
if (estaCubierto(pago)) return false
|
||||
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Status derivado del plan:
|
||||
* - CANCELADO: solo manual
|
||||
* - SIN_PLAN: no hay calendario capturado (0 pagos o montos en 0). Distinto de
|
||||
* LIQUIDADO: el adeudo real es desconocido, no saldado; queda fuera de la
|
||||
* cobranza del día hasta que se capture el plan.
|
||||
* - LIQUIDADO: saldo == 0
|
||||
* - ACTIVO: tiene saldo pendiente
|
||||
*/
|
||||
export function statusPlan(plan: PlanPago): StatusPlan {
|
||||
if (plan.status === 'CANCELADO') return 'CANCELADO'
|
||||
if (!tieneCalendario(plan)) return 'SIN_PLAN'
|
||||
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
|
||||
}
|
||||
|
||||
/** Al corriente = sin pagos VENCIDOS */
|
||||
/** ¿El plan tiene calendario capturado? (al menos un pago con monto > 0) */
|
||||
export function tieneCalendario(plan: PlanPago): boolean {
|
||||
return plan.pagos.some((p) => p.monto > EPS)
|
||||
}
|
||||
|
||||
/** Al corriente = sin pagos atrasados (un PARCIAL vencido también es atraso) */
|
||||
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
|
||||
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
return !plan.pagos.some((p) => pagoAtrasado(p, hoy))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,10 +262,11 @@ export interface CobranzaItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
|
||||
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
||||
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
|
||||
* atraso, el vencimiento más próximo primero.
|
||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS
|
||||
* (los SIN_PLAN quedan fuera: sin calendario capturado no hay nada que
|
||||
* cobrar): pagos vencidos (incluye PARCIAL vencido) más los que vencen en los
|
||||
* próximos `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a
|
||||
* igual atraso, el vencimiento más próximo primero.
|
||||
*/
|
||||
export function cobranzaDelDia(
|
||||
planes: PlanPago[],
|
||||
|
||||
@@ -48,6 +48,56 @@ export function claveTelefono(telefono: string): string {
|
||||
return digitos.length >= 10 ? digitos.slice(-10) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Clave de nombre para deduplicar/relacionar contactos que no traen teléfono ni
|
||||
* email (las altas fallidas dejan un registro hueco con el mismo nombre).
|
||||
* Sin acentos, sin mayúsculas y con los espacios colapsados.
|
||||
*/
|
||||
export function claveNombre(nombre: string): string {
|
||||
return nombre
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplana el JSON de perfil a un mapa campo→valor no vacío: sirve tanto para el
|
||||
* `payloadJson` del alta de cliente (anidado por secciones) como para el
|
||||
* `datosJson` del perfilamiento de una captura de afluencia.
|
||||
*
|
||||
* Mismo criterio que el reporte de clientes: gana el primer valor no vacío y se
|
||||
* ignoran las secciones que repiten los mismos campos vacíos (coacreditado) o
|
||||
* que no son de perfil. Así una sección posterior no pisa un dato real.
|
||||
*/
|
||||
export function aplanarPerfil(json: string | null | undefined): Record<string, string> {
|
||||
if (!json) return {}
|
||||
try {
|
||||
const datos = JSON.parse(json) as Record<string, unknown>
|
||||
const plano: Record<string, string> = {}
|
||||
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
|
||||
const recorrer = (obj: Record<string, unknown>, raiz: boolean): void => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v === null || v === undefined) continue
|
||||
if (typeof v === 'object' && !Array.isArray(v)) {
|
||||
if (raiz && SECCIONES_IGNORADAS.has(k)) continue
|
||||
recorrer(v as Record<string, unknown>, false)
|
||||
continue
|
||||
}
|
||||
if (typeof v === 'object') continue
|
||||
const texto = String(v)
|
||||
if (texto.trim() === '') continue
|
||||
if (plano[k] === undefined) plano[k] = texto
|
||||
}
|
||||
}
|
||||
recorrer(datos, true)
|
||||
return plano
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Perfil Completo del Contacto (modo local) ─────────────────────────
|
||||
|
||||
export interface PerfilCompletoContacto {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
cliente: { findMany: vi.fn() },
|
||||
leadCaptura: { findMany: vi.fn() },
|
||||
usuario: { findMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
@@ -13,12 +14,42 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', username: 'alfonso castillo' }])
|
||||
})
|
||||
|
||||
describe('exportarCSV', () => {
|
||||
it('genera CSV con encabezados cuando no hay contactos', async () => {
|
||||
const csv = await exportarCSV()
|
||||
expect(csv).toContain('Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad')
|
||||
expect(csv).toContain('Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad,Asesor')
|
||||
})
|
||||
|
||||
it('incluye el nombre del asesor que dio de alta el contacto', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{
|
||||
nombreCompleto: 'Juan Pérez',
|
||||
celular: '4771234567',
|
||||
email: null,
|
||||
proyecto: 'SENDA',
|
||||
createdAt: new Date('2026-09-01'),
|
||||
capturadoPorId: 'u-1',
|
||||
},
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{
|
||||
nombreLead: 'Ana López',
|
||||
telefono: '6649998877',
|
||||
email: null,
|
||||
desarrollo: 'DIAGONAL',
|
||||
etapa: 'NUEVO',
|
||||
updatedAt: new Date('2026-09-05'),
|
||||
usuarioId: 'u-1',
|
||||
},
|
||||
])
|
||||
|
||||
const csv = await exportarCSV()
|
||||
const lineas = csv.split('\n')
|
||||
expect(lineas[1]).toContain('alfonso castillo')
|
||||
expect(lineas[2]).toContain('alfonso castillo')
|
||||
})
|
||||
|
||||
it('incluye clientes y capturas en el CSV', async () => {
|
||||
|
||||
@@ -118,8 +118,102 @@ describe('listarContactosLocal', () => {
|
||||
expect(lista[0]?.id).toBe('c1')
|
||||
})
|
||||
|
||||
it('deduplica por nombre a los registros sin teléfono ni email (gana la ficha con más datos)', async () => {
|
||||
// Alta fallida: Cliente hueco (payload vacío, sin teléfono ni email) con el
|
||||
// mismo nombre que su ficha real
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({
|
||||
id: 'hueco',
|
||||
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
|
||||
payloadJson: '{}',
|
||||
createdAt: new Date('2026-09-11T10:00:00'),
|
||||
}),
|
||||
clienteFake({
|
||||
id: 'completo',
|
||||
nombreCompleto: 'Luis Enrique Salgado Zavaleta',
|
||||
celular: '+52 5586880444',
|
||||
email: 'luisenriquesalgadozavaleta@gmail.com',
|
||||
curp: 'SAZL990504HDFLVS06',
|
||||
rfc: 'SAZL990504KY0',
|
||||
proyecto: 'SOLANA',
|
||||
unidadCodigo: '9',
|
||||
payloadJson: '{"datosGenerales":{"estadoCivil":"SOLTERO"}}',
|
||||
}),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
|
||||
expect(lista).toHaveLength(1)
|
||||
expect(lista[0]).toMatchObject({
|
||||
id: 'completo',
|
||||
nombre: 'Luis Enrique Salgado Zavaleta',
|
||||
telefono: '+52 5586880444',
|
||||
email: 'luisenriquesalgadozavaleta@gmail.com',
|
||||
estado: 'Cliente',
|
||||
})
|
||||
})
|
||||
|
||||
it('fusiona el registro hueco aunque llegue después de la ficha completa', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({
|
||||
id: 'completo',
|
||||
nombreCompleto: 'GRACIELA BAUTISTA VELASCO',
|
||||
celular: '+52 6641159092',
|
||||
email: 'gbautista1304@gmail.com',
|
||||
}),
|
||||
clienteFake({ id: 'hueco', nombreCompleto: 'Graciela Bautista Velasco', payloadJson: '{}' }),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
expect(lista).toHaveLength(1)
|
||||
expect(lista[0]?.id).toBe('completo')
|
||||
})
|
||||
|
||||
it('no fusiona a dos personas distintas con el mismo nombre si tienen teléfonos distintos', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567' }),
|
||||
clienteFake({ id: 'c2', nombreCompleto: 'JUAN PÉREZ', celular: '6649998877' }),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
expect(lista.map((c) => c.id).sort()).toEqual(['c1', 'c2'])
|
||||
})
|
||||
|
||||
it('un Cliente hueco se fusiona con la captura del mismo nombre (gana la que trae más datos)', async () => {
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({
|
||||
id: 'hueco',
|
||||
nombreCompleto: 'Guadalupe',
|
||||
curp: '',
|
||||
rfc: '',
|
||||
payloadJson: '{}',
|
||||
createdAt: new Date('2026-09-15T10:00:00'),
|
||||
}),
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({
|
||||
leadId: 'loc_g',
|
||||
nombreLead: 'Guadalupe',
|
||||
telefono: '+52 664 222 6090',
|
||||
email: 'lupisintj1@hotmail.com',
|
||||
etapa: 'Contactado',
|
||||
}),
|
||||
])
|
||||
|
||||
const lista = (await listarContactosLocal()).contactos
|
||||
|
||||
expect(lista).toHaveLength(1)
|
||||
expect(lista[0]).toMatchObject({
|
||||
id: 'loc_g',
|
||||
telefono: '+52 664 222 6090',
|
||||
email: 'lupisintj1@hotmail.com',
|
||||
estado: 'Contactado',
|
||||
})
|
||||
})
|
||||
|
||||
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
|
||||
// 101 clientes: la página 1 llena 100 y detecta que hay más
|
||||
// 101 clientes: la paginación se aplica DESPUÉS de fusionar fuentes, así
|
||||
// que el servicio pide el universo completo y corta en memoria
|
||||
prismaMock.cliente.findMany.mockResolvedValue(
|
||||
Array.from({ length: 101 }, (_, i) =>
|
||||
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
|
||||
@@ -132,13 +226,12 @@ describe('listarContactosLocal', () => {
|
||||
expect(pagina1.contactos).toHaveLength(100)
|
||||
expect(pagina1.hayMas).toBe(true)
|
||||
|
||||
// La página 2 recibe el siguiente bloque (skip=100, take=101)
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
clienteFake({ id: 'c101', celular: '4770000100' }),
|
||||
])
|
||||
// La página 2 trae la fila más vieja (orden desc por fecha: la 101 va
|
||||
// primero en la página 1)
|
||||
const pagina2 = await listarContactosLocal(undefined, 2)
|
||||
expect(pagina2.pagina).toBe(2)
|
||||
expect(pagina2.contactos).toHaveLength(1)
|
||||
expect(pagina2.contactos[0]?.id).toBe('c1')
|
||||
expect(pagina2.hayMas).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -233,4 +326,110 @@ describe('detalleContactoLocal', () => {
|
||||
expect(detalle.relaciones.oportunidades).toHaveLength(1)
|
||||
expect(detalle.historial.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('completa el perfil con el payloadJson del alta (estado civil, nacimiento, actividad, dirección)', async () => {
|
||||
prismaMock.cliente.findFirst.mockResolvedValue(
|
||||
clienteFake({
|
||||
id: 'c1',
|
||||
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
|
||||
celular: '5586880444',
|
||||
email: 'luis@correo.mx',
|
||||
curp: '',
|
||||
rfc: '',
|
||||
payloadJson: JSON.stringify({
|
||||
datosGenerales: {
|
||||
estadoCivil: 'SOLTERO',
|
||||
fechaNacimiento: '1999-05-04',
|
||||
actividadOcupacion: 'PILOTO AVIADOR',
|
||||
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
|
||||
},
|
||||
empleo: { compania: 'FRASA', direccion: 'AV COLINA DE SAN PABLO' },
|
||||
// El co-acreditado repite los campos vacíos: no debe pisar los reales
|
||||
coacreditado: { tieneCoacreditado: false, generales: { estadoCivil: '', fechaNacimiento: '', actividadOcupacion: '', direccion: '' } },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const detalle = await detalleContactoLocal('c1')
|
||||
expect(detalle?.contacto).toMatchObject({
|
||||
estadoCivil: 'SOLTERO',
|
||||
fechaNacimiento: '1999-05-04',
|
||||
actividadEconomica: 'PILOTO AVIADOR',
|
||||
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
|
||||
})
|
||||
})
|
||||
|
||||
it('toma el perfilamiento del datosJson de la captura cuando no hay Cliente', async () => {
|
||||
const captura = capturaFake({
|
||||
leadId: 'loc_1',
|
||||
nombreLead: 'RAMON MORALES',
|
||||
telefono: '6643535353',
|
||||
email: 'ramon.morales@gmail.com',
|
||||
datosJson: JSON.stringify({
|
||||
edad: 41,
|
||||
estadoCivil: 'Casado(a)',
|
||||
ocupacion: 'empleado',
|
||||
rangoSalarial: '50000',
|
||||
}),
|
||||
})
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(captura)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([captura])
|
||||
|
||||
const detalle = await detalleContactoLocal('loc_1')
|
||||
expect(detalle?.contacto).toMatchObject({
|
||||
nombre: 'RAMON MORALES',
|
||||
estadoCivil: 'Casado(a)',
|
||||
actividadEconomica: 'empleado',
|
||||
})
|
||||
// No se inventa fecha de nacimiento a partir de la edad
|
||||
expect(detalle?.contacto['fechaNacimiento']).toBe('')
|
||||
expect(detalle?.contacto['direccion']).toBe('')
|
||||
})
|
||||
|
||||
it('incluye los documentos del registro hueco de la misma persona (no se pierden al fusionar)', async () => {
|
||||
prismaMock.cliente.findFirst.mockResolvedValue(
|
||||
clienteFake({
|
||||
id: 'c1',
|
||||
nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA',
|
||||
celular: '6643998451',
|
||||
email: 'daleman@frasa.com.mx',
|
||||
}),
|
||||
)
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'hueco', nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', celular: null, email: null },
|
||||
{ id: 'otro', nombreCompleto: 'OTRA PERSONA', celular: null, email: null },
|
||||
])
|
||||
prismaMock.documento.findMany.mockResolvedValue([
|
||||
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
|
||||
])
|
||||
|
||||
const detalle = await detalleContactoLocal('c1')
|
||||
|
||||
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clienteId: { in: ['c1', 'hueco'] } } }),
|
||||
)
|
||||
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
|
||||
})
|
||||
|
||||
it('si la ficha visible es la captura, también se muestran los documentos de su Cliente hueco', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(
|
||||
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
|
||||
)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([
|
||||
{ id: 'hueco', nombreCompleto: 'Guadalupe', celular: null, email: null },
|
||||
])
|
||||
prismaMock.documento.findMany.mockResolvedValue([
|
||||
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
|
||||
])
|
||||
|
||||
const detalle = await detalleContactoLocal('loc_g')
|
||||
|
||||
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clienteId: { in: ['hueco'] } } }),
|
||||
)
|
||||
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
|
||||
import {
|
||||
aplanarPerfil,
|
||||
claveNombre,
|
||||
claveTelefono,
|
||||
type DetalleContacto,
|
||||
type EventoHistorialContacto,
|
||||
type RelacionCrm,
|
||||
} from './contactos-detalle.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
@@ -20,10 +27,10 @@ export interface ContactoListaLocal {
|
||||
|
||||
/**
|
||||
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
|
||||
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
|
||||
* ficha completa), más reciente primero. Paginada de 100 en 100 (el front
|
||||
* pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual:
|
||||
* clientes capturados por él + leads asignados a él.
|
||||
* OPORTUNIDADES/APARTADO, deduplicados (gana el Cliente: tiene la ficha
|
||||
* completa), más reciente primero. Paginada de 100 en 100 (el front pagina con
|
||||
* `?pagina=N` y muestra el límite). Filtra por usuario actual: clientes
|
||||
* capturados por él + leads asignados a él.
|
||||
*/
|
||||
export const CONTACTOS_POR_PAGINA = 100
|
||||
|
||||
@@ -50,16 +57,47 @@ export async function esContactoDelUsuario(contactoId: string, usuarioId: string
|
||||
return cliente !== null || captura !== null
|
||||
}
|
||||
|
||||
/** Candidato de la lista antes de deduplicar */
|
||||
interface Candidato {
|
||||
contacto: ContactoListaLocal
|
||||
/** La ficha de Cliente (alta completa) gana a la captura al fusionar */
|
||||
esCliente: boolean
|
||||
/** Cuántos datos trae el registro: desempata la fusión de duplicados */
|
||||
peso: number
|
||||
}
|
||||
|
||||
/** Cuenta los valores no vacíos de la ficha (para saber cuál está más completa) */
|
||||
function pesoDeFicha(valores: readonly (string | null | undefined)[], conPerfil: boolean): number {
|
||||
const llenos = valores.filter((v) => v !== null && v !== undefined && v.trim() !== '').length
|
||||
return llenos + (conPerfil ? 1 : 0)
|
||||
}
|
||||
|
||||
/** true si el JSON trae algo más que un objeto/contenedor vacío */
|
||||
function tieneDatos(json: string | null | undefined): boolean {
|
||||
if (!json) return false
|
||||
return Object.keys(aplanarPerfil(json)).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de contactos locales deduplicada en dos pasos:
|
||||
* 1. por teléfono (últimos 10 dígitos), como siempre;
|
||||
* 2. por nombre para los registros SIN teléfono ni email — las altas fallidas
|
||||
* dejan un Cliente hueco con el mismo nombre que su ficha real, y antes se
|
||||
* repetía siempre porque la clave de teléfono era ''.
|
||||
* Al fusionar se conserva el registro con más datos y se completan sus vacíos
|
||||
* con los del otro (por teléfono gana el Cliente: es la ficha del alta), sin
|
||||
* perder documentos ni relaciones: el detalle une también las dos fichas.
|
||||
*/
|
||||
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
|
||||
const porPagina = CONTACTOS_POR_PAGINA
|
||||
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
||||
const skip = (paginaValida - 1) * porPagina
|
||||
// +1 por fuente para detectar si queda más (hayMas) sin una consulta extra
|
||||
// La paginación se aplica DESPUÉS de fusionar las dos fuentes: antes se hacía
|
||||
// skip/take por fuente y el dedupe posterior perdía contactos entre páginas
|
||||
// y daba hayMas=false antes de tiempo.
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: usuarioId ? { capturadoPorId: usuarioId } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: porPagina + 1,
|
||||
})
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: {
|
||||
@@ -67,26 +105,78 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
...(usuarioId ? { usuarioId } : {}),
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip,
|
||||
take: porPagina + 1,
|
||||
})
|
||||
|
||||
const vistos = new Set<string>()
|
||||
const lista: ContactoListaLocal[] = []
|
||||
const agregar = (entrada: ContactoListaLocal): void => {
|
||||
// Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
|
||||
// teléfono se duplicaban siempre)
|
||||
const tel = claveTelefono(entrada.telefono)
|
||||
const clave = tel !== ''
|
||||
? tel
|
||||
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}`
|
||||
if (vistos.has(clave)) return
|
||||
vistos.add(clave)
|
||||
lista.push(entrada)
|
||||
const lista: Candidato[] = []
|
||||
const porTelefono = new Map<string, number>()
|
||||
const porNombre = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Deja en la posición `i` la ficha ganadora, completando sus vacíos con los
|
||||
* del otro registro. Por teléfono gana el Cliente (mismo teléfono = misma
|
||||
* persona, y su alta es la ficha completa); por nombre gana el que tiene más
|
||||
* datos, que es el criterio de la fusión de duplicados huecos.
|
||||
*/
|
||||
const fusionar = (i: number, nueva: Candidato, tel: string, nombre: string, porNombreClave: boolean): void => {
|
||||
const previa = lista[i]!
|
||||
const ganaNueva = porNombreClave
|
||||
? nueva.peso !== previa.peso
|
||||
? nueva.peso > previa.peso
|
||||
: nueva.esCliente
|
||||
: nueva.esCliente !== previa.esCliente
|
||||
? nueva.esCliente
|
||||
: nueva.peso > previa.peso
|
||||
const [ganadora, perdedora] = ganaNueva ? [nueva, previa] : [previa, nueva]
|
||||
lista[i] = {
|
||||
esCliente: ganadora.esCliente,
|
||||
peso: ganadora.peso,
|
||||
contacto: {
|
||||
...ganadora.contacto,
|
||||
nombre: ganadora.contacto.nombre.trim() !== '' ? ganadora.contacto.nombre : perdedora.contacto.nombre,
|
||||
telefono: ganadora.contacto.telefono !== '' ? ganadora.contacto.telefono : perdedora.contacto.telefono,
|
||||
email: ganadora.contacto.email !== '' ? ganadora.contacto.email : perdedora.contacto.email,
|
||||
estado: ganadora.contacto.estado !== '' ? ganadora.contacto.estado : perdedora.contacto.estado,
|
||||
},
|
||||
}
|
||||
if (tel !== '') porTelefono.set(tel, i)
|
||||
porNombre.set(nombre, i)
|
||||
}
|
||||
|
||||
/** ¿El registro ya agregado en esa posición no tiene teléfono ni email? */
|
||||
const esHueco = (i: number): boolean => {
|
||||
const c = lista[i]!.contacto
|
||||
return c.telefono.trim() === '' && c.email.trim() === ''
|
||||
}
|
||||
|
||||
const agregar = (nueva: Candidato): void => {
|
||||
const tel = claveTelefono(nueva.contacto.telefono)
|
||||
const nombre = claveNombre(nueva.contacto.nombre)
|
||||
const email = nueva.contacto.email.trim()
|
||||
const iNombre = porNombre.get(nombre)
|
||||
// Sin teléfono ni email el nombre es lo único que identifica a la persona.
|
||||
// Con teléfono o email sólo se une a un registro hueco del mismo nombre (la
|
||||
// misma persona antes de completar su ficha), nunca a otro teléfono.
|
||||
const iSinContacto =
|
||||
tel === '' && email === ''
|
||||
? iNombre
|
||||
: iNombre !== undefined && esHueco(iNombre)
|
||||
? iNombre
|
||||
: undefined
|
||||
const iTelefono = tel !== '' ? porTelefono.get(tel) : undefined
|
||||
const i = iTelefono ?? iSinContacto
|
||||
if (i !== undefined) {
|
||||
fusionar(i, nueva, tel, nombre, iTelefono === undefined)
|
||||
return
|
||||
}
|
||||
lista.push(nueva)
|
||||
const indice = lista.length - 1
|
||||
if (tel !== '') porTelefono.set(tel, indice)
|
||||
porNombre.set(nombre, indice)
|
||||
}
|
||||
|
||||
for (const c of clientes) {
|
||||
agregar({
|
||||
contacto: {
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
@@ -94,10 +184,14 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
|
||||
updatedAt: c.createdAt.toISOString(),
|
||||
fuente: 'local',
|
||||
},
|
||||
esCliente: true,
|
||||
peso: pesoDeFicha([c.celular, c.email, c.curp, c.rfc, c.proyecto, c.unidadCodigo], tieneDatos(c.payloadJson)),
|
||||
})
|
||||
}
|
||||
for (const c of capturas) {
|
||||
agregar({
|
||||
contacto: {
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
@@ -105,23 +199,48 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
estado: c.etapa,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
fuente: 'local',
|
||||
},
|
||||
esCliente: false,
|
||||
peso: pesoDeFicha([c.telefono, c.email, c.desarrollo, c.modelo], tieneDatos(c.datosJson)),
|
||||
})
|
||||
}
|
||||
const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
const hayMas = ordenada.length > porPagina
|
||||
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
return {
|
||||
contactos: ordenada.slice(0, porPagina),
|
||||
contactos: ordenada.slice(skip, skip + porPagina),
|
||||
pagina: paginaValida,
|
||||
porPagina,
|
||||
hayMas,
|
||||
hayMas: ordenada.length > skip + porPagina,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clientes huecos con el mismo nombre que el contacto: un alta fallida deja un
|
||||
* registro sin teléfono ni email (payload vacío) que la lista fusiona con su
|
||||
* ficha real. Sólo se miran los huecos — nombre + un teléfono/email propios no
|
||||
* identifican a la misma persona. `excluirId` omite el propio contacto.
|
||||
*/
|
||||
async function huecosConMismoNombre(nombre: string, excluirId: string | null): Promise<string[]> {
|
||||
const huecos = await prisma.cliente.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
...(excluirId !== null ? [{ id: { not: excluirId } }] : []),
|
||||
{ OR: [{ celular: null }, { celular: '' }] },
|
||||
{ OR: [{ email: null }, { email: '' }] },
|
||||
],
|
||||
},
|
||||
select: { id: true, nombreCompleto: true },
|
||||
})
|
||||
const clave = claveNombre(nombre)
|
||||
return huecos.filter((c) => claveNombre(c.nombreCompleto) === clave).map((c) => c.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
|
||||
* heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se
|
||||
* arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el
|
||||
* historial se deriva de capturas + alta + documentos + cotizaciones locales.
|
||||
* El perfil (estado civil, fecha de nacimiento, actividad económica, dirección)
|
||||
* se completa con el payloadJson del alta y el datosJson de las capturas.
|
||||
* null si no existe en ninguna tabla.
|
||||
*/
|
||||
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
|
||||
@@ -160,8 +279,22 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
const documentos = cliente
|
||||
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
|
||||
// Duplicados huecos de la misma persona (altas fallidas: mismo nombre, sin
|
||||
// teléfono ni email). La lista los fusiona en una sola ficha, así que el
|
||||
// detalle muestra también sus documentos para no perderlos, tanto si el
|
||||
// contacto visible es el Cliente como si es la captura.
|
||||
const huecos =
|
||||
cliente !== null
|
||||
? await huecosConMismoNombre(cliente.nombreCompleto, cliente.id)
|
||||
: capturaBase !== null
|
||||
? await huecosConMismoNombre(capturaBase.nombreLead, null)
|
||||
: []
|
||||
const idsConDocumentos = [...(cliente !== null ? [cliente.id] : []), ...huecos]
|
||||
const documentos = idsConDocumentos.length
|
||||
? await prisma.documento.findMany({
|
||||
where: { clienteId: { in: idsConDocumentos } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
|
||||
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
|
||||
@@ -245,6 +378,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
})),
|
||||
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||||
|
||||
// Perfil del alta (payloadJson del Cliente) y de las capturas de afluencia
|
||||
// (datosJson): de ahí salen estado civil, fecha de nacimiento, actividad
|
||||
// económica y dirección, que no son columnas de la BD. Gana el primer valor
|
||||
// no vacío, así una fuente posterior no pisa un dato real con un vacío.
|
||||
const perfiles = [
|
||||
aplanarPerfil(cliente?.payloadJson),
|
||||
...[capturaBase, ...relacionadas].map((c) => aplanarPerfil(c?.datosJson)),
|
||||
]
|
||||
const dePerfil = (...claves: string[]): string => {
|
||||
for (const clave of claves) {
|
||||
for (const perfil of perfiles) {
|
||||
const valor = (perfil[clave] ?? '').trim()
|
||||
if (valor !== '') return valor
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const contacto: Record<string, string> =
|
||||
cliente !== null
|
||||
? {
|
||||
@@ -252,14 +403,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
telefono: cliente.celular ?? '',
|
||||
email: cliente.email ?? '',
|
||||
estado: 'Cliente',
|
||||
curp: cliente.curp,
|
||||
rfc: cliente.rfc,
|
||||
curp: cliente.curp || dePerfil('curp'),
|
||||
rfc: cliente.rfc || dePerfil('rfc'),
|
||||
estadoCivil: dePerfil('estadoCivil'),
|
||||
fechaNacimiento: dePerfil('fechaNacimiento'),
|
||||
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
|
||||
direccion: dePerfil('direccion'),
|
||||
}
|
||||
: {
|
||||
nombre: capturaBase!.nombreLead,
|
||||
telefono: capturaBase!.telefono ?? '',
|
||||
email: capturaBase!.email ?? '',
|
||||
estado: capturaBase!.etapa,
|
||||
curp: dePerfil('curp'),
|
||||
rfc: dePerfil('rfc'),
|
||||
estadoCivil: dePerfil('estadoCivil'),
|
||||
fechaNacimiento: dePerfil('fechaNacimiento'),
|
||||
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
|
||||
direccion: dePerfil('direccion'),
|
||||
}
|
||||
|
||||
return { contacto, propiedad, relaciones, historial }
|
||||
@@ -286,6 +447,9 @@ export async function exportarCSV(
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
// Nombre del asesor que dio de alta cada contacto (para la columna Asesor)
|
||||
const usuarios = await prisma.usuario.findMany({ select: { id: true, username: true } })
|
||||
const nombreAsesor = new Map(usuarios.map((u) => [u.id, u.username]))
|
||||
|
||||
interface FilaCSV {
|
||||
Nombre: string
|
||||
@@ -294,15 +458,21 @@ export async function exportarCSV(
|
||||
Desarrollo: string
|
||||
Estatus: string
|
||||
'Última actividad': string
|
||||
Asesor: string
|
||||
}
|
||||
|
||||
const filas: FilaCSV[] = []
|
||||
// Dedupe en dos pasos (igual que el listado): por teléfono y, cuando no hay
|
||||
// teléfono, por nombre normalizado — antes un contacto sin teléfono salía
|
||||
// duplicado en el CSV.
|
||||
const vistos = new Set<string>()
|
||||
const claveNombre = (nombre: string): string => nombre.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
|
||||
for (const c of clientes) {
|
||||
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
const claveTelefono = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(c.nombreCompleto)}`
|
||||
if (vistos.has(clave)) continue
|
||||
vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: c.nombreCompleto,
|
||||
Teléfono: c.celular ?? '',
|
||||
@@ -310,13 +480,15 @@ export async function exportarCSV(
|
||||
Desarrollo: c.proyecto,
|
||||
Estatus: 'Cliente',
|
||||
'Última actividad': c.createdAt.toISOString().slice(0, 10),
|
||||
Asesor: c.capturadoPorId ? (nombreAsesor.get(c.capturadoPorId) ?? '') : '',
|
||||
})
|
||||
}
|
||||
|
||||
for (const cap of capturas) {
|
||||
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
if (clave !== '' && vistos.has(clave)) continue
|
||||
if (clave !== '') vistos.add(clave)
|
||||
const claveTelefono = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(cap.nombreLead)}`
|
||||
if (vistos.has(clave)) continue
|
||||
vistos.add(clave)
|
||||
filas.push({
|
||||
Nombre: cap.nombreLead,
|
||||
Teléfono: cap.telefono ?? '',
|
||||
@@ -324,6 +496,7 @@ export async function exportarCSV(
|
||||
Desarrollo: cap.desarrollo ?? '',
|
||||
Estatus: cap.etapa,
|
||||
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
|
||||
Asesor: cap.usuarioId ? (nombreAsesor.get(cap.usuarioId) ?? '') : '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,10 +512,10 @@ export async function exportarCSV(
|
||||
}
|
||||
|
||||
// Generar CSV
|
||||
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad'
|
||||
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad,Asesor'
|
||||
const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
|
||||
const lineas = filtradas.map((f) =>
|
||||
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad']].map(escape).join(',')
|
||||
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad'], f.Asesor].map(escape).join(',')
|
||||
)
|
||||
|
||||
return [encabezado, ...lineas].join('\n')
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0, N = 4)', () => {
|
||||
})
|
||||
|
||||
describe('SENDA — unidad sin fecha de entrega', () => {
|
||||
it('N = null, mensualidades "—" y total pagado null', () => {
|
||||
it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
|
||||
const sinFecha = unidad({
|
||||
codigo: 'SENDA-411-25-A101',
|
||||
desarrollo: 'SENDA',
|
||||
@@ -131,10 +131,12 @@ describe('SENDA — unidad sin fecha de entrega', () => {
|
||||
precioConAcabados: 1948000,
|
||||
})
|
||||
const p = calcularPropuesta(sinFecha, 'BANCARIO', false, APARTADO_DEFAULT)
|
||||
expect(p.numMensualidades).toBeNull()
|
||||
expect(p.mensualidad).toBeNull()
|
||||
expect(p.notaMensualidades).toBe('sin fecha de entrega')
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeNull()
|
||||
// Antes salía numMensualidades/mensualidad null y "— (sin fecha de entrega)"
|
||||
expect(p.numMensualidades).toBe(6) // nDefault de SENDA
|
||||
expect(p.notaMensualidades).toBeUndefined()
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(97400, 2)
|
||||
expect(p.mensualidad).toBeCloseTo(97400 / 6, 4)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
|
||||
expect(p.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
|
||||
})
|
||||
@@ -149,12 +151,13 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
|
||||
mesesParaFirma: 1,
|
||||
})
|
||||
|
||||
it('INFONAVIT con enganche 0% (guarda: no resta el apartado)', () => {
|
||||
it('INFONAVIT con enganche 0%: el apartado se descuenta (no se cobra encima del precio)', () => {
|
||||
const p = calcularPropuesta(casa6, 'INFONAVIT', false, APARTADO_DEFAULT)
|
||||
expect(p.enganche).toBe(0)
|
||||
expect(p.numMensualidades).toBe(1)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
|
||||
})
|
||||
|
||||
it('BANCARIO 5% sobre final', () => {
|
||||
@@ -164,3 +167,82 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
|
||||
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cuadre del total: apartado y enganche personalizado', () => {
|
||||
const TIPOS = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO'] as const
|
||||
const CASOS = [
|
||||
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
|
||||
{ desarrollo: 'ATELIER', precio: 3_997_500 },
|
||||
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
|
||||
{ desarrollo: 'SENDA', precio: 2_451_750 },
|
||||
{ desarrollo: 'SOLANA', precio: 2_560_000 },
|
||||
]
|
||||
const unidadDe = (desarrollo: string, precio: number) =>
|
||||
unidad({
|
||||
codigo: `${desarrollo}-1`,
|
||||
desarrollo,
|
||||
precioSinAcabados: precio,
|
||||
precioConAcabados: precio,
|
||||
})
|
||||
|
||||
it('las 4 propuestas de cada desarrollo cuadran con su precio final', () => {
|
||||
for (const caso of CASOS) {
|
||||
for (const tipo of TIPOS) {
|
||||
const p = calcularPropuesta(unidadDe(caso.desarrollo, caso.precio), tipo, false, APARTADO_DEFAULT)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
|
||||
for (const caso of CASOS) {
|
||||
for (const tipo of TIPOS) {
|
||||
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
|
||||
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
|
||||
const p = calcularPropuesta(
|
||||
unidadDe(caso.desarrollo, caso.precio),
|
||||
tipo,
|
||||
false,
|
||||
apartado,
|
||||
undefined,
|
||||
enganchePct,
|
||||
)
|
||||
expect(p.enganche).toBeGreaterThanOrEqual(0)
|
||||
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
|
||||
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
|
||||
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
|
||||
const u101 = unidad({
|
||||
codigo: 'U-101',
|
||||
desarrollo: 'DIAGONAL',
|
||||
precioSinAcabados: 4_415_000,
|
||||
precioConAcabados: 4_579_000,
|
||||
})
|
||||
const p = calcularPropuesta(u101, 'BANCARIO', false, APARTADO_DEFAULT, undefined, 0.2)
|
||||
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
|
||||
expect(p.mensualidadesTotal).toBe(0)
|
||||
expect(p.pagoFinal).toBeCloseTo(4_415_000 - APARTADO_DEFAULT - p.enganche, 2)
|
||||
})
|
||||
|
||||
it('apartado mayor al enganche: el enganche queda en 0 (nunca negativo) y el total cuadra', () => {
|
||||
const u101 = unidad({
|
||||
codigo: 'U-101',
|
||||
desarrollo: 'DIAGONAL',
|
||||
precioSinAcabados: 4_415_000,
|
||||
precioConAcabados: 4_579_000,
|
||||
})
|
||||
// 2.5% de 4,415,000 = 110,375; con apartado 200,000 el neto sería −89,625
|
||||
const p = calcularPropuesta(u101, 'BANCARIO', false, 200_000)
|
||||
expect(p.enganche).toBe(0)
|
||||
// El apartado de más se acredita en las mensualidades (remanente real)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - p.pagoFinal, 2)
|
||||
expect(totalPagado(p, 200_000)).toBeCloseTo(4_415_000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
135
api-gateway/src/services/cotizacion-esquema.service.test.ts
Normal file
135
api-gateway/src/services/cotizacion-esquema.service.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calcularEsquemaFrasa,
|
||||
validarEsquemaInput,
|
||||
PCT_ENGANCHE_FIRMA,
|
||||
PCT_ENGANCHE,
|
||||
PCT_ENGANCHE_MAXIMO,
|
||||
PCT_ESCRITURACION,
|
||||
MENSUALIDADES_DEFAULT,
|
||||
MENSUALIDADES_MIN,
|
||||
MENSUALIDADES_MAX,
|
||||
} from './cotizacion-esquema.service.js'
|
||||
|
||||
/**
|
||||
* Esquema único FRASA 2026: apartado libre + enganche de firma 2.5% +
|
||||
* enganche 7.5% diferible + escrituración 5.5% (firma o diferida). Servicio
|
||||
* puro (sin prisma): el API recalcula server-side y no confía en los montos
|
||||
* del cliente. Debe dar EXACTAMENTE los mismos números que
|
||||
* apps/cotizador/src/lib/esquema-frasa.ts (caso dorado del cliente).
|
||||
*/
|
||||
|
||||
/** Caso dorado del cliente (28 sep 2026): casa de $1,000,000 */
|
||||
const CASO = { precio: 1000000, apartado: 15000, numMensualidades: 14 }
|
||||
|
||||
describe('calcularEsquemaFrasa — caso dorado $1,000,000 / apartado $15,000', () => {
|
||||
it('escrituración de contado: firma $65,000 y mensualidades de $75,000/14', () => {
|
||||
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: false })
|
||||
expect(r.engancheFirmaTotal).toBe(25000) // 2.5%
|
||||
expect(r.saldoFirma).toBe(10000) // 25,000 − 15,000 apartado acreditado
|
||||
expect(r.excedenteApartado).toBe(0)
|
||||
expect(r.enganche).toBe(75000) // 7.5%
|
||||
expect(r.escrituracion).toBe(55000) // 5.5%
|
||||
expect(r.pagoUnicoFirma).toBe(65000) // 10,000 + 55,000
|
||||
expect(r.baseDiferible).toBe(75000)
|
||||
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2) // 5,357.14
|
||||
expect(r.totalPagado).toBeCloseTo(1000000 * (PCT_ENGANCHE_FIRMA + PCT_ENGANCHE + PCT_ESCRITURACION), 2)
|
||||
})
|
||||
|
||||
it('escrituración diferida: firma $10,000 y mensualidades de $130,000/14', () => {
|
||||
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: true })
|
||||
expect(r.saldoFirma).toBe(10000)
|
||||
expect(r.pagoUnicoFirma).toBe(10000)
|
||||
expect(r.baseDiferible).toBe(130000) // 75,000 + 55,000
|
||||
expect(r.mensualidad).toBeCloseTo(130000 / 14, 2) // 9,285.71
|
||||
})
|
||||
|
||||
it('el total siempre cuadra: apartado + firma + diferible', () => {
|
||||
for (const dif of [false, true]) {
|
||||
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: dif })
|
||||
expect(r.apartado + r.pagoUnicoFirma + r.baseDiferible).toBeCloseTo(r.totalPagado, 6)
|
||||
expect(r.totalPagado).toBeCloseTo(155000, 2) // 15.5% del precio
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularEsquemaFrasa — apartado vs enganche de firma', () => {
|
||||
it('apartado exactamente el 2.5%: saldo a firma 0', () => {
|
||||
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 25000, numMensualidades: 14, escrituracionDiferida: false })
|
||||
expect(r.saldoFirma).toBe(0)
|
||||
expect(r.excedenteApartado).toBe(0)
|
||||
expect(r.enganche).toBe(75000)
|
||||
})
|
||||
|
||||
it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', () => {
|
||||
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 30000, numMensualidades: 14, escrituracionDiferida: false })
|
||||
expect(r.saldoFirma).toBe(0)
|
||||
expect(r.excedenteApartado).toBe(5000)
|
||||
expect(r.enganche).toBe(70000) // 75,000 − 5,000
|
||||
expect(r.totalPagado).toBeCloseTo(155000, 2) // 30k + 70k + 55k
|
||||
})
|
||||
|
||||
it('apartado $0: el cliente paga todo el 2.5% a la firma', () => {
|
||||
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })
|
||||
expect(r.saldoFirma).toBe(25000)
|
||||
expect(r.enganche).toBe(75000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validarEsquemaInput — guardas del esquema', () => {
|
||||
const valido = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
|
||||
|
||||
it('acepta el caso dorado', () => {
|
||||
expect(validarEsquemaInput(valido)).toBeNull()
|
||||
})
|
||||
|
||||
it('rechaza precio no positivo, apartado negativo y apartado mayor al precio', () => {
|
||||
expect(validarEsquemaInput({ ...valido, precio: 0 })).toContain('precio')
|
||||
expect(validarEsquemaInput({ ...valido, apartado: -1 })).toContain('apartado')
|
||||
expect(validarEsquemaInput({ ...valido, apartado: 2000000 })).toContain('superar')
|
||||
})
|
||||
|
||||
it('rechaza mensualidades fuera de rango o no enteras', () => {
|
||||
expect(validarEsquemaInput({ ...valido, numMensualidades: 0 })).toContain('Mínimo')
|
||||
expect(validarEsquemaInput({ ...valido, numMensualidades: 37 })).toContain('Máximo')
|
||||
expect(validarEsquemaInput({ ...valido, numMensualidades: 2.5 })).toContain('entero')
|
||||
})
|
||||
|
||||
it('calcularEsquemaFrasa lanza el mensaje de validación', () => {
|
||||
expect(() => calcularEsquemaFrasa({ ...valido, numMensualidades: 0 })).toThrow('Mínimo')
|
||||
})
|
||||
|
||||
it('constantes del esquema (globales para todos los desarrollos)', () => {
|
||||
expect(PCT_ENGANCHE_FIRMA).toBe(0.025)
|
||||
expect(PCT_ENGANCHE).toBe(0.075)
|
||||
expect(PCT_ESCRITURACION).toBe(0.055)
|
||||
expect(MENSUALIDADES_DEFAULT).toBe(14)
|
||||
expect(MENSUALIDADES_MIN).toBe(1)
|
||||
expect(MENSUALIDADES_MAX).toBe(36)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularEsquemaFrasa (API) — enganche adicional (hasta 90% de anticipo)', () => {
|
||||
const base = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
|
||||
|
||||
it('sin adicional: 0 y saldo a financiar del 84.5%', () => {
|
||||
const r = calcularEsquemaFrasa(base)
|
||||
expect(r.engancheAdicional).toBe(0)
|
||||
expect(r.saldoAFinanciar).toBeCloseTo(845000, 2)
|
||||
})
|
||||
|
||||
it('el adicional reduce el saldo a financiar peso a peso', () => {
|
||||
const r = calcularEsquemaFrasa({ ...base, engancheAdicional: 400000 })
|
||||
expect(r.saldoAFinanciar).toBeCloseTo(445000, 2)
|
||||
expect(r.totalPagado).toBeCloseTo(155000, 2)
|
||||
})
|
||||
|
||||
it('rechaza pasar del 90% de enganche total', () => {
|
||||
expect(PCT_ENGANCHE_MAXIMO).toBe(0.9)
|
||||
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 745001 })).toThrow(/90%/)
|
||||
})
|
||||
|
||||
it('rechaza adicional negativo', () => {
|
||||
expect(validarEsquemaInput({ ...base, engancheAdicional: -1 })).toMatch(/adicional/i)
|
||||
})
|
||||
})
|
||||
126
api-gateway/src/services/cotizacion-esquema.service.ts
Normal file
126
api-gateway/src/services/cotizacion-esquema.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Esquema de cotización FRASA 2026 (flujo único, aprobado por el cliente):
|
||||
*
|
||||
* 1. APARTADO: cantidad libre (sin mínimo), aparta la unidad. Hoy.
|
||||
* 2. ENGANCHE DE FIRMA: 2.5% del precio. El apartado se acredita DENTRO de
|
||||
* este concepto → lo que se paga a la firma es el saldo restante
|
||||
* (si el apartado supera el 2.5%, el excedente pasa al enganche).
|
||||
* 3. ENGANCHE: 7.5% del precio, diferible en N mensualidades (N a elección
|
||||
* del vendedor).
|
||||
* 4. GASTOS DE ESCRITURACIÓN: 5.5% del precio. El cliente los salda en un
|
||||
* pago a la firma O los difiere junto con el enganche.
|
||||
*
|
||||
* Espejo en TypeScript de apps/cotizador/src/lib/esquema-frasa.ts: las fórmulas
|
||||
* deben mantenerse idénticas en ambos lados (el API recalcula server-side y no
|
||||
* confía en los montos que manda el cliente). Servicio puro: no toca prisma.
|
||||
* Porcentajes globales para todos los desarrollos (confirmado por el cliente
|
||||
* el 28 sep 2026).
|
||||
*/
|
||||
|
||||
export const PCT_ENGANCHE_FIRMA = 0.025
|
||||
export const PCT_ENGANCHE = 0.075
|
||||
export const PCT_ESCRITURACION = 0.055
|
||||
/** Tope total de enganche (esquema + adicional): el cliente puede dar hasta
|
||||
* 90% de anticipo, quedando un mínimo del 10% del precio a financiar */
|
||||
export const PCT_ENGANCHE_MAXIMO = 0.9
|
||||
|
||||
export const MENSUALIDADES_DEFAULT = 14
|
||||
export const MENSUALIDADES_MIN = 1
|
||||
export const MENSUALIDADES_MAX = 36
|
||||
|
||||
export interface EsquemaFrasaInput {
|
||||
/** Precio de la unidad (ya resuelto acabados/precio único) */
|
||||
precio: number
|
||||
/** Apartado: cantidad libre (>= 0) */
|
||||
apartado: number
|
||||
/** Número de mensualidades del diferimiento (elección del vendedor) */
|
||||
numMensualidades: number
|
||||
/** true = la escrituración se difiere con el enganche; false = se paga a la firma */
|
||||
escrituracionDiferida: boolean
|
||||
/** Enganche adicional (anticipo extra, >= 0): opcional; el total de
|
||||
* anticipos (esquema + adicional) no puede superar el 90% del precio */
|
||||
engancheAdicional?: number
|
||||
}
|
||||
|
||||
export interface EsquemaFrasaResultado {
|
||||
precio: number
|
||||
apartado: number
|
||||
/** 2.5% del precio (concepto completo) */
|
||||
engancheFirmaTotal: number
|
||||
/** Lo que se paga a la firma por el enganche de firma (>= 0) */
|
||||
saldoFirma: number
|
||||
/** Apartado que excedió el 2.5% y se acredita al enganche (>= 0) */
|
||||
excedenteApartado: number
|
||||
/** Enganche del 7.5%, neto del excedente del apartado */
|
||||
enganche: number
|
||||
escrituracion: number
|
||||
escrituracionDiferida: boolean
|
||||
/** Todo lo que se liquida a la firma: saldoFirma + escrituración si no se difiere */
|
||||
pagoUnicoFirma: number
|
||||
/** Lo que entra al diferimiento: enganche (+ escrituración si se difiere) */
|
||||
baseDiferible: number
|
||||
numMensualidades: number
|
||||
mensualidad: number
|
||||
/** apartado + firma + diferible — debe cuadrar con el precio saldado */
|
||||
totalPagado: number
|
||||
/** Anticipo extra sobre el esquema (0 si no se captura) */
|
||||
engancheAdicional: number
|
||||
/** Lo que queda por financiar: precio − esquema − adicional (mínimo 10%) */
|
||||
saldoAFinanciar: number
|
||||
}
|
||||
|
||||
/** Error de validación de entrada; null si es válida */
|
||||
export function validarEsquemaInput(input: EsquemaFrasaInput): string | null {
|
||||
if (!Number.isFinite(input.precio) || input.precio <= 0) return 'El precio debe ser mayor a cero'
|
||||
if (!Number.isFinite(input.apartado) || input.apartado < 0) return 'El apartado no puede ser negativo'
|
||||
if (input.apartado > input.precio) return 'El apartado no puede superar el precio de la propiedad'
|
||||
if (!Number.isInteger(input.numMensualidades)) return 'Las mensualidades deben ser un número entero'
|
||||
if (input.numMensualidades < MENSUALIDADES_MIN) return `Mínimo ${MENSUALIDADES_MIN} mensualidad`
|
||||
if (input.numMensualidades > MENSUALIDADES_MAX) return `Máximo ${MENSUALIDADES_MAX} mensualidades`
|
||||
const adicional = input.engancheAdicional ?? 0
|
||||
if (!Number.isFinite(adicional) || adicional < 0) return 'El enganche adicional no puede ser negativo'
|
||||
return null
|
||||
}
|
||||
|
||||
export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResultado {
|
||||
const error = validarEsquemaInput(input)
|
||||
if (error) throw new Error(error)
|
||||
|
||||
const { precio, apartado, numMensualidades, escrituracionDiferida } = input
|
||||
const engancheAdicional = input.engancheAdicional ?? 0
|
||||
const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA
|
||||
const saldoFirma = Math.max(0, engancheFirmaTotal - apartado)
|
||||
const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal)
|
||||
const enganche = precio * PCT_ENGANCHE - excedenteApartado
|
||||
const escrituracion = precio * PCT_ESCRITURACION
|
||||
const pagoUnicoFirma = saldoFirma + (escrituracionDiferida ? 0 : escrituracion)
|
||||
const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0)
|
||||
const mensualidad = baseDiferible / numMensualidades
|
||||
const totalPagado = apartado + pagoUnicoFirma + baseDiferible
|
||||
// El anticipo total (esquema + adicional) no puede superar el 90% del precio
|
||||
if (totalPagado + engancheAdicional > precio * PCT_ENGANCHE_MAXIMO + 1e-9) {
|
||||
throw new Error(
|
||||
`El enganche total no puede superar el ${PCT_ENGANCHE_MAXIMO * 100}% del precio (` +
|
||||
`máx. ${(precio * PCT_ENGANCHE_MAXIMO - totalPagado).toFixed(2)} de enganche adicional)`,
|
||||
)
|
||||
}
|
||||
const saldoAFinanciar = precio - totalPagado - engancheAdicional
|
||||
|
||||
return {
|
||||
precio,
|
||||
apartado,
|
||||
engancheFirmaTotal,
|
||||
saldoFirma,
|
||||
excedenteApartado,
|
||||
enganche,
|
||||
escrituracion,
|
||||
escrituracionDiferida,
|
||||
pagoUnicoFirma,
|
||||
baseDiferible,
|
||||
numMensualidades,
|
||||
mensualidad,
|
||||
totalPagado,
|
||||
engancheAdicional,
|
||||
saldoAFinanciar,
|
||||
}
|
||||
}
|
||||
201
api-gateway/src/services/cotizacion-reglas.test.ts
Normal file
201
api-gateway/src/services/cotizacion-reglas.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
APARTADO_DEFAULT,
|
||||
DESARROLLOS as DESARROLLOS_LOCALES,
|
||||
calcularPropuesta,
|
||||
calcularPropuestas,
|
||||
totalPagado,
|
||||
type DescuentosPropuestas,
|
||||
type ParametrosDesarrollo,
|
||||
type TipoPropuestaApi,
|
||||
type UnidadCotizable,
|
||||
} from './cotizacion.service.js'
|
||||
import { normalizarReglas } from './reglas-desarrollo.service.js'
|
||||
|
||||
/**
|
||||
* Compatibilidad de las reglas configurables: cotizar con las reglas que llegan
|
||||
* de la BD (mismo JSON que guarda `ReglaCotizadorDesarrollo`, validado por
|
||||
* `normalizarReglas`) debe dar EXACTAMENTE los mismos números que con las
|
||||
* constantes históricas. Unidades: 3 reales por desarrollo del inventario.
|
||||
*/
|
||||
|
||||
function unidad(parcial: Partial<UnidadCotizable> & Pick<UnidadCotizable, 'codigo' | 'desarrollo'>): UnidadCotizable {
|
||||
return {
|
||||
modelo: 'X',
|
||||
fechaEntrega: null,
|
||||
supEstacionamiento: 0,
|
||||
supConstruccion: 0,
|
||||
supExcedente: 0,
|
||||
precioSinAcabados: 0,
|
||||
precioConAcabados: 0,
|
||||
status: 'DISPONIBLE',
|
||||
...parcial,
|
||||
}
|
||||
}
|
||||
|
||||
const UNIDADES: UnidadCotizable[] = [
|
||||
// DIAGONAL (precio varía con acabados)
|
||||
unidad({ codigo: 'U-101', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 4415000, precioConAcabados: 4579000 }),
|
||||
unidad({ codigo: 'U-103', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 3107108.39, precioConAcabados: 3269000 }),
|
||||
unidad({ codigo: 'U-201', desarrollo: 'DIAGONAL', fechaEntrega: null, precioSinAcabados: 3990000, precioConAcabados: 4150000 }),
|
||||
// ATELIER (precio único, N fijo = 1)
|
||||
unidad({ codigo: 'ATELIER-10', desarrollo: 'ATELIER', precioSinAcabados: 3997500, precioConAcabados: 3997500 }),
|
||||
unidad({ codigo: 'ATELIER-17', desarrollo: 'ATELIER', precioSinAcabados: 3554291, precioConAcabados: 3554291 }),
|
||||
unidad({ codigo: 'ATELIER-27', desarrollo: 'ATELIER', precioSinAcabados: 2304339, precioConAcabados: 2304339 }),
|
||||
// ESTANZA (pago final sobre precio de LISTA: el "quirk" del Excel)
|
||||
unidad({ codigo: 'ESTANZA-17', desarrollo: 'ESTANZA', precioSinAcabados: 2617632, precioConAcabados: 2617632 }),
|
||||
unidad({ codigo: 'ESTANZA-19', desarrollo: 'ESTANZA', precioSinAcabados: 2340562, precioConAcabados: 2340562 }),
|
||||
unidad({ codigo: 'ESTANZA-25', desarrollo: 'ESTANZA', precioSinAcabados: 2501683, precioConAcabados: 2501683 }),
|
||||
// SENDA (N por fecha de entrega; hay piezas sin fecha → nDefault)
|
||||
unidad({ codigo: 'SENDA-409-1-2', desarrollo: 'SENDA', fechaEntrega: '2026-12-19', precioSinAcabados: 2700000, precioConAcabados: 2700000 }),
|
||||
unidad({ codigo: 'SENDA-411-1-7', desarrollo: 'SENDA', fechaEntrega: null, precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
|
||||
unidad({ codigo: 'SENDA-409-1-4', desarrollo: 'SENDA', fechaEntrega: '2026-11-30', precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
|
||||
// SOLANA (N por unidad: meses para firma; en el espejo viene null → nDefault = 1)
|
||||
unidad({ codigo: 'SOLANA-6', desarrollo: 'SOLANA', precioSinAcabados: 2560000, precioConAcabados: 2560000 }),
|
||||
unidad({ codigo: 'SOLANA-7', desarrollo: 'SOLANA', precioSinAcabados: 2730000, precioConAcabados: 2730000 }),
|
||||
unidad({ codigo: 'SOLANA-9', desarrollo: 'SOLANA', precioSinAcabados: 2990445, precioConAcabados: 2990445 }),
|
||||
]
|
||||
|
||||
const TIPOS: TipoPropuestaApi[] = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']
|
||||
|
||||
const DESCUENTOS: DescuentosPropuestas = {
|
||||
creditoBancario: 0.01,
|
||||
creditoInfonavit: 0.005,
|
||||
contado: 0.03,
|
||||
semiContado: 0.015,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reglas "de BD": el JSON serializado tal como lo guarda el admin, con los
|
||||
* campos extra que agrega el módulo de administración (nombre, activo, flags).
|
||||
* Pasa por `normalizarReglas`, el mismo camino que usa el API al leerlas.
|
||||
*/
|
||||
function reglasDeBd(desarrollo: string): ParametrosDesarrollo {
|
||||
const constantes = DESARROLLOS_LOCALES[desarrollo]!
|
||||
const json = JSON.parse(
|
||||
JSON.stringify({
|
||||
...constantes,
|
||||
nombre: desarrollo,
|
||||
activoEnCotizador: true,
|
||||
propuestas: constantes.propuestas.map((p) => ({ ...p, activa: true })),
|
||||
}),
|
||||
) as unknown
|
||||
const reglas = normalizarReglas(desarrollo, json)
|
||||
if (!reglas) throw new Error(`Las reglas de BD de ${desarrollo} deberían ser válidas`)
|
||||
return reglas
|
||||
}
|
||||
|
||||
describe('reglas de BD vs constantes: mismos números', () => {
|
||||
it('las 4 propuestas de cada unidad dan resultados idénticos', () => {
|
||||
for (const u of UNIDADES) {
|
||||
const reglas = reglasDeBd(u.desarrollo)
|
||||
for (const acabados of [false, true]) {
|
||||
const conConstantes = calcularPropuestas({
|
||||
unidad: u,
|
||||
acabados,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
})
|
||||
const conBd = calcularPropuestas({
|
||||
unidad: u,
|
||||
acabados,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
reglas,
|
||||
})
|
||||
expect(conBd).toHaveLength(conConstantes.length)
|
||||
expect(conBd).toEqual(conConstantes)
|
||||
for (const p of conBd) {
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('la cotización de una propuesta (con descuento, enganche y N del vendedor) es idéntica', () => {
|
||||
for (const u of UNIDADES) {
|
||||
const reglas = reglasDeBd(u.desarrollo)
|
||||
for (const tipo of TIPOS) {
|
||||
for (const apartado of [0, APARTADO_DEFAULT, 150_000]) {
|
||||
const conConstantes = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12)
|
||||
const conBd = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12, reglas)
|
||||
expect(conBd).toEqual(conConstantes)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sin reglas en el parámetro se comporta como hoy (respaldo en constantes)', () => {
|
||||
const u = UNIDADES[0]!
|
||||
expect(calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, null)).toEqual(
|
||||
calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reglas del admin que cambian el comportamiento', () => {
|
||||
const u101 = UNIDADES[0]!
|
||||
|
||||
it('un descuento distinto en la BD se usa en el cálculo', () => {
|
||||
const base = DESARROLLOS_LOCALES['DIAGONAL']!
|
||||
const reglas = normalizarReglas('DIAGONAL', {
|
||||
...base,
|
||||
propuestas: base.propuestas.map((p) =>
|
||||
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.05 } : p,
|
||||
),
|
||||
})!
|
||||
const [bancario, , contado] = calcularPropuestas({
|
||||
unidad: u101,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.05, semiContado: 0 },
|
||||
reglas,
|
||||
})
|
||||
expect(bancario?.precioFinal).toBeCloseTo(4_415_000, 2)
|
||||
expect(contado?.descuento).toBe(0.05)
|
||||
expect(contado?.precioFinal).toBeCloseTo(4_415_000 * 0.95, 2)
|
||||
})
|
||||
|
||||
it('una propuesta desactivada no se calcula ni se puede elegir', () => {
|
||||
const base = DESARROLLOS_LOCALES['DIAGONAL']!
|
||||
const reglas = normalizarReglas('DIAGONAL', {
|
||||
...base,
|
||||
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
|
||||
})!
|
||||
|
||||
const propuestas = calcularPropuestas({
|
||||
unidad: u101,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
reglas,
|
||||
})
|
||||
|
||||
expect(propuestas.map((p) => p.tipo)).toEqual([
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'SEMI_CONTADO',
|
||||
])
|
||||
expect(() =>
|
||||
calcularPropuesta(u101, 'CONTADO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas),
|
||||
).toThrow(/desactivada/)
|
||||
})
|
||||
|
||||
it('un cambio de parámetros del Excel se refleja (pctEnganche / base del pago final)', () => {
|
||||
const base = DESARROLLOS_LOCALES['ESTANZA']!
|
||||
const reglas = normalizarReglas('ESTANZA', {
|
||||
...base,
|
||||
propuestas: base.propuestas.map((p) =>
|
||||
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.1, pagoFinalBase: 'final' } : p,
|
||||
),
|
||||
})!
|
||||
const casa = UNIDADES.find((u) => u.codigo === 'ESTANZA-17')!
|
||||
|
||||
const p = calcularPropuesta(casa, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas)
|
||||
|
||||
// 10% de enganche sobre precio final, pago final 90% sobre final
|
||||
expect(p.enganche).toBeCloseTo(0.1 * 2617632 - APARTADO_DEFAULT, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(0.9 * 2617632, 2)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2617632, 2)
|
||||
})
|
||||
})
|
||||
@@ -48,10 +48,10 @@ export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
||||
|
||||
// ---------- Parametrización por desarrollo ----------
|
||||
|
||||
type BasePrecio = 'lista' | 'final'
|
||||
type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
|
||||
export type BasePrecio = 'lista' | 'final'
|
||||
export type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
|
||||
|
||||
interface PropuestaConfig {
|
||||
export interface PropuestaConfig {
|
||||
tipo: TipoPropuesta
|
||||
claveDescuento: keyof DescuentosPropuestas
|
||||
titulo: string
|
||||
@@ -62,13 +62,35 @@ interface PropuestaConfig {
|
||||
pagoFinalBase: BasePrecio
|
||||
modoMensualidades: ModoMensualidades
|
||||
engancheCeroSinApartado?: boolean
|
||||
/**
|
||||
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
|
||||
* el cotizador. Ausente/true => vigente (comportamiento histórico).
|
||||
*/
|
||||
activa?: boolean
|
||||
}
|
||||
|
||||
interface ParametrosDesarrollo {
|
||||
export interface ParametrosDesarrollo {
|
||||
id: string
|
||||
/** Nombre para mostrar (solo lo traen las reglas guardadas por el admin) */
|
||||
nombre?: string
|
||||
precioUnico: boolean
|
||||
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
|
||||
nFijo?: number
|
||||
/**
|
||||
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
|
||||
* entrega ni meses para firma. Garantiza que la mensualidad y el total
|
||||
* siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts).
|
||||
*/
|
||||
nDefault?: number
|
||||
/** Cuota mensual de mantenimiento (MXN); null => pendiente */
|
||||
cuotaMantenimiento?: number | null
|
||||
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
|
||||
prefijoUnidadPdf?: string
|
||||
/**
|
||||
* Regla del admin: false => el desarrollo no se ofrece en el cotizador.
|
||||
* Ausente/true => disponible (comportamiento histórico).
|
||||
*/
|
||||
activoEnCotizador?: boolean
|
||||
propuestas: PropuestaConfig[]
|
||||
}
|
||||
|
||||
@@ -81,6 +103,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
id: 'DIAGONAL',
|
||||
precioUnico: false,
|
||||
fuenteDeN: 'fechaEntrega',
|
||||
// Último recurso si la unidad no trae fecha de entrega (mediana del
|
||||
// inventario disponible 2026-09: 13 meses hasta la terminación de obra)
|
||||
nDefault: 13,
|
||||
propuestas: [
|
||||
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' },
|
||||
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' },
|
||||
@@ -116,6 +141,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
id: 'SENDA',
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fechaEntrega',
|
||||
// Último recurso si la unidad no trae fecha de entrega (mediana del
|
||||
// inventario disponible 2026-09: 6 mensualidades)
|
||||
nDefault: 6,
|
||||
propuestas: [
|
||||
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
|
||||
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
|
||||
@@ -127,6 +155,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
id: 'SOLANA',
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'porUnidad',
|
||||
// Último recurso si la unidad no trae "meses para firma" (las casas
|
||||
// vendibles del inventario Excel están en 1 mes)
|
||||
nDefault: 1,
|
||||
propuestas: [
|
||||
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
|
||||
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true },
|
||||
@@ -138,10 +169,23 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
|
||||
const DESARROLLO_DEFAULT = 'DIAGONAL'
|
||||
|
||||
function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
|
||||
/**
|
||||
* Reglas comerciales del desarrollo: las configuradas por el admin (BD) ganan
|
||||
* cuando corresponden al id pedido; si no, se usan las constantes del módulo.
|
||||
*/
|
||||
function obtenerDesarrollo(
|
||||
id?: string | null,
|
||||
reglas?: ParametrosDesarrollo | null,
|
||||
): ParametrosDesarrollo {
|
||||
if (reglas && (id === undefined || id === null || id === '' || reglas.id === id)) return reglas
|
||||
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
|
||||
}
|
||||
|
||||
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
|
||||
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
|
||||
return params.propuestas.filter((p) => p.activa !== false)
|
||||
}
|
||||
|
||||
export interface PropuestaResultado {
|
||||
tipo: TipoPropuesta
|
||||
titulo: string
|
||||
@@ -162,6 +206,11 @@ export interface CotizacionInput {
|
||||
apartado: number
|
||||
descuentos: DescuentosPropuestas
|
||||
desarrollo?: string
|
||||
/**
|
||||
* Reglas comerciales efectivas del desarrollo (las configuradas por el admin
|
||||
* o su respaldo en constantes). Si no vienen, se usan las constantes.
|
||||
*/
|
||||
reglas?: ParametrosDesarrollo
|
||||
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
|
||||
numMeses?: number
|
||||
}
|
||||
@@ -174,7 +223,9 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
|
||||
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
|
||||
}
|
||||
|
||||
/** Resuelve N según la fuente del desarrollo (null si no se puede conocer) */
|
||||
/** Resuelve N según la fuente del desarrollo; cae al plazo por defecto del
|
||||
* desarrollo (`nDefault`) cuando el inventario no trae el dato, para que la
|
||||
* mensualidad y el total SIEMPRE se calculen. */
|
||||
export function resolverNumMensualidades(
|
||||
unidad: UnidadCotizable,
|
||||
params: ParametrosDesarrollo,
|
||||
@@ -183,9 +234,11 @@ export function resolverNumMensualidades(
|
||||
case 'fijo':
|
||||
return params.nFijo ?? 1
|
||||
case 'porUnidad':
|
||||
return unidad.mesesParaFirma ?? null
|
||||
return unidad.mesesParaFirma ?? params.nDefault ?? null
|
||||
case 'fechaEntrega':
|
||||
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null
|
||||
return unidad.fechaEntrega
|
||||
? calcularNumMensualidades(unidad.fechaEntrega)
|
||||
: (params.nDefault ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +249,42 @@ function baseComun(precioLista: number, descuento: number) {
|
||||
return { montoDescuento, precioFinal }
|
||||
}
|
||||
|
||||
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
|
||||
interface BloquesPago {
|
||||
enganche: number
|
||||
mensualidades: number | null
|
||||
pagoFinal: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
|
||||
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
|
||||
* propuesta), aunque el apartado o un enganche personalizado descuadren las
|
||||
* fórmulas:
|
||||
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
|
||||
* el apartado supera el enganche, ni mensualidades negativas con enganche
|
||||
* alto);
|
||||
* - el excedente se recorta de las mensualidades (bloque flexible del
|
||||
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
|
||||
* precio.
|
||||
*/
|
||||
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
|
||||
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
|
||||
const enganche = Math.max(0, bloques.enganche)
|
||||
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
|
||||
let pagoFinal = Math.max(0, bloques.pagoFinal)
|
||||
|
||||
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
|
||||
if (excedente > 0 && mensualidades !== null) {
|
||||
const recorte = Math.min(mensualidades, excedente)
|
||||
mensualidades -= recorte
|
||||
excedente -= recorte
|
||||
}
|
||||
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
|
||||
|
||||
return { enganche, mensualidades, pagoFinal }
|
||||
}
|
||||
|
||||
/** Calcula una propuesta comercial con los parámetros dados */
|
||||
function calcularPropuestaConfig(
|
||||
cfg: PropuestaConfig,
|
||||
@@ -217,17 +306,21 @@ function calcularPropuestaConfig(
|
||||
cfg.pctEnganche <= 0.05
|
||||
const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche
|
||||
|
||||
// Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
|
||||
// SOLANA lo deja en 0 cuando el % es 0
|
||||
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
||||
const enganche =
|
||||
const engancheBruto =
|
||||
cfg.engancheCeroSinApartado && pctEnganche === 0
|
||||
? 0
|
||||
: pctEnganche * baseEnganche - apartado
|
||||
: pctEnganche * baseEnganche
|
||||
// Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
|
||||
// apartado supera el enganche; cuadrarBloques lo acota a 0)
|
||||
const engancheExcel = engancheBruto - apartado
|
||||
|
||||
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
||||
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
||||
const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
|
||||
|
||||
let mensualidadesTotal: number | null = null
|
||||
let mensualidad: number | null = null
|
||||
let mensualidadesExcel: number | null = null
|
||||
let notaMensualidades: string | undefined
|
||||
if (cfg.modoMensualidades !== 'ninguna') {
|
||||
if (n === null) {
|
||||
@@ -235,22 +328,26 @@ function calcularPropuestaConfig(
|
||||
} else {
|
||||
switch (cfg.modoMensualidades) {
|
||||
case 'remanente':
|
||||
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
|
||||
mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
|
||||
break
|
||||
case 'remanenteLista':
|
||||
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
|
||||
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||
mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
|
||||
break
|
||||
case 'porcentaje':
|
||||
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||
mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||
break
|
||||
}
|
||||
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
|
||||
mensualidadesTotal = Math.max(0, mensualidadesTotal)
|
||||
mensualidad = mensualidadesTotal / n
|
||||
}
|
||||
}
|
||||
|
||||
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
|
||||
enganche: engancheExcel,
|
||||
mensualidades: mensualidadesExcel,
|
||||
pagoFinal: pagoFinalExcel,
|
||||
})
|
||||
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
|
||||
|
||||
return {
|
||||
tipo: cfg.tipo,
|
||||
titulo: cfg.titulo,
|
||||
@@ -260,7 +357,7 @@ function calcularPropuestaConfig(
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad,
|
||||
mensualidadesTotal,
|
||||
mensualidadesTotal: mensualidades,
|
||||
pagoFinal,
|
||||
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
|
||||
}
|
||||
@@ -293,13 +390,16 @@ export function indiceMenorTotal(totales: number[]): number {
|
||||
return indice
|
||||
}
|
||||
|
||||
/** Calcula las propuestas comerciales del desarrollo de la unidad */
|
||||
/** Calcula las propuestas comerciales vigentes del desarrollo de la unidad */
|
||||
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
|
||||
const { unidad, acabados, apartado, descuentos } = input
|
||||
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
|
||||
const params = obtenerDesarrollo(
|
||||
input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT,
|
||||
input.reglas ?? null,
|
||||
)
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
return params.propuestas.map((cfg) =>
|
||||
return propuestasVigentes(params).map((cfg) =>
|
||||
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
|
||||
)
|
||||
}
|
||||
@@ -321,6 +421,10 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
|
||||
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
|
||||
* `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con
|
||||
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
|
||||
* `numMeses` opcional: número de mensualidades elegido por el vendedor en el
|
||||
* cotizador; si no viene se resuelve con la fuente del desarrollo.
|
||||
* `reglas` opcional: reglas comerciales efectivas del desarrollo de la unidad
|
||||
* (BD del admin con respaldo en constantes); sin ellas se usan las constantes.
|
||||
*/
|
||||
export function calcularPropuesta(
|
||||
unidad: UnidadCotizable,
|
||||
@@ -329,15 +433,22 @@ export function calcularPropuesta(
|
||||
apartado: number = APARTADO_DEFAULT,
|
||||
descuento?: number,
|
||||
enganchePct?: number,
|
||||
numMeses?: number,
|
||||
reglas?: ParametrosDesarrollo | null,
|
||||
): PropuestaResultado {
|
||||
const params = obtenerDesarrollo(unidad.desarrollo)
|
||||
const params = obtenerDesarrollo(unidad.desarrollo, reglas ?? null)
|
||||
const tipoInterno = TIPO_PROPUESTA_API[tipo]
|
||||
const cfg = params.propuestas.find((p) => p.tipo === tipoInterno)
|
||||
const cfg = propuestasVigentes(params).find((p) => p.tipo === tipoInterno)
|
||||
if (!cfg) {
|
||||
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`)
|
||||
const desactivada = params.propuestas.some((p) => p.tipo === tipoInterno)
|
||||
throw new Error(
|
||||
desactivada
|
||||
? `La propuesta ${tipo} está desactivada para el desarrollo ${params.id}`
|
||||
: `La propuesta ${tipo} no aplica para el desarrollo ${params.id}`,
|
||||
)
|
||||
}
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = resolverNumMensualidades(unidad, params)
|
||||
const n = numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
const d = descuento ?? cfg.descuentoDefault
|
||||
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
automatizacionState: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
@@ -43,6 +44,7 @@ beforeEach(() => {
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
|
||||
prismaMock.leadOportunidad.create.mockResolvedValue({})
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
})
|
||||
|
||||
@@ -234,9 +236,26 @@ describe('CrmLocalService.apartar', () => {
|
||||
email: 'luis@correo.com',
|
||||
desarrollo: 'ATELIER',
|
||||
modelo: 'SOHO',
|
||||
datosJson: '{"edad":35}',
|
||||
datosJson: JSON.stringify({
|
||||
edad: 35,
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
}
|
||||
|
||||
it('sin afluencia completa rechaza el apartado (regla de negocio)', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
...filaOportunidad,
|
||||
datosJson: '{"edad":35}',
|
||||
})
|
||||
await expect(svc.apartar('loc_op', 'u-1')).rejects.toThrow(/afluencia/i)
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
|
||||
const apartado = await svc.apartar('loc_op', 'u-1')
|
||||
@@ -311,8 +330,326 @@ describe('CrmLocalService.buscarLocales', () => {
|
||||
modeloInteres: 'SOHO',
|
||||
})
|
||||
expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' })
|
||||
// La búsqueda por nombre incluye variantes con/sin acento: 'ana' también
|
||||
// consulta 'ána'/'aná' además del término literal
|
||||
const whereLead = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
|
||||
expect(whereLead['AND'][1]['OR']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ nombreLead: { contains: 'ana' } },
|
||||
{ telefono: { contains: 'ana' } },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('buscar nombre sin acento encuentra el registro con tilde (jazmin -> Jazmin)', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{
|
||||
leadId: 'loc_ac',
|
||||
nombreLead: 'Jazmín Estiven',
|
||||
telefono: '6641112233',
|
||||
telefonoNorm: '6641112233',
|
||||
email: null,
|
||||
etapa: 'NUEVO',
|
||||
origen: null,
|
||||
board: 'LEADS',
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
updatedAt: new Date('2026-09-29T10:00:00Z'),
|
||||
},
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
const r = await svc.buscarLocales('jazmin')
|
||||
expect(r.map((x) => x.id)).toContain('loc_ac')
|
||||
const whereLead = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
|
||||
expect(whereLead['AND'][1]['OR']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ nombreLead: { contains: 'jazmin' } },
|
||||
{ nombreLead: { contains: 'jazmín' } },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('encuentra por telefono aunque el registro guarde espacios/ladas duplicadas', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{
|
||||
leadId: 'loc_tel',
|
||||
nombreLead: 'Jazmin Estiven',
|
||||
telefono: '+52 +52 664 782 5023',
|
||||
telefonoNorm: '6647825023',
|
||||
email: null,
|
||||
etapa: 'Intento de contacto',
|
||||
origen: 'PROSPECCION ASESOR',
|
||||
board: 'LEADS',
|
||||
desarrollo: 'DIAGONAL',
|
||||
modelo: null,
|
||||
updatedAt: new Date('2026-09-29T10:00:00Z'),
|
||||
},
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
const r = await svc.buscarLocales('6647825023')
|
||||
expect(r).toHaveLength(1)
|
||||
expect(r[0]).toMatchObject({ id: 'loc_tel', nombre: 'Jazmin Estiven' })
|
||||
// El match telefonico va contra telefonoNorm (digitos), no el telefono crudo
|
||||
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] } }),
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
AND: [
|
||||
{},
|
||||
{
|
||||
OR: [
|
||||
{ nombreLead: { contains: '6647825023' } },
|
||||
{ telefono: { contains: '6647825023' } },
|
||||
{ telefonoNorm: { contains: '6647825023' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('buscar con lada completa (+52 ...) encuentra el registro sin lada', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{
|
||||
leadId: 'loc_tel2',
|
||||
nombreLead: 'Jazmin Estiven',
|
||||
telefono: '664 782 5023',
|
||||
telefonoNorm: '6647825023',
|
||||
email: null,
|
||||
etapa: 'Intento de contacto',
|
||||
origen: null,
|
||||
board: 'LEADS',
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
updatedAt: new Date('2026-09-29T10:00:00Z'),
|
||||
},
|
||||
])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
const r = await svc.buscarLocales('+52 664 782 5023')
|
||||
expect(r.map((x) => x.id)).toContain('loc_tel2')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('CrmLocalService.crearLead — dedup con etapas cerradas', () => {
|
||||
it('el dedup por teléfono excluye etapas cerradas: limpieza/descartes/cancelados no bloquean la re-captura', async () => {
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data }))
|
||||
const r = await svc.crearLead({ nombre: 'Reintento', telefono: '6641234567' }, 'u-1')
|
||||
expect('duplicado' in r).toBe(false)
|
||||
expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('el dedup por email usa el mismo criterio (cualquier tablero, sin etapas cerradas)', async () => {
|
||||
prismaMock.leadCaptura.findFirst
|
||||
.mockResolvedValueOnce(null) // teléfono
|
||||
.mockResolvedValueOnce(null) // email
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data }))
|
||||
await svc.crearLead({ nombre: 'Reintento Mail', telefono: '', email: 'reintento@correo.com' }, 'u-1')
|
||||
expect(prismaMock.leadCaptura.findFirst).toHaveBeenLastCalledWith({
|
||||
where: {
|
||||
OR: [{ email: 'reintento@correo.com' }],
|
||||
etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrmLocalService.buscarLocales — privacidad por vendedor', () => {
|
||||
it('soloPropio filtra leads por usuario y clientes por capturadoPorId', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
await svc.buscarLocales('ana', { usuarioId: 'u-9', soloPropio: true })
|
||||
const wherePrivado = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
|
||||
expect(wherePrivado['AND'][0]).toEqual({ usuarioId: 'u-9' })
|
||||
expect(wherePrivado['AND'][1]['OR']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ nombreLead: { contains: 'ana' } },
|
||||
{ telefono: { contains: 'ana' } },
|
||||
]),
|
||||
)
|
||||
const whereCliente = prismaMock.cliente.findMany.mock.calls[0][0]['where']
|
||||
expect(whereCliente['AND'][0]).toEqual({ capturadoPorId: 'u-9' })
|
||||
expect(whereCliente['AND'][1]['OR']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ nombreCompleto: { contains: 'ana' } },
|
||||
{ celular: { contains: 'ana' } },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('sin alcance la búsqueda sigue siendo global (roles de supervisión)', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
await svc.buscarLocales('ana')
|
||||
const whereGlobal = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
|
||||
expect(whereGlobal['AND'][0]).toEqual({})
|
||||
expect(whereGlobal['AND'][1]['OR']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ nombreLead: { contains: 'ana' } },
|
||||
{ telefono: { contains: 'ana' } },
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizaTelefono', () => {
|
||||
it('quita formato y toma los últimos 10 dígitos', async () => {
|
||||
const { normalizaTelefono } = await import('./crm-local.service.js')
|
||||
expect(normalizaTelefono('+52 664 225 7841')).toBe('6642257841')
|
||||
expect(normalizaTelefono('+526198790165')).toBe('6198790165')
|
||||
expect(normalizaTelefono('+52 52 664 225 7841')).toBe('6642257841') // lada duplicada
|
||||
expect(normalizaTelefono('4771234567')).toBe('4771234567')
|
||||
})
|
||||
|
||||
it('conserva 7-9 dígitos; rechaza menos de 7 y vacíos', async () => {
|
||||
const { normalizaTelefono } = await import('./crm-local.service.js')
|
||||
expect(normalizaTelefono('12345678')).toBe('12345678')
|
||||
expect(normalizaTelefono('123456')).toBeNull()
|
||||
expect(normalizaTelefono('')).toBeNull()
|
||||
expect(normalizaTelefono(null)).toBeNull()
|
||||
expect(normalizaTelefono(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrmLocalService dedup normalizado (anti-duplicados)', () => {
|
||||
it('crearLead guarda telefonoNorm aunque el teléfono venga con espacios', async () => {
|
||||
const r = await svc.crearLead({ nombre: 'Guadalupe Avila', telefono: '+52 664 225 7841' }, 'u-1')
|
||||
expect('duplicado' in r).toBe(false)
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ telefono: '+52 664 225 7841', telefonoNorm: '6642257841' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('el dedup busca por telefonoNorm exacto (no por contains con espacios)', async () => {
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Guadalupe' })
|
||||
const r = await svc.crearLead({ nombre: 'Guadalupe', telefono: '664 225 7841' })
|
||||
expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Guadalupe' } })
|
||||
expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ telefonoNorm: '6642257841' }),
|
||||
}),
|
||||
)
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('crearAfluencia guarda telefonoNorm y dedup por teléfono normalizado', async () => {
|
||||
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '+52 (664) 111-2233' }, 'u-2')
|
||||
expect('duplicado' in r).toBe(false)
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ telefonoNorm: '6641112233' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('email se guarda en minúsculas y el dedup lo busca también en minúsculas', async () => {
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
|
||||
const r = await svc.crearLead({ nombre: 'Ana Mail', email: 'Ana@Correo.COM' }, 'u-1')
|
||||
expect('duplicado' in r).toBe(false)
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ email: 'ana@correo.com' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('convertirLead propaga telefonoNorm a la oportunidad nueva', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_lead',
|
||||
oportunidadId: null,
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Ana Propaga',
|
||||
telefono: '+52 664 225 7841',
|
||||
telefonoNorm: '6642257841',
|
||||
email: null,
|
||||
origen: null,
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
datosJson: null,
|
||||
})
|
||||
await svc.convertirLead('loc_lead')
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'OPORTUNIDADES', telefonoNorm: '6642257841' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('dos capturas simultáneas del mismo teléfono: sólo una crea (lock anti-carrera)', async () => {
|
||||
// findFirst devuelve null la primera vez y "encuentra" la fila ya creada
|
||||
// a partir de la segunda: sin lock, la carrera crearía dos filas.
|
||||
const tienda = new Map<string, { leadId: string; nombreLead: string }>()
|
||||
prismaMock.leadCaptura.findFirst.mockImplementation(async ({ where }) => {
|
||||
const tel = where?.telefonoNorm as string | undefined
|
||||
return tel ? (tienda.get(tel) ?? null) : null
|
||||
})
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => {
|
||||
tienda.set(data.telefonoNorm, { leadId: data.leadId, nombreLead: data.nombreLead })
|
||||
return { id: 'row', ...data }
|
||||
})
|
||||
const [r1, r2] = await Promise.all([
|
||||
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
|
||||
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
|
||||
])
|
||||
const duplicados = [r1, r2].filter((r) => 'duplicado' in r).length
|
||||
const creados = [r1, r2].filter((r) => !('duplicado' in r)).length
|
||||
expect(creados).toBe(1)
|
||||
expect(duplicados).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrmLocalService.reasignarCadena', () => {
|
||||
it('lead sin convertir: solo mueve su propia fila', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'L1', board: 'LEADS', oportunidadId: null,
|
||||
})
|
||||
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 1 })
|
||||
const n = await svc.reasignarCadena('L1', 'u-nueva')
|
||||
expect(n).toBe(1)
|
||||
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
|
||||
where: { leadId: { in: ['L1'] } },
|
||||
data: { usuarioId: 'u-nueva' },
|
||||
})
|
||||
// Sin oportunidad no busca ni lead enlazado ni apartado
|
||||
expect(prismaMock.automatizacionState.findFirst).not.toHaveBeenCalled()
|
||||
// Las actividades pendientes también pasan al nuevo vendedor
|
||||
expect(prismaMock.recordatorioVendedor.updateMany).toHaveBeenCalledWith({
|
||||
where: { leadId: { in: ['L1'] }, completado: false },
|
||||
data: { usuarioId: 'u-nueva' },
|
||||
})
|
||||
})
|
||||
|
||||
it('oportunidad convertida: mueve lead + oportunidad + apartado', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'O1', board: 'OPORTUNIDADES', oportunidadId: 'O1',
|
||||
})
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'L1' })
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' })
|
||||
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 3 })
|
||||
const n = await svc.reasignarCadena('O1', 'u-nueva')
|
||||
expect(n).toBe(3)
|
||||
const where = prismaMock.leadCaptura.updateMany.mock.calls[0][0]['where']
|
||||
expect(where['leadId']['in'].sort()).toEqual(['A1', 'L1', 'O1'])
|
||||
})
|
||||
|
||||
it('apartado: reconstruye la cadena completa hacia atrás', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'A1', board: 'APARTADO', oportunidadId: 'A1',
|
||||
})
|
||||
prismaMock.automatizacionState.findFirst.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' })
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'L1' })
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' })
|
||||
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 3 })
|
||||
const n = await svc.reasignarCadena('A1', 'u-nueva')
|
||||
expect(n).toBe(3)
|
||||
const where = prismaMock.leadCaptura.updateMany.mock.calls[0][0]['where']
|
||||
expect(where['leadId']['in'].sort()).toEqual(['A1', 'L1', 'O1'])
|
||||
})
|
||||
|
||||
it('id inexistente: 404 en vez de "éxito" silencioso', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
await expect(svc.reasignarCadena('loc_fantasma', 'u-nueva')).rejects.toThrow('Lead no encontrado')
|
||||
expect(prismaMock.leadCaptura.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { afluenciaCompleta } from '../config/vocabulario.js'
|
||||
import { claveApartadoCreado } from './automatizaciones.service.js'
|
||||
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
@@ -76,19 +77,28 @@ export class CrmLocalService {
|
||||
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
|
||||
*/
|
||||
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
||||
// Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER
|
||||
// tablero: evita que la misma persona quede como lead y como afluencia.
|
||||
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
|
||||
const telLimpio = soloDigitos(datos.telefono).slice(-10)
|
||||
if (telLimpio.length >= 7) {
|
||||
const existente = await buscarPorTelefono(telLimpio)
|
||||
const telNorm =
|
||||
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
|
||||
const emailNorm =
|
||||
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
|
||||
// Serializa la captura por teléfono/email: dos envíos simultáneos no deben
|
||||
// pasar juntos la validación de duplicado.
|
||||
return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `cap:${usuarioId ?? 'anon'}`, async () => {
|
||||
// Dedup por teléfono normalizado en CUALQUIER tablero: evita que la
|
||||
// misma persona quede como lead y como afluencia.
|
||||
if (telNorm) {
|
||||
const existente = await buscarPorTelefono(telNorm)
|
||||
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
|
||||
}
|
||||
}
|
||||
// Dedup por email
|
||||
if (datos.email !== undefined && datos.email.trim() !== '') {
|
||||
// Dedup por email (minúsculas): mismo criterio que el teléfono (cualquier
|
||||
// tablero, sin etapas cerradas)
|
||||
if (emailNorm) {
|
||||
// Busca el email normalizado y, si venía distinto (mayúsculas), también
|
||||
// tal cual: los registros viejos conservan el formato original.
|
||||
const variantes = [{ email: emailNorm }]
|
||||
if (datos.email && datos.email.trim() !== emailNorm) variantes.push({ email: datos.email.trim() })
|
||||
const existenteEmail = await prisma.leadCaptura.findFirst({
|
||||
where: { email: datos.email.trim(), board: 'LEADS' },
|
||||
where: { OR: variantes, etapa: { notIn: ETAPAS_CERRADAS } },
|
||||
})
|
||||
if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } }
|
||||
}
|
||||
@@ -100,7 +110,8 @@ export class CrmLocalService {
|
||||
usuarioId: usuarioId ?? 'desconocido',
|
||||
nombreLead: datos.nombre,
|
||||
telefono: datos.telefono ?? null,
|
||||
email: datos.email ?? null,
|
||||
telefonoNorm: telNorm,
|
||||
email: emailNorm,
|
||||
origen: datos.origen ?? null,
|
||||
desarrollo: datos.desarrollo ?? null,
|
||||
board: 'LEADS',
|
||||
@@ -110,6 +121,7 @@ export class CrmLocalService {
|
||||
},
|
||||
})
|
||||
return { id: fila.leadId, oportunidadId: null }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,35 +135,22 @@ export class CrmLocalService {
|
||||
|
||||
if (oportunidadId !== undefined) {
|
||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
||||
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
|
||||
// ese id para que la captura no se pierda en silencio.
|
||||
// Sin fila local: id inválido o liga guardada de una limpieza anterior.
|
||||
// Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
|
||||
// rechaza para no sembrar registros huérfanos en Oportunidades.
|
||||
if (!existente) {
|
||||
await prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: oportunidadId,
|
||||
oportunidadId,
|
||||
usuarioId: usuarioId ?? 'desconocido',
|
||||
nombreLead: datos.nombre,
|
||||
telefono: datos.telefono ?? null,
|
||||
email: datos.email ?? null,
|
||||
origen: datos.origen ?? null,
|
||||
desarrollo: datos.desarrollo ?? null,
|
||||
modelo: modelo ?? null,
|
||||
board: 'OPORTUNIDADES',
|
||||
etapa: 'Afluencia',
|
||||
datosJson: JSON.stringify(datos),
|
||||
pendienteSync: true,
|
||||
},
|
||||
})
|
||||
return { id: oportunidadId, oportunidadId, actualizada: true }
|
||||
throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
|
||||
}
|
||||
|
||||
// Sólo se pisan los campos capturados: lo que no viene en el payload se
|
||||
// conserva (antes se borraba con null y se perdía la cita agendada).
|
||||
const cambio: Record<string, unknown> = { pendienteSync: true }
|
||||
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
|
||||
if (datos.telefono !== undefined) cambio['telefono'] = datos.telefono
|
||||
if (datos.email !== undefined) cambio['email'] = datos.email
|
||||
if (datos.telefono !== undefined) {
|
||||
cambio['telefono'] = datos.telefono
|
||||
cambio['telefonoNorm'] = datos.telefono.trim() === '' ? null : normalizaTelefono(datos.telefono)
|
||||
}
|
||||
if (datos.email !== undefined) cambio['email'] = datos.email.trim() === '' ? null : datos.email.trim().toLowerCase()
|
||||
if (datos.origen !== undefined) cambio['origen'] = datos.origen
|
||||
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
|
||||
if (modelo !== undefined) cambio['modelo'] = modelo
|
||||
@@ -176,13 +175,17 @@ export class CrmLocalService {
|
||||
return { id: oportunidadId, oportunidadId, actualizada: true }
|
||||
}
|
||||
|
||||
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
|
||||
const telLimpio = soloDigitos(datos.telefono).slice(-10)
|
||||
if (telLimpio.length >= 7) {
|
||||
const duplicado = await buscarPorTelefono(telLimpio)
|
||||
const telNorm =
|
||||
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
|
||||
const emailNorm =
|
||||
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
|
||||
// Dedup + creación serializados por teléfono/email (mismo criterio que
|
||||
// crearLead: dos envíos simultáneos no deben duplicar).
|
||||
return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `afl:${usuarioId ?? 'anon'}`, async () => {
|
||||
if (telNorm) {
|
||||
const duplicado = await buscarPorTelefono(telNorm)
|
||||
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
|
||||
}
|
||||
}
|
||||
|
||||
const id = idLocal()
|
||||
await prisma.$transaction([
|
||||
@@ -193,7 +196,8 @@ export class CrmLocalService {
|
||||
usuarioId: usuarioId ?? 'desconocido',
|
||||
nombreLead: datos.nombre,
|
||||
telefono: datos.telefono ?? null,
|
||||
email: datos.email ?? null,
|
||||
telefonoNorm: telNorm,
|
||||
email: emailNorm,
|
||||
origen: datos.origen ?? null,
|
||||
desarrollo: datos.desarrollo ?? null,
|
||||
modelo: modelo ?? null,
|
||||
@@ -208,6 +212,7 @@ export class CrmLocalService {
|
||||
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
|
||||
])
|
||||
return { id, oportunidadId: id }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,9 +249,10 @@ export class CrmLocalService {
|
||||
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
||||
): Promise<void> {
|
||||
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
||||
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
|
||||
// Guardar fechaCita en datosJson (oportunidad y apartado) para que la
|
||||
// muestre Mi día y el calendario
|
||||
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
|
||||
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
|
||||
if (opciones.fecha !== undefined && (board === 'OPORTUNIDADES' || board === 'APARTADO')) {
|
||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (existente?.datosJson) {
|
||||
@@ -317,6 +323,7 @@ export class CrmLocalService {
|
||||
usuarioId: lead.usuarioId,
|
||||
nombreLead: lead.nombreLead,
|
||||
telefono: lead.telefono,
|
||||
telefonoNorm: lead.telefonoNorm,
|
||||
email: lead.email,
|
||||
origen: lead.origen,
|
||||
desarrollo: lead.desarrollo,
|
||||
@@ -344,8 +351,16 @@ export class CrmLocalService {
|
||||
* oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
|
||||
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
|
||||
* automatizaciones.
|
||||
*
|
||||
* `opciones.unidadCodigo` deja la unidad apartada en el `datosJson` del
|
||||
* apartado (la cotización del vendedor o la unidad de interés): es lo que
|
||||
* después leen el bloqueo de inventario y Mi día.
|
||||
*/
|
||||
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
|
||||
async apartar(
|
||||
oportunidadIdLocal: string,
|
||||
usuarioId?: string,
|
||||
opciones: { unidadCodigo?: string | undefined } = {},
|
||||
) {
|
||||
// Sombra para ids heredados sin fila local
|
||||
const oportunidad = await this.filaLocal(
|
||||
oportunidadIdLocal,
|
||||
@@ -354,6 +369,11 @@ export class CrmLocalService {
|
||||
!esIdLocal(oportunidadIdLocal),
|
||||
)
|
||||
|
||||
// Regla de negocio: no se aparta sin la afluencia completa del cliente
|
||||
if (!afluenciaCompleta(oportunidad.datosJson)) {
|
||||
throw new AppError(400, 'Primero debes capturar la afluencia del cliente (botón Afluencia) antes de apartar')
|
||||
}
|
||||
|
||||
const clave = claveApartadoCreado(oportunidadIdLocal)
|
||||
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
||||
if (previo !== null) {
|
||||
@@ -362,6 +382,7 @@ export class CrmLocalService {
|
||||
}
|
||||
|
||||
const apartadoId = idLocal()
|
||||
const datosJson = conUnidadCodigo(oportunidad.datosJson, opciones.unidadCodigo)
|
||||
const resultados = await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: oportunidadIdLocal },
|
||||
@@ -373,17 +394,18 @@ export class CrmLocalService {
|
||||
usuarioId: oportunidad.usuarioId,
|
||||
nombreLead: oportunidad.nombreLead,
|
||||
telefono: oportunidad.telefono,
|
||||
telefonoNorm: oportunidad.telefonoNorm,
|
||||
email: oportunidad.email,
|
||||
origen: oportunidad.origen,
|
||||
desarrollo: oportunidad.desarrollo,
|
||||
modelo: oportunidad.modelo,
|
||||
board: 'APARTADO',
|
||||
etapa: 'Pago Apartado',
|
||||
datosJson: oportunidad.datosJson,
|
||||
datosJson,
|
||||
// Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados
|
||||
// por vencer", el cron de avisos y la lista de Mi día (antes sólo se
|
||||
// calculaba en el JSON del alta y la columna quedaba vacía).
|
||||
fechaLimite: fechaLimiteApartado(oportunidad.datosJson),
|
||||
fechaLimite: fechaLimiteApartado(datosJson),
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
@@ -404,20 +426,81 @@ export class CrmLocalService {
|
||||
/**
|
||||
* Búsqueda local para el picker del alta de clientes (y el buscador del
|
||||
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular.
|
||||
* Con `alcance.soloPropio` (rol vendedor) filtra a los registros propios:
|
||||
* cada asesor sólo encuentra sus leads y contactos, no los de sus compañeros.
|
||||
*/
|
||||
async buscarLocales(termino: string): Promise<ResultadoBusquedaLocal[]> {
|
||||
async buscarLocales(
|
||||
termino: string,
|
||||
alcance?: { usuarioId: string; soloPropio: boolean },
|
||||
): Promise<ResultadoBusquedaLocal[]> {
|
||||
const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {}
|
||||
const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {}
|
||||
// Búsqueda telefónica: el teléfono crudo puede traer espacios, ladas o
|
||||
// caracteres invisibles (p. ej. '+52 +52 664 782 5023'), así que el match
|
||||
// va contra los dígitos normalizados (telefonoNorm), en ambos sentidos:
|
||||
// quien busca '6647825023' o '+52 664 782 5023' debe encontrar el registro.
|
||||
const digitos = termino.replace(/\D/g, '')
|
||||
const pareceTelefono = /^[\d\s+().-]+$/.test(termino) && digitos.length >= 2
|
||||
// Con lada (+52 ...) el término supera los 10 dígitos del número nacional:
|
||||
// se agrega también el sufijo de 10 dígitos para el match en telefonoNorm
|
||||
const sufijoNacional = digitos.length > 10 ? digitos.slice(-10) : null
|
||||
const condTelefonoLead =
|
||||
digitos.length >= 2
|
||||
? [
|
||||
{ telefono: { contains: termino } },
|
||||
{ telefonoNorm: { contains: digitos } },
|
||||
...(sufijoNacional !== null ? [{ telefonoNorm: { contains: sufijoNacional } }] : []),
|
||||
]
|
||||
: [{ telefono: { contains: termino } }]
|
||||
const condTelefonoCliente = digitos.length >= 2 ? [{ celular: { contains: digitos } }] : []
|
||||
// Nombres: variantes con/sin acento (y ñ/n) porque el asesor no siempre
|
||||
// teclea las tildes ('jazmin' debe encontrar a 'Jazmín'). Se genera la
|
||||
// forma sin acentos más una variante por vocal acentuada (una a la vez),
|
||||
// suficiente en la práctica y acotado en tamaño.
|
||||
const A_QUITA: Record<string, string> = {
|
||||
á: 'a', à: 'a', ä: 'a', â: 'a', é: 'e', è: 'e', ë: 'e', ê: 'e',
|
||||
í: 'i', ï: 'i', î: 'i', ó: 'o', ö: 'o', ô: 'o', ú: 'u', ü: 'u', û: 'u',
|
||||
Á: 'A', À: 'A', Ä: 'A', Â: 'A', É: 'E', È: 'E', Ë: 'E', Ê: 'E',
|
||||
Í: 'I', Ï: 'I', Î: 'I', Ó: 'O', Ö: 'O', Ô: 'O', Ú: 'U', Ü: 'U', Û: 'U',
|
||||
}
|
||||
const A_PONE: Record<string, string> = {
|
||||
a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú', n: 'ñ', N: 'Ñ',
|
||||
}
|
||||
const variantesNombre = (t: string): string[] => {
|
||||
const base = [...t].map((c) => A_QUITA[c] ?? c).join('')
|
||||
const variants = new Set<string>([base])
|
||||
const chars = [...base]
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const ch = chars[i] as string
|
||||
const acentuada = A_PONE[ch]
|
||||
if (acentuada !== undefined) variants.add(chars.slice(0, i).join('') + acentuada + chars.slice(i + 1).join(''))
|
||||
}
|
||||
if (base !== t) variants.add(t)
|
||||
return [...variants]
|
||||
}
|
||||
const condNombreLead = variantesNombre(termino).map((v) => ({ nombreLead: { contains: v } }))
|
||||
const condNombreCliente = variantesNombre(termino).map((v) => ({ nombreCompleto: { contains: v } }))
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] },
|
||||
where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 15,
|
||||
take: 50,
|
||||
})
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] },
|
||||
where: { AND: [propioCliente, { OR: [...condNombreCliente, { celular: { contains: termino } }, ...condTelefonoCliente] }] },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 15,
|
||||
take: 50,
|
||||
})
|
||||
/** Teléfono buscado: match si los dígitos del registro contienen los del
|
||||
* término, o viceversa (búsqueda con lada completa vs registro sin lada). */
|
||||
const coincideTelefono = (crudo: string | null | undefined): boolean => {
|
||||
if (digitos.length < 2) return true
|
||||
const d = (crudo ?? '').replace(/\D/g, '')
|
||||
return d.includes(digitos) || (d.length >= 6 && digitos.includes(d))
|
||||
}
|
||||
const candidatos = [
|
||||
...capturas.map((c) => ({
|
||||
...capturas
|
||||
.filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino))
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
@@ -429,7 +512,9 @@ export class CrmLocalService {
|
||||
modeloInteres: c.modelo ?? '',
|
||||
orden: c.updatedAt,
|
||||
})),
|
||||
...clientes.map((c) => ({
|
||||
...clientes
|
||||
.filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino))
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
@@ -447,6 +532,62 @@ export class CrmLocalService {
|
||||
.slice(0, 15)
|
||||
.map(({ orden: _orden, ...resto }) => resto)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasignación en cadena (solo admin): mueve el registro y TODA su cadena
|
||||
* al nuevo vendedor. Un cliente puede tener hasta 3 filas (lead →
|
||||
* oportunidad → apartado) y cada una tiene su propio leadId; reasignar sólo
|
||||
* la fila tocada dejaba al cliente "partido" entre dos vendedores (el lead
|
||||
* con uno y la oportunidad/apartado con el otro). También pasan las
|
||||
* actividades PENDIENTES (calendario); las completadas quedan como
|
||||
* historial del vendedor original. Devuelve cuántas filas se movieron.
|
||||
*/
|
||||
async reasignarCadena(itemId: string, usuarioId: string): Promise<number> {
|
||||
const origen = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
if (!origen) throw new NotFoundError('Lead no encontrado')
|
||||
|
||||
const ids = new Set<string>([itemId])
|
||||
let oportunidadId: string | null = null
|
||||
if (origen.board === 'LEADS') {
|
||||
oportunidadId = origen.oportunidadId
|
||||
} else if (origen.board === 'OPORTUNIDADES') {
|
||||
oportunidadId = itemId
|
||||
} else if (origen.board === 'APARTADO') {
|
||||
// Cadena inversa: el estado 'apartado-creado:{O}' apunta a este apartado
|
||||
const estado = await prisma.automatizacionState.findFirst({
|
||||
where: { clave: { startsWith: 'apartado-creado:' }, valor: itemId },
|
||||
})
|
||||
oportunidadId = estado?.clave.slice('apartado-creado:'.length) ?? null
|
||||
}
|
||||
|
||||
if (oportunidadId !== null) {
|
||||
ids.add(oportunidadId)
|
||||
// El lead origen (tabla LEADS) apunta a la oportunidad con su columna
|
||||
const lead = await prisma.leadCaptura.findFirst({
|
||||
where: { board: 'LEADS', oportunidadId },
|
||||
select: { leadId: true },
|
||||
})
|
||||
if (lead) ids.add(lead.leadId)
|
||||
// El apartado cuelga de la oportunidad por la clave de automatización
|
||||
const apartado = await prisma.automatizacionState.findUnique({
|
||||
where: { clave: claveApartadoCreado(oportunidadId) },
|
||||
})
|
||||
if (apartado) ids.add(apartado.valor)
|
||||
}
|
||||
|
||||
const listaIds = [...ids]
|
||||
const resultados = await prisma.$transaction([
|
||||
prisma.leadCaptura.updateMany({
|
||||
where: { leadId: { in: listaIds } },
|
||||
data: { usuarioId },
|
||||
}),
|
||||
prisma.recordatorioVendedor.updateMany({
|
||||
where: { leadId: { in: listaIds }, completado: false },
|
||||
data: { usuarioId },
|
||||
}),
|
||||
])
|
||||
return resultados[0].count
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
|
||||
@@ -473,12 +614,76 @@ function soloDigitos(telefono: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero.
|
||||
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados.
|
||||
* Teléfono normalizado para dedup: sólo dígitos; con 10 o más toma los
|
||||
* últimos 10 (cubre lada duplicada tipo "+52 52 ..."); con 7-9 conserva
|
||||
* todos. Menos de 7 dígitos no es buscable (null). Coincide con el backfill
|
||||
* de la migración 20260926120000_lead_telefono_norm.
|
||||
*/
|
||||
async function buscarPorTelefono(diezDigitos: string) {
|
||||
export function normalizaTelefono(telefono: string | null | undefined): string | null {
|
||||
if (!telefono) return null
|
||||
const digitos = soloDigitos(telefono)
|
||||
if (digitos.length >= 10) return digitos.slice(-10)
|
||||
if (digitos.length >= 7) return digitos
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutex en memoria por clave (un sólo proceso Node): serializa la pareja
|
||||
* validar-duplicado + crear para el mismo teléfono/email. Sin esto, dos
|
||||
* envíos simultáneos (doble clic en dos pestañas, red lenta) pasaban ambos
|
||||
* la validación y duplicaban el registro.
|
||||
*/
|
||||
const colasLock = new Map<string, Promise<unknown>>()
|
||||
|
||||
function conLock<T>(clave: string, fn: () => Promise<T>): Promise<T> {
|
||||
const encolada = (colasLock.get(clave) ?? Promise.resolve()).then(fn, fn)
|
||||
colasLock.set(clave, encolada)
|
||||
const libera = () => {
|
||||
if (colasLock.get(clave) === encolada) colasLock.delete(clave)
|
||||
}
|
||||
void encolada.then(libera, libera)
|
||||
return encolada
|
||||
}
|
||||
|
||||
/**
|
||||
* `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva
|
||||
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
|
||||
* devuelve el JSON original tal cual.
|
||||
*/
|
||||
function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): string | null {
|
||||
const unidad = unidadCodigo?.trim()
|
||||
if (!unidad) return datosJson
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (datosJson) {
|
||||
try {
|
||||
datos = JSON.parse(datosJson) as Record<string, unknown>
|
||||
} catch {
|
||||
datos = {}
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ ...datos, unidadCodigo: unidad })
|
||||
}
|
||||
|
||||
/**
|
||||
* Etapas que cierran el ciclo de un registro: descartados, perdidos y
|
||||
* cancelados. Ya NO bloquean una captura nueva con el mismo teléfono/email:
|
||||
* tras una limpieza (o un descarte) el vendedor debe poder volver a capturar
|
||||
* a esa persona. Verificado en vivo: restos de APARTADO cancelados bloqueaban
|
||||
* re-capturas tras la última limpieza de leads.
|
||||
*/
|
||||
const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado']
|
||||
|
||||
/**
|
||||
* Busca un contacto por teléfono normalizado (columna telefonoNorm) en
|
||||
* CUALQUIER tablero, excluyendo etapas cerradas. Así el mismo número no se
|
||||
* duplica entre tableros activos, pero los registros limpiados o descartados
|
||||
* no estorban. La búsqueda anterior por `telefono contains` fallaba contra
|
||||
* teléfonos guardados con espacios ("+52 664 225 7841" no contiene
|
||||
* "6642257841") y dejaba pasar duplicados.
|
||||
*/
|
||||
async function buscarPorTelefono(telefonoNormalizado: string) {
|
||||
return prisma.leadCaptura.findFirst({
|
||||
where: { telefono: { contains: diezDigitos } },
|
||||
where: { telefonoNorm: telefonoNormalizado, etapa: { notIn: ETAPAS_CERRADAS } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
496
api-gateway/src/services/desarrollos.service.test.ts
Normal file
496
api-gateway/src/services/desarrollos.service.test.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
desarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
modeloDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
reglaCotizadorDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
unidad: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
cotizacion: { count: vi.fn() },
|
||||
leadCaptura: { count: vi.fn() },
|
||||
cliente: { count: vi.fn() },
|
||||
planPago: { count: vi.fn() },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import {
|
||||
actualizarDesarrollo,
|
||||
agregarModelo,
|
||||
crearDesarrollo,
|
||||
crearUnidades,
|
||||
crearUnidadesSchema,
|
||||
eliminarDesarrollo,
|
||||
eliminarModelo,
|
||||
eliminarUnidad,
|
||||
guardarReglas,
|
||||
listarDesarrollos,
|
||||
listarDesarrollosPublicos,
|
||||
obtenerDesarrolloPublico,
|
||||
parametrosDesarrolloSchema,
|
||||
} from './desarrollos.service.js'
|
||||
|
||||
/** Fila de Desarrollo tal como la devuelve Prisma (con relaciones) */
|
||||
function filaDesarrollo(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd-1',
|
||||
clave: 'SENDA',
|
||||
nombre: 'Senda',
|
||||
estado: 'ACTIVO',
|
||||
tipo: 'VERTICAL',
|
||||
constructora: null,
|
||||
direccion: null,
|
||||
zona: 'Zapopan',
|
||||
descripcion: null,
|
||||
amenidades: 'alberca, gym',
|
||||
fechaInicioObra: null,
|
||||
fechaEntregaProgramada: new Date('2026-12-01T00:00:00.000Z'),
|
||||
fechaEntregaReprogramada: null,
|
||||
activoEnCotizador: true,
|
||||
activoEnAlta: true,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
imagenUrl: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
modelos: [],
|
||||
reglas: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function filaUnidad(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'u-1',
|
||||
codigo: 'SENDA-1',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'HIMALAYA',
|
||||
manzana: '',
|
||||
lote: '',
|
||||
nivel: '',
|
||||
supConstruccion: 120,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 1,
|
||||
precioLista: 3000000,
|
||||
precioAcabados: 3200000,
|
||||
status: 'DISPONIBLE',
|
||||
etapa: '',
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const REGLAS_VALIDAS = {
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fechaEntrega' as const,
|
||||
nDefault: 6,
|
||||
cuotaMantenimiento: null,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
propuestas: [
|
||||
{
|
||||
tipo: 'CONTADO' as const,
|
||||
claveDescuento: 'contado' as const,
|
||||
titulo: 'Contado',
|
||||
descuentoDefault: 0.02,
|
||||
pctEnganche: 0.9,
|
||||
engancheBase: 'final' as const,
|
||||
pctPagoFinal: 0.1,
|
||||
pagoFinalBase: 'final' as const,
|
||||
modoMensualidades: 'ninguna' as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.count.mockResolvedValue(0)
|
||||
prismaMock.cotizacion.count.mockResolvedValue(0)
|
||||
prismaMock.leadCaptura.count.mockResolvedValue(0)
|
||||
prismaMock.cliente.count.mockResolvedValue(0)
|
||||
prismaMock.planPago.count.mockResolvedValue(0)
|
||||
// create devuelve lo que recibe (más id/updatedAt), como Prisma
|
||||
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: `id-${String(data['codigo'])}`,
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...data,
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Fichas ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('crearDesarrollo', () => {
|
||||
it('rechaza una clave duplicada con 409', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
|
||||
await expect(crearDesarrollo({ clave: 'SENDA', nombre: 'Senda' })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: 'Ya existe un desarrollo con la clave SENDA',
|
||||
})
|
||||
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normaliza la clave a mayúsculas y aplica los defaults', async () => {
|
||||
prismaMock.desarrollo.findUnique
|
||||
.mockResolvedValueOnce(null) // no existe la clave
|
||||
.mockResolvedValueOnce(filaDesarrollo()) // lectura de la ficha creada
|
||||
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
|
||||
|
||||
const ficha = await crearDesarrollo({ clave: ' senda ', nombre: 'Senda' })
|
||||
|
||||
expect(prismaMock.desarrollo.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
clave: 'SENDA',
|
||||
nombre: 'Senda',
|
||||
estado: 'ACTIVO',
|
||||
tipo: 'VERTICAL',
|
||||
activoEnCotizador: true,
|
||||
activoEnAlta: true,
|
||||
}),
|
||||
})
|
||||
expect(ficha.clave).toBe('SENDA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listarDesarrollos', () => {
|
||||
it('cuenta unidades totales/disponibles y expone tieneReglas', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({ reglas: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedAt: new Date(), updatedPorId: null } }),
|
||||
filaDesarrollo({ id: 'd-2', clave: 'SOLE', nombre: 'Sole' }),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
|
||||
{ desarrollo: 'SENDA', status: 'VENDIDO' },
|
||||
{ desarrollo: 'SOLE', status: 'disponible' },
|
||||
])
|
||||
|
||||
const { desarrollos } = await listarDesarrollos()
|
||||
|
||||
expect(desarrollos[0]).toMatchObject({ clave: 'SENDA', unidadesTotales: 2, unidadesDisponibles: 1, tieneReglas: true })
|
||||
expect(desarrollos[1]).toMatchObject({ clave: 'SOLE', unidadesTotales: 1, unidadesDisponibles: 1, tieneReglas: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('actualizarDesarrollo', () => {
|
||||
it('404 si el desarrollo no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(actualizarDesarrollo('nope', { nombre: 'X' })).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
|
||||
it('no deja cambiar la clave si tiene unidades', async () => {
|
||||
prismaMock.desarrollo.findUnique
|
||||
.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' })
|
||||
.mockResolvedValueOnce(null) // clave destino libre
|
||||
prismaMock.unidad.count.mockResolvedValue(4)
|
||||
|
||||
await expect(actualizarDesarrollo('d-1', { clave: 'SENDA2' })).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.desarrollo.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Borrado bloqueado ─────────────────────────────────────────────────
|
||||
|
||||
describe('eliminarDesarrollo', () => {
|
||||
it('409 con el mensaje acordado si tiene inventario y operación', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
|
||||
prismaMock.cotizacion.count.mockResolvedValue(7)
|
||||
|
||||
await expect(eliminarDesarrollo('d-1')).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: 'El desarrollo tiene 2 unidades y 7 registros de operación; desactívalo en vez de borrarlo',
|
||||
})
|
||||
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('borra si no hay inventario ni operación', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
await eliminarDesarrollo('d-1')
|
||||
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
describe('crearUnidades — generador', () => {
|
||||
it('genera una unidad por número del rango con {clave} y {n}', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
|
||||
const resultado = await crearUnidades('d-1', {
|
||||
generar: { patron: '{clave}-401-{n}', desde: 1, hasta: 3, precioLista: 2500000, supConstruccion: 88 },
|
||||
})
|
||||
|
||||
expect(resultado.creadas).toBe(3)
|
||||
expect(resultado.unidades.map((u) => u.codigo)).toEqual(['SENDA-401-1', 'SENDA-401-2', 'SENDA-401-3'])
|
||||
expect(prismaMock.unidad.create).toHaveBeenCalledTimes(3)
|
||||
expect(prismaMock.unidad.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
codigo: 'SENDA-401-1',
|
||||
desarrollo: 'SENDA',
|
||||
status: 'DISPONIBLE',
|
||||
precioLista: 2500000,
|
||||
supConstruccion: 88,
|
||||
precioAcabados: 0,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('el esquema rechaza un patrón sin {n} y un body con ambos modos', () => {
|
||||
expect(() => crearUnidadesSchema.parse({ generar: { patron: 'SENDA-401', desde: 1, hasta: 3, precioLista: 1 } })).toThrow()
|
||||
expect(() =>
|
||||
crearUnidadesSchema.parse({ unidades: [{ precioLista: 1 }], generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }),
|
||||
).toThrow()
|
||||
expect(() => crearUnidadesSchema.parse({})).toThrow()
|
||||
})
|
||||
|
||||
it('rechaza un rango invertido', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
await expect(
|
||||
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 5, hasta: 1, precioLista: 1 } }),
|
||||
).rejects.toMatchObject({ status: 400 })
|
||||
})
|
||||
|
||||
it('resuelve el modelo por id validando que sea del desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA' })
|
||||
|
||||
const resultado = await crearUnidades('d-1', {
|
||||
generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-1', precioLista: 1 },
|
||||
})
|
||||
expect(resultado.unidades[0]?.modelo).toBe('HIMALAYA')
|
||||
})
|
||||
|
||||
it('rechaza un modelo de otro desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-9', desarrolloId: 'd-9', nombre: 'AJENO' })
|
||||
|
||||
await expect(
|
||||
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-9', precioLista: 1 } }),
|
||||
).rejects.toMatchObject({ status: 400 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearUnidades — alta manual y duplicados', () => {
|
||||
it('arma el código con manzana y lote cuando no se envía', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
|
||||
const resultado = await crearUnidades('d-1', {
|
||||
unidades: [{ manzana: '4', lote: '12', precioLista: 100, precioAcabados: 120 }],
|
||||
})
|
||||
expect(resultado.unidades[0]?.codigo).toBe('SENDA-4-12')
|
||||
})
|
||||
|
||||
it('409 y no crea nada si un código ya existe en la BD', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany
|
||||
.mockResolvedValueOnce([]) // codigos del desarrollo (correlativo)
|
||||
.mockResolvedValueOnce([{ codigo: 'SENDA-9' }]) // ya existe
|
||||
|
||||
await expect(
|
||||
crearUnidades('d-1', { unidades: [{ codigo: 'SENDA-9', precioLista: 1 }] }),
|
||||
).rejects.toMatchObject({ status: 409, message: 'Códigos de unidad duplicados: SENDA-9' })
|
||||
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('409 si el lote repetido aparece dos veces en la misma alta', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([])
|
||||
|
||||
await expect(
|
||||
crearUnidades('d-1', {
|
||||
unidades: [
|
||||
{ codigo: 'SENDA-7', precioLista: 1 },
|
||||
{ codigo: 'SENDA-7', precioLista: 2 },
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('eliminarUnidad', () => {
|
||||
it('409 si la unidad tiene cotizaciones o ventas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
prismaMock.cotizacion.count.mockResolvedValue(2)
|
||||
|
||||
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404 si la unidad no pertenece al desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad({ desarrollo: 'SOLANA' }))
|
||||
|
||||
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
|
||||
it('borra una unidad libre', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
|
||||
await eliminarUnidad('d-1', 'SENDA-1')
|
||||
expect(prismaMock.unidad.delete).toHaveBeenCalledWith({ where: { codigo: 'SENDA-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Modelos ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('eliminarModelo', () => {
|
||||
it('409 si el modelo tiene unidades', async () => {
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
|
||||
id: 'm-1',
|
||||
nombre: 'HIMALAYA',
|
||||
desarrollo: { clave: 'SENDA' },
|
||||
})
|
||||
prismaMock.unidad.count.mockResolvedValue(79)
|
||||
|
||||
await expect(eliminarModelo('m-1')).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.modeloDesarrollo.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('borra el modelo sin unidades', async () => {
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
|
||||
id: 'm-1',
|
||||
nombre: 'NUEVO',
|
||||
desarrollo: { clave: 'SENDA' },
|
||||
})
|
||||
await eliminarModelo('m-1')
|
||||
expect(prismaMock.modeloDesarrollo.delete).toHaveBeenCalledWith({ where: { id: 'm-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('agregarModelo', () => {
|
||||
it('409 si el nombre ya existe en el desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1' })
|
||||
|
||||
await expect(agregarModelo('d-1', { nombre: 'HIMALAYA' })).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Reglas del cotizador ──────────────────────────────────────────────
|
||||
|
||||
describe('parametrosDesarrolloSchema', () => {
|
||||
it('acepta la forma real del cotizador', () => {
|
||||
expect(parametrosDesarrolloSchema.parse(REGLAS_VALIDAS)).toMatchObject({ precioUnico: true })
|
||||
})
|
||||
|
||||
it('rechaza porcentajes fuera de 0..1', () => {
|
||||
const invalido = {
|
||||
...REGLAS_VALIDAS,
|
||||
propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], pctEnganche: 1.5 }],
|
||||
}
|
||||
expect(() => parametrosDesarrolloSchema.parse(invalido)).toThrow()
|
||||
})
|
||||
|
||||
it('rechaza enums desconocidos y claves de más', () => {
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, fuenteDeN: 'otra' })).toThrow()
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], modoMensualidades: 'raro' }] })).toThrow()
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, extra: 1 })).toThrow()
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], descuentoDefault: -1 }] })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('guardarReglas', () => {
|
||||
it('hace upsert del JSON validado y devuelve el objeto parseado', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
|
||||
desarrolloId: 'd-1',
|
||||
parametrosJson: JSON.stringify(REGLAS_VALIDAS),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedPorId: 'u-admin',
|
||||
})
|
||||
|
||||
const guardadas = await guardarReglas('d-1', REGLAS_VALIDAS, 'u-admin')
|
||||
|
||||
expect(prismaMock.reglaCotizadorDesarrollo.upsert).toHaveBeenCalledWith({
|
||||
where: { desarrolloId: 'd-1' },
|
||||
update: { parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
|
||||
create: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
|
||||
})
|
||||
expect(guardadas.parametros.precioUnico).toBe(true)
|
||||
expect(guardadas.updatedPorId).toBe('u-admin')
|
||||
})
|
||||
|
||||
it('404 si el desarrollo no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(guardarReglas('nope', REGLAS_VALIDAS, null)).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Público ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('listarDesarrollosPublicos', () => {
|
||||
it('solo publica activos no cerrados, con inventario disponible y sin datos personales', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({
|
||||
modelos: [
|
||||
{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
|
||||
],
|
||||
}),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
filaUnidad({ codigo: 'SENDA-1', nivel: '4' }),
|
||||
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 999 }),
|
||||
])
|
||||
|
||||
const { desarrollos } = await listarDesarrollosPublicos()
|
||||
const d = desarrollos[0]
|
||||
|
||||
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith({
|
||||
where: { activoEnCotizador: true, estado: { not: 'CERRADO' } },
|
||||
include: expect.anything(),
|
||||
orderBy: { clave: 'asc' },
|
||||
})
|
||||
expect(d?.unidadesDisponibles).toEqual([
|
||||
{
|
||||
codigo: 'SENDA-1',
|
||||
modelo: 'HIMALAYA',
|
||||
nivel: '4',
|
||||
precioLista: 3000000,
|
||||
precioAcabados: 3200000,
|
||||
supConstruccion: 120,
|
||||
},
|
||||
])
|
||||
expect(d?.modelos[0]).toMatchObject({ nombre: 'HIMALAYA', disponibles: 1, precioDesde: 3000000 })
|
||||
|
||||
// Nunca consulta tablas con datos de clientes y la ficha no trae esos campos
|
||||
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
|
||||
const json = JSON.stringify(desarrollos)
|
||||
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado']) {
|
||||
expect(json.toLowerCase()).not.toContain(prohibido)
|
||||
}
|
||||
})
|
||||
|
||||
it('404 si la clave no existe o no es pública', async () => {
|
||||
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
|
||||
await expect(obtenerDesarrolloPublico('NOEXISTE')).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
805
api-gateway/src/services/desarrollos.service.ts
Normal file
805
api-gateway/src/services/desarrollos.service.ts
Normal file
@@ -0,0 +1,805 @@
|
||||
import { z } from 'zod'
|
||||
import { prisma } from './prisma.js'
|
||||
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* Módulo Desarrollos: la BD es la fuente de verdad del catálogo de
|
||||
* desarrollos (ficha comercial, modelos de unidad y reglas del cotizador).
|
||||
*
|
||||
* Sustituye a las listas fijas del código (DESARROLLOS de
|
||||
* config/campos-departamento.ts y las constantes de
|
||||
* apps/cotizador/src/data/desarrollos.ts), que hoy siguen funcionando como
|
||||
* espejo hasta que el resto de los frentes migre a esta tabla.
|
||||
*
|
||||
* El inventario sigue viviendo en `Unidad`, ligado por `desarrollo` = `clave`
|
||||
* del desarrollo (el `codigo` de la unidad se forma con esa clave).
|
||||
*/
|
||||
|
||||
/** Estados comerciales del desarrollo */
|
||||
export const ESTADOS_DESARROLLO = ['ACTIVO', 'PREVENTA', 'CERRADO'] as const
|
||||
/** VERTICAL = edificio (deptos); HORIZONTAL = casas en lote */
|
||||
export const TIPOS_DESARROLLO = ['VERTICAL', 'HORIZONTAL'] as const
|
||||
/** Formatos de propuesta del cotizador */
|
||||
export const TIPOS_PROPUESTA = [
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'CONTADO',
|
||||
'SEMI_CONTADO',
|
||||
] as const
|
||||
/** Claves de descuento editables del cotizador */
|
||||
export const CLAVES_DESCUENTO = ['creditoBancario', 'creditoInfonavit', 'contado', 'semiContado'] as const
|
||||
|
||||
const CLAVE_DESARROLLO = /^[A-Z0-9-]{2,20}$/
|
||||
|
||||
// ── Validación de las reglas del cotizador ────────────────────────────
|
||||
|
||||
const fraccion = (campo: string) =>
|
||||
z.number().min(0, `${campo} debe estar entre 0 y 1`).max(1, `${campo} debe estar entre 0 y 1`)
|
||||
|
||||
const propuestaSchema = z
|
||||
.object({
|
||||
tipo: z.enum(TIPOS_PROPUESTA),
|
||||
claveDescuento: z.enum(CLAVES_DESCUENTO),
|
||||
titulo: z.string().trim().min(1),
|
||||
descuentoDefault: fraccion('descuentoDefault'),
|
||||
pctEnganche: fraccion('pctEnganche'),
|
||||
engancheBase: z.enum(['lista', 'final']),
|
||||
pctPagoFinal: fraccion('pctPagoFinal'),
|
||||
pagoFinalBase: z.enum(['lista', 'final']),
|
||||
// 'porcentaje' es el modo de la mayoría de los desarrollos; sin él la
|
||||
// config real del cotizador no valida (el brief lo omitía por error).
|
||||
modoMensualidades: z.enum(['remanente', 'remanenteLista', 'porcentaje', 'ninguna']),
|
||||
// SOLANA Infonavit: si el enganche es 0 y no hay apartado, no se resta
|
||||
engancheCeroSinApartado: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* Forma del JSON de reglas: la config que antes vivía en
|
||||
* apps/cotizador/src/data/desarrollos.ts. `id`/`nombre` se aceptan porque los
|
||||
* objetos del cotizador los traen, pero la fuente de verdad del nombre es la
|
||||
* ficha del desarrollo.
|
||||
*/
|
||||
export const parametrosDesarrolloSchema = z
|
||||
.object({
|
||||
id: z.string().trim().min(1).optional(),
|
||||
nombre: z.string().trim().min(1).optional(),
|
||||
precioUnico: z.boolean(),
|
||||
fuenteDeN: z.enum(['fechaEntrega', 'fijo', 'porUnidad']),
|
||||
// N fijo cuando fuenteDeN === 'fijo' (entrega inmediata)
|
||||
nFijo: z.number().int().min(0).optional(),
|
||||
nDefault: z.number().int().min(0),
|
||||
// null => el cotizador muestra "PENDIENTE" (desarrollos ya entregados)
|
||||
cuotaMantenimiento: z.number().min(0).nullable(),
|
||||
prefijoUnidadPdf: z.string().trim().min(1),
|
||||
propuestas: z.array(propuestaSchema).min(1, 'Se requiere al menos una propuesta'),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ParametrosDesarrolloJson = z.infer<typeof parametrosDesarrolloSchema>
|
||||
|
||||
// ── Validación de la ficha ────────────────────────────────────────────
|
||||
|
||||
export const crearDesarrolloSchema = z
|
||||
.object({
|
||||
clave: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(CLAVE_DESARROLLO, 'La clave debe ser mayúsculas sin espacios (2 a 20 caracteres: A-Z, 0-9, -)'),
|
||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
estado: z.enum(ESTADOS_DESARROLLO).optional(),
|
||||
tipo: z.enum(TIPOS_DESARROLLO).optional(),
|
||||
constructora: z.string().trim().min(1).nullable().optional(),
|
||||
direccion: z.string().trim().min(1).nullable().optional(),
|
||||
zona: z.string().trim().min(1).nullable().optional(),
|
||||
descripcion: z.string().trim().min(1).nullable().optional(),
|
||||
amenidades: z.string().trim().min(1).nullable().optional(),
|
||||
fechaInicioObra: z.coerce.date().nullable().optional(),
|
||||
fechaEntregaProgramada: z.coerce.date().nullable().optional(),
|
||||
fechaEntregaReprogramada: z.coerce.date().nullable().optional(),
|
||||
activoEnCotizador: z.boolean().optional(),
|
||||
activoEnAlta: z.boolean().optional(),
|
||||
prefijoUnidadPdf: z.string().trim().min(1).nullable().optional(),
|
||||
imagenUrl: z.string().trim().min(1).nullable().optional(),
|
||||
logoUrl: z.string().trim().min(1).nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const actualizarDesarrolloSchema = crearDesarrolloSchema.partial()
|
||||
|
||||
export const crearModeloSchema = z
|
||||
.object({
|
||||
nombre: z.string().trim().min(1, 'El nombre del modelo es obligatorio'),
|
||||
recamaras: z.number().int().min(0).nullable().optional(),
|
||||
banos: z.number().min(0).nullable().optional(),
|
||||
estacionamientos: z.number().int().min(0).nullable().optional(),
|
||||
supConstruccion: z.number().min(0).nullable().optional(),
|
||||
activo: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const actualizarModeloSchema = crearModeloSchema.partial()
|
||||
|
||||
const unidadManualSchema = z
|
||||
.object({
|
||||
codigo: z.string().trim().min(1).optional(),
|
||||
manzana: z.string().trim().optional(),
|
||||
lote: z.string().trim().optional(),
|
||||
nivel: z.string().trim().optional(),
|
||||
modeloId: z.string().trim().min(1).optional(),
|
||||
precioLista: z.number().min(0, 'precioLista es obligatorio'),
|
||||
precioAcabados: z.number().min(0).optional(),
|
||||
supConstruccion: z.number().min(0).optional(),
|
||||
supExcedente: z.number().min(0).optional(),
|
||||
supEstacionamiento: z.number().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** Tope del generador: un rango más grande huele a error de captura */
|
||||
export const MAX_UNIDADES_POR_ALTA = 1000
|
||||
|
||||
const generarUnidadesSchema = z
|
||||
.object({
|
||||
patron: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((p) => p.includes('{n}'), 'El patrón debe incluir {n} (el número de la unidad)')
|
||||
// El código final sigue el formato de la clave: mayúsculas sin espacios
|
||||
.refine((p) => !/[^A-Za-z0-9{}_-]/.test(p), 'El patrón solo admite letras, números, guiones, {clave} y {n}'),
|
||||
desde: z.number().int(),
|
||||
hasta: z.number().int(),
|
||||
modeloId: z.string().trim().min(1).optional(),
|
||||
precioLista: z.number().min(0),
|
||||
precioAcabados: z.number().min(0).optional(),
|
||||
supConstruccion: z.number().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const crearUnidadesSchema = z
|
||||
.object({
|
||||
unidades: z.array(unidadManualSchema).min(1).max(MAX_UNIDADES_POR_ALTA).optional(),
|
||||
generar: generarUnidadesSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((b) => (b.unidades === undefined) !== (b.generar === undefined), {
|
||||
message: 'Envía `unidades` (alta manual) o `generar` (rango), pero no ambos',
|
||||
})
|
||||
|
||||
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
|
||||
|
||||
/** Edición de una unidad existente (PATCH): precios, m², ubicación y modelo */
|
||||
export const actualizarUnidadSchema = z
|
||||
.object({
|
||||
manzana: z.string().trim().optional(),
|
||||
lote: z.string().trim().optional(),
|
||||
nivel: z.string().trim().optional(),
|
||||
modeloId: z.string().trim().min(1).optional(),
|
||||
precioLista: z.number().min(0).optional(),
|
||||
precioAcabados: z.number().min(0).optional(),
|
||||
supConstruccion: z.number().min(0).optional(),
|
||||
supExcedente: z.number().min(0).optional(),
|
||||
supEstacionamiento: z.number().min(0).optional(),
|
||||
status: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ActualizarUnidadBody = z.infer<typeof actualizarUnidadSchema>
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ModeloFila {
|
||||
id: string
|
||||
nombre: string
|
||||
recamaras: number | null
|
||||
banos: number | null
|
||||
estacionamientos: number | null
|
||||
supConstruccion: number | null
|
||||
activo: boolean
|
||||
}
|
||||
|
||||
function modeloDto(m: ModeloFila) {
|
||||
return {
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
recamaras: m.recamaras,
|
||||
banos: m.banos,
|
||||
estacionamientos: m.estacionamientos,
|
||||
supConstruccion: m.supConstruccion,
|
||||
activo: m.activo,
|
||||
}
|
||||
}
|
||||
|
||||
function esDisponible(status: string): boolean {
|
||||
return status.trim().toUpperCase() === 'DISPONIBLE'
|
||||
}
|
||||
|
||||
/** Clave de desarrollo normalizada (la tabla Unidad la guarda en mayúsculas) */
|
||||
export function normalizarClave(clave: string): string {
|
||||
return clave.trim().toUpperCase()
|
||||
}
|
||||
|
||||
/** Conteos por desarrollo a partir de una lectura del inventario */
|
||||
function conteosPorDesarrollo(unidades: { desarrollo: string; status: string }[]): Map<string, { total: number; disponibles: number }> {
|
||||
const mapa = new Map<string, { total: number; disponibles: number }>()
|
||||
for (const u of unidades) {
|
||||
const clave = normalizarClave(u.desarrollo)
|
||||
const actual = mapa.get(clave) ?? { total: 0, disponibles: 0 }
|
||||
actual.total++
|
||||
if (esDisponible(u.status)) actual.disponibles++
|
||||
mapa.set(clave, actual)
|
||||
}
|
||||
return mapa
|
||||
}
|
||||
|
||||
function parsearReglas(regla: { parametrosJson: string; updatedAt: Date; updatedPorId: string | null } | null) {
|
||||
if (regla === null) return null
|
||||
let parametros: unknown
|
||||
try {
|
||||
parametros = JSON.parse(regla.parametrosJson)
|
||||
} catch {
|
||||
parametros = null
|
||||
}
|
||||
return {
|
||||
parametros,
|
||||
updatedAt: regla.updatedAt,
|
||||
updatedPorId: regla.updatedPorId,
|
||||
}
|
||||
}
|
||||
|
||||
/** Desarrollo + relaciones tal como lo devuelve Prisma en las lecturas de ficha */
|
||||
const INCLUDE_FICHA = { modelos: { orderBy: { nombre: 'asc' } }, reglas: true } as const
|
||||
|
||||
async function fichaPorId(id: string) {
|
||||
const fila = await prisma.desarrollo.findUnique({ where: { id }, include: INCLUDE_FICHA })
|
||||
if (fila === null) return null
|
||||
const unidades = await prisma.unidad.findMany({
|
||||
where: { desarrollo: fila.clave },
|
||||
select: { status: true },
|
||||
})
|
||||
const porEstatus: Record<string, number> = {}
|
||||
for (const u of unidades) {
|
||||
const estatus = u.status.trim().toUpperCase() || 'SIN_ESTATUS'
|
||||
porEstatus[estatus] = (porEstatus[estatus] ?? 0) + 1
|
||||
}
|
||||
return {
|
||||
id: fila.id,
|
||||
clave: fila.clave,
|
||||
nombre: fila.nombre,
|
||||
estado: fila.estado,
|
||||
tipo: fila.tipo,
|
||||
constructora: fila.constructora,
|
||||
direccion: fila.direccion,
|
||||
zona: fila.zona,
|
||||
descripcion: fila.descripcion,
|
||||
amenidades: fila.amenidades,
|
||||
fechaInicioObra: fila.fechaInicioObra,
|
||||
fechaEntregaProgramada: fila.fechaEntregaProgramada,
|
||||
fechaEntregaReprogramada: fila.fechaEntregaReprogramada,
|
||||
activoEnCotizador: fila.activoEnCotizador,
|
||||
activoEnAlta: fila.activoEnAlta,
|
||||
prefijoUnidadPdf: fila.prefijoUnidadPdf,
|
||||
imagenUrl: fila.imagenUrl,
|
||||
logoUrl: fila.logoUrl,
|
||||
createdAt: fila.createdAt,
|
||||
updatedAt: fila.updatedAt,
|
||||
modelos: fila.modelos.map(modeloDto),
|
||||
reglas: parsearReglas(fila.reglas),
|
||||
unidadesTotales: unidades.length,
|
||||
unidadesDisponibles: unidades.filter((u) => esDisponible(u.status)).length,
|
||||
unidadesPorEstatus: porEstatus,
|
||||
}
|
||||
}
|
||||
|
||||
export type FichaDesarrollo = NonNullable<Awaited<ReturnType<typeof fichaPorId>>>
|
||||
|
||||
/** La ficha completa o 404 */
|
||||
export async function obtenerDesarrollo(id: string): Promise<FichaDesarrollo> {
|
||||
const ficha = await fichaPorId(id)
|
||||
if (ficha === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
return ficha
|
||||
}
|
||||
|
||||
// ── Listado ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function listarDesarrollos() {
|
||||
const [desarrollos, unidades] = await Promise.all([
|
||||
prisma.desarrollo.findMany({ include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
|
||||
prisma.unidad.findMany({ select: { desarrollo: true, status: true } }),
|
||||
])
|
||||
const conteos = conteosPorDesarrollo(unidades)
|
||||
return {
|
||||
desarrollos: desarrollos.map((d) => {
|
||||
const c = conteos.get(normalizarClave(d.clave)) ?? { total: 0, disponibles: 0 }
|
||||
return {
|
||||
id: d.id,
|
||||
clave: d.clave,
|
||||
nombre: d.nombre,
|
||||
estado: d.estado,
|
||||
tipo: d.tipo,
|
||||
activoEnCotizador: d.activoEnCotizador,
|
||||
activoEnAlta: d.activoEnAlta,
|
||||
modelos: d.modelos.map(modeloDto),
|
||||
unidadesTotales: c.total,
|
||||
unidadesDisponibles: c.disponibles,
|
||||
tieneReglas: d.reglas !== null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alta / cambio / borrado ───────────────────────────────────────────
|
||||
|
||||
export async function crearDesarrollo(data: z.infer<typeof crearDesarrolloSchema>) {
|
||||
const clave = normalizarClave(data.clave)
|
||||
const existente = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
|
||||
if (existente !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
|
||||
|
||||
const fila = await prisma.desarrollo.create({
|
||||
data: {
|
||||
clave,
|
||||
nombre: data.nombre,
|
||||
estado: data.estado ?? 'ACTIVO',
|
||||
tipo: data.tipo ?? 'VERTICAL',
|
||||
constructora: data.constructora ?? null,
|
||||
direccion: data.direccion ?? null,
|
||||
zona: data.zona ?? null,
|
||||
descripcion: data.descripcion ?? null,
|
||||
amenidades: data.amenidades ?? null,
|
||||
fechaInicioObra: data.fechaInicioObra ?? null,
|
||||
fechaEntregaProgramada: data.fechaEntregaProgramada ?? null,
|
||||
fechaEntregaReprogramada: data.fechaEntregaReprogramada ?? null,
|
||||
activoEnCotizador: data.activoEnCotizador ?? true,
|
||||
activoEnAlta: data.activoEnAlta ?? true,
|
||||
prefijoUnidadPdf: data.prefijoUnidadPdf ?? null,
|
||||
imagenUrl: data.imagenUrl ?? null,
|
||||
logoUrl: data.logoUrl ?? null,
|
||||
},
|
||||
})
|
||||
return obtenerDesarrollo(fila.id)
|
||||
}
|
||||
|
||||
export async function actualizarDesarrollo(id: string, data: z.infer<typeof actualizarDesarrolloSchema>) {
|
||||
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
|
||||
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
// Solo se actualizan los campos enviados (los undefined se descartan)
|
||||
const cambios: Record<string, unknown> = {}
|
||||
for (const [campo, valor] of Object.entries(data)) {
|
||||
if (valor !== undefined) cambios[campo] = valor
|
||||
}
|
||||
if (data.clave !== undefined) {
|
||||
const clave = normalizarClave(data.clave)
|
||||
if (clave !== actual.clave) {
|
||||
const ocupada = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
|
||||
if (ocupada !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
|
||||
// El inventario se liga por clave: renombrar la clave sin arrastrar las
|
||||
// unidades las dejaría huérfanas, así que solo se permite sin unidades.
|
||||
const unidades = await prisma.unidad.count({ where: { desarrollo: actual.clave } })
|
||||
if (unidades > 0) {
|
||||
throw new AppError(409, `El desarrollo tiene ${unidades} unidades; no se puede cambiar la clave`)
|
||||
}
|
||||
}
|
||||
cambios['clave'] = clave
|
||||
}
|
||||
|
||||
await prisma.desarrollo.update({ where: { id }, data: cambios as never })
|
||||
return obtenerDesarrollo(id)
|
||||
}
|
||||
|
||||
/** Registros de operación ligados al desarrollo: impiden borrarlo */
|
||||
async function contarRegistrosDeOperacion(clave: string, codigos: string[]): Promise<number> {
|
||||
const porUnidad = codigos.length > 0 ? { unidadCodigo: { in: codigos } } : null
|
||||
const [cotizaciones, leads, clientes, planes] = await Promise.all([
|
||||
prisma.cotizacion.count({ where: porUnidad === null ? { desarrollo: clave } : { OR: [{ desarrollo: clave }, porUnidad] } }),
|
||||
prisma.leadCaptura.count({ where: { desarrollo: clave } }),
|
||||
prisma.cliente.count({ where: { proyecto: clave } }),
|
||||
prisma.planPago.count({ where: { proyecto: clave } }),
|
||||
])
|
||||
return cotizaciones + leads + clientes + planes
|
||||
}
|
||||
|
||||
export async function eliminarDesarrollo(id: string): Promise<void> {
|
||||
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
|
||||
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const unidades = await prisma.unidad.findMany({ where: { desarrollo: actual.clave }, select: { codigo: true } })
|
||||
const operacion = await contarRegistrosDeOperacion(
|
||||
actual.clave,
|
||||
unidades.map((u) => u.codigo),
|
||||
)
|
||||
if (unidades.length > 0 || operacion > 0) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`El desarrollo tiene ${unidades.length} unidades y ${operacion} registros de operación; desactívalo en vez de borrarlo`,
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.desarrollo.delete({ where: { id } })
|
||||
}
|
||||
|
||||
// ── Modelos ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function agregarModelo(desarrolloId: string, data: z.infer<typeof crearModeloSchema>) {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const repetido = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { desarrolloId_nombre: { desarrolloId, nombre: data.nombre } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
|
||||
|
||||
return prisma.modeloDesarrollo.create({
|
||||
data: {
|
||||
desarrolloId,
|
||||
nombre: data.nombre,
|
||||
recamaras: data.recamaras ?? null,
|
||||
banos: data.banos ?? null,
|
||||
estacionamientos: data.estacionamientos ?? null,
|
||||
supConstruccion: data.supConstruccion ?? null,
|
||||
activo: data.activo ?? true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function actualizarModelo(modeloId: string, data: z.infer<typeof actualizarModeloSchema>) {
|
||||
const actual = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
|
||||
if (actual === null) throw new NotFoundError('Modelo no encontrado')
|
||||
|
||||
if (data.nombre !== undefined && data.nombre !== actual.nombre) {
|
||||
const repetido = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { desarrolloId_nombre: { desarrolloId: actual.desarrolloId, nombre: data.nombre } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
|
||||
}
|
||||
|
||||
const cambios: Record<string, unknown> = {}
|
||||
for (const [campo, valor] of Object.entries(data)) {
|
||||
if (valor !== undefined) cambios[campo] = valor
|
||||
}
|
||||
return prisma.modeloDesarrollo.update({ where: { id: modeloId }, data: cambios as never })
|
||||
}
|
||||
|
||||
export async function eliminarModelo(modeloId: string): Promise<void> {
|
||||
const actual = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { id: modeloId },
|
||||
include: { desarrollo: { select: { clave: true } } },
|
||||
})
|
||||
if (actual === null) throw new NotFoundError('Modelo no encontrado')
|
||||
|
||||
const unidades = await prisma.unidad.count({
|
||||
where: { desarrollo: actual.desarrollo.clave, modelo: actual.nombre },
|
||||
})
|
||||
if (unidades > 0) {
|
||||
throw new AppError(409, `El modelo ${actual.nombre} tiene ${unidades} unidades; no se puede eliminar`)
|
||||
}
|
||||
|
||||
await prisma.modeloDesarrollo.delete({ where: { id: modeloId } })
|
||||
}
|
||||
|
||||
// ── Reglas del cotizador ──────────────────────────────────────────────
|
||||
|
||||
export async function guardarReglas(desarrolloId: string, parametros: ParametrosDesarrolloJson, usuarioId: string | null) {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const parametrosJson = JSON.stringify(parametros)
|
||||
const regla = await prisma.reglaCotizadorDesarrollo.upsert({
|
||||
where: { desarrolloId },
|
||||
update: { parametrosJson, updatedPorId: usuarioId },
|
||||
create: { desarrolloId, parametrosJson, updatedPorId: usuarioId },
|
||||
})
|
||||
|
||||
return {
|
||||
desarrolloId: regla.desarrolloId,
|
||||
parametros: JSON.parse(regla.parametrosJson) as ParametrosDesarrolloJson,
|
||||
updatedAt: regla.updatedAt,
|
||||
updatedPorId: regla.updatedPorId,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
interface UnidadNueva {
|
||||
codigo: string
|
||||
modelo: string
|
||||
manzana: string
|
||||
lote: string
|
||||
nivel: string
|
||||
precioLista: number
|
||||
precioAcabados: number
|
||||
supConstruccion: number
|
||||
supExcedente: number
|
||||
supEstacionamiento: number
|
||||
status: string
|
||||
}
|
||||
|
||||
/** Modelo del desarrollo por id (valida que pertenezca al desarrollo) */
|
||||
async function nombreModelo(desarrolloId: string, modeloId: string): Promise<string> {
|
||||
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
|
||||
if (modelo === null || modelo.desarrolloId !== desarrolloId) {
|
||||
throw new BadRequestError('El modelo no pertenece a este desarrollo')
|
||||
}
|
||||
return modelo.nombre
|
||||
}
|
||||
|
||||
/** Correlativo siguiente de `${clave}-n` en el inventario del desarrollo */
|
||||
function correlativoDesdeCodigos(clave: string, codigos: string[]): number {
|
||||
const patron = new RegExp(`^${clave}-(\\d+)$`)
|
||||
let max = 0
|
||||
for (const codigo of codigos) {
|
||||
const m = patron.exec(codigo)
|
||||
if (m === null) continue
|
||||
const n = Number(m[1])
|
||||
if (n > max) max = n
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
export async function crearUnidades(
|
||||
desarrolloId: string,
|
||||
body: CrearUnidadesBody,
|
||||
): Promise<{ creadas: number; unidades: UnidadNueva[] }> {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true, clave: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
const clave = desarrollo.clave
|
||||
|
||||
let filas: UnidadNueva[]
|
||||
|
||||
if (body.generar !== undefined) {
|
||||
const g = body.generar
|
||||
if (g.hasta < g.desde) throw new BadRequestError('El rango es inválido: `hasta` debe ser mayor o igual a `desde`')
|
||||
if (g.hasta - g.desde + 1 > MAX_UNIDADES_POR_ALTA) {
|
||||
throw new BadRequestError(`El rango genera más de ${MAX_UNIDADES_POR_ALTA} unidades`)
|
||||
}
|
||||
const modelo = g.modeloId !== undefined ? await nombreModelo(desarrolloId, g.modeloId) : ''
|
||||
filas = []
|
||||
for (let n = g.desde; n <= g.hasta; n++) {
|
||||
const codigo = g.patron.replaceAll('{clave}', clave).replaceAll('{n}', String(n))
|
||||
filas.push({
|
||||
codigo,
|
||||
modelo,
|
||||
manzana: '',
|
||||
lote: '',
|
||||
nivel: '',
|
||||
precioLista: g.precioLista,
|
||||
precioAcabados: g.precioAcabados ?? 0,
|
||||
supConstruccion: g.supConstruccion ?? 0,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 0,
|
||||
status: 'DISPONIBLE',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const manuales = body.unidades ?? []
|
||||
const existentes = await prisma.unidad.findMany({ where: { desarrollo: clave }, select: { codigo: true } })
|
||||
let correlativo = correlativoDesdeCodigos(clave, existentes.map((u) => u.codigo))
|
||||
filas = []
|
||||
for (const u of manuales) {
|
||||
const modelo = u.modeloId !== undefined ? await nombreModelo(desarrolloId, u.modeloId) : ''
|
||||
// Sin código explícito se arma con manzana/lote o con un correlativo
|
||||
const codigo =
|
||||
u.codigo ??
|
||||
(u.manzana !== undefined && u.manzana !== '' && u.lote !== undefined && u.lote !== ''
|
||||
? `${clave}-${u.manzana}-${u.lote}`
|
||||
: `${clave}-${++correlativo}`)
|
||||
filas.push({
|
||||
codigo,
|
||||
modelo,
|
||||
manzana: u.manzana ?? '',
|
||||
lote: u.lote ?? '',
|
||||
nivel: u.nivel ?? '',
|
||||
precioLista: u.precioLista,
|
||||
precioAcabados: u.precioAcabados ?? 0,
|
||||
supConstruccion: u.supConstruccion ?? 0,
|
||||
supExcedente: u.supExcedente ?? 0,
|
||||
supEstacionamiento: u.supEstacionamiento ?? 0,
|
||||
status: 'DISPONIBLE',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const codigos = filas.map((f) => f.codigo)
|
||||
const repetidosEnLote = [...new Set(codigos.filter((c, i) => codigos.indexOf(c) !== i))]
|
||||
const yaEnBd =
|
||||
codigos.length > 0
|
||||
? (await prisma.unidad.findMany({ where: { codigo: { in: codigos } }, select: { codigo: true } })).map((u) => u.codigo)
|
||||
: []
|
||||
const duplicados = [...new Set([...repetidosEnLote, ...yaEnBd])]
|
||||
if (duplicados.length > 0) {
|
||||
throw new AppError(409, `Códigos de unidad duplicados: ${duplicados.join(', ')}`)
|
||||
}
|
||||
|
||||
const creadas = await prisma.$transaction(filas.map((f) => prisma.unidad.create({ data: { ...f, desarrollo: clave } })))
|
||||
return {
|
||||
creadas: creadas.length,
|
||||
unidades: creadas.map((u) => ({
|
||||
codigo: u.codigo,
|
||||
modelo: u.modelo,
|
||||
manzana: u.manzana,
|
||||
lote: u.lote,
|
||||
nivel: u.nivel,
|
||||
precioLista: u.precioLista,
|
||||
precioAcabados: u.precioAcabados,
|
||||
supConstruccion: u.supConstruccion,
|
||||
supExcedente: u.supExcedente,
|
||||
supEstacionamiento: u.supEstacionamiento,
|
||||
status: u.status,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function eliminarUnidad(desarrolloId: string, codigo: string): Promise<void> {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
|
||||
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
|
||||
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
|
||||
}
|
||||
|
||||
const [cotizaciones, planes, clientes] = await Promise.all([
|
||||
prisma.cotizacion.count({ where: { unidadCodigo: codigo } }),
|
||||
prisma.planPago.count({ where: { unidadCodigo: codigo } }),
|
||||
prisma.cliente.count({ where: { unidadCodigo: codigo } }),
|
||||
])
|
||||
const operacion = cotizaciones + planes + clientes
|
||||
if (operacion > 0) {
|
||||
throw new AppError(409, `La unidad ${codigo} tiene ${operacion} cotizaciones o ventas; no se puede eliminar`)
|
||||
}
|
||||
|
||||
await prisma.unidad.delete({ where: { codigo } })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m²,
|
||||
* ubicación, modelo y estatus de una unidad existente.
|
||||
*/
|
||||
export async function actualizarUnidad(
|
||||
desarrolloId: string,
|
||||
codigo: string,
|
||||
cambios: ActualizarUnidadBody,
|
||||
) {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
|
||||
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
|
||||
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
|
||||
}
|
||||
|
||||
// modeloId → nombre del modelo en la tabla Unidad
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const clave of ['manzana', 'lote', 'nivel', 'precioLista', 'precioAcabados', 'supConstruccion', 'supExcedente', 'supEstacionamiento', 'status'] as const) {
|
||||
const valor = cambios[clave]
|
||||
if (valor !== undefined) data[clave] = valor
|
||||
}
|
||||
if (cambios.modeloId !== undefined) {
|
||||
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: cambios.modeloId }, select: { nombre: true } })
|
||||
if (modelo === null) throw new NotFoundError('Modelo no encontrado')
|
||||
data['modelo'] = modelo.nombre
|
||||
}
|
||||
if (Object.keys(data).length === 0) throw new BadRequestError('No hay cambios que aplicar')
|
||||
|
||||
return prisma.unidad.update({ where: { codigo }, data })
|
||||
}
|
||||
|
||||
// ── Público (micrositio, sin auth) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ficha pública: SOLO información comercial del desarrollo y su inventario
|
||||
* disponible. Nunca clientes, cotizaciones ni datos personales.
|
||||
*/
|
||||
function fichaPublica(
|
||||
desarrollo: {
|
||||
clave: string
|
||||
nombre: string
|
||||
estado: string
|
||||
tipo: string
|
||||
zona: string | null
|
||||
direccion: string | null
|
||||
descripcion: string | null
|
||||
amenidades: string | null
|
||||
imagenUrl: string | null
|
||||
logoUrl: string | null
|
||||
fechaEntregaProgramada: Date | null
|
||||
modelos: { nombre: string; recamaras: number | null; banos: number | null; estacionamientos: number | null; supConstruccion: number | null; activo: boolean }[]
|
||||
},
|
||||
unidades: {
|
||||
codigo: string
|
||||
modelo: string
|
||||
nivel: string
|
||||
precioLista: number
|
||||
precioAcabados: number
|
||||
supConstruccion: number
|
||||
}[],
|
||||
) {
|
||||
const disponiblesPorModelo = new Map<string, { n: number; precioDesde: number | null }>()
|
||||
for (const u of unidades) {
|
||||
const actual = disponiblesPorModelo.get(u.modelo) ?? { n: 0, precioDesde: null }
|
||||
actual.n++
|
||||
if (u.precioLista > 0 && (actual.precioDesde === null || u.precioLista < actual.precioDesde)) {
|
||||
actual.precioDesde = u.precioLista
|
||||
}
|
||||
disponiblesPorModelo.set(u.modelo, actual)
|
||||
}
|
||||
|
||||
return {
|
||||
clave: desarrollo.clave,
|
||||
nombre: desarrollo.nombre,
|
||||
estado: desarrollo.estado,
|
||||
tipo: desarrollo.tipo,
|
||||
zona: desarrollo.zona,
|
||||
direccion: desarrollo.direccion,
|
||||
descripcion: desarrollo.descripcion,
|
||||
amenidades: desarrollo.amenidades,
|
||||
imagenUrl: desarrollo.imagenUrl,
|
||||
logoUrl: desarrollo.logoUrl,
|
||||
fechaEntregaProgramada: desarrollo.fechaEntregaProgramada,
|
||||
modelos: desarrollo.modelos
|
||||
.filter((m) => m.activo)
|
||||
.map((m) => {
|
||||
const disp = disponiblesPorModelo.get(m.nombre) ?? { n: 0, precioDesde: null }
|
||||
return {
|
||||
nombre: m.nombre,
|
||||
recamaras: m.recamaras,
|
||||
banos: m.banos,
|
||||
estacionamientos: m.estacionamientos,
|
||||
supConstruccion: m.supConstruccion,
|
||||
disponibles: disp.n,
|
||||
precioDesde: disp.precioDesde,
|
||||
}
|
||||
}),
|
||||
unidadesDisponibles: unidades.map((u) => ({
|
||||
codigo: u.codigo,
|
||||
modelo: u.modelo,
|
||||
precioLista: u.precioLista,
|
||||
precioAcabados: u.precioAcabados,
|
||||
supConstruccion: u.supConstruccion,
|
||||
...(u.nivel !== '' ? { nivel: u.nivel } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Filtro de publicación: activo en cotizador y no cerrado */
|
||||
const DONDE_PUBLICO = { activoEnCotizador: true, estado: { not: 'CERRADO' } } as const
|
||||
|
||||
/** Inventario publicado de un desarrollo (solo unidades DISPONIBLE) */
|
||||
async function unidadesPublicas(clave: string) {
|
||||
const filas = await prisma.unidad.findMany({
|
||||
where: { desarrollo: clave },
|
||||
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
|
||||
orderBy: { codigo: 'asc' },
|
||||
})
|
||||
return filas.filter((u) => esDisponible(u.status))
|
||||
}
|
||||
|
||||
export async function listarDesarrollosPublicos() {
|
||||
const [desarrollos, unidades] = await Promise.all([
|
||||
prisma.desarrollo.findMany({ where: DONDE_PUBLICO, include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
|
||||
prisma.unidad.findMany({
|
||||
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
|
||||
orderBy: { codigo: 'asc' },
|
||||
}),
|
||||
])
|
||||
return {
|
||||
desarrollos: desarrollos.map((d) =>
|
||||
fichaPublica(
|
||||
d,
|
||||
unidades
|
||||
.filter((u) => normalizarClave(u.desarrollo) === normalizarClave(d.clave) && esDisponible(u.status))
|
||||
.map((u) => ({ codigo: u.codigo, modelo: u.modelo, nivel: u.nivel, precioLista: u.precioLista, precioAcabados: u.precioAcabados, supConstruccion: u.supConstruccion })),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function obtenerDesarrolloPublico(claveCruda: string) {
|
||||
const clave = normalizarClave(claveCruda)
|
||||
const fila = await prisma.desarrollo.findFirst({ where: { clave, ...DONDE_PUBLICO }, include: INCLUDE_FICHA })
|
||||
if (fila === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
const unidades = await unidadesPublicas(fila.clave)
|
||||
return fichaPublica(fila, unidades)
|
||||
}
|
||||
@@ -137,6 +137,8 @@ describe('obtenerScores', () => {
|
||||
where: {
|
||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||
usuarioId: 'u1',
|
||||
// Los cerrados (descartados/perdidos) no reciben score
|
||||
etapa: { notIn: ['Descartado', 'Perdido'] },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,10 @@ import { prisma } from './prisma.js'
|
||||
|
||||
const ETAPA_SCORE: Record<string, number> = {
|
||||
NUEVO: 20,
|
||||
'INTENTO DE CONTACTO': 30,
|
||||
CONTACTADO: 40,
|
||||
INTERESADO: 60,
|
||||
AGENDO: 80,
|
||||
'AGENDO CITA': 80,
|
||||
CALIFICADO: 90,
|
||||
}
|
||||
|
||||
@@ -74,6 +75,8 @@ export function calcularScore(lead: {
|
||||
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
|
||||
const where: Record<string, unknown> = {
|
||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||
// Los cerrados (descartados/perdidos) no reciben score ni salen en el ranking
|
||||
etapa: { notIn: ['Descartado', 'Perdido'] },
|
||||
}
|
||||
if (usuarioId) {
|
||||
where['usuarioId'] = usuarioId
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* resuelve por el usuarioId de cada fila.
|
||||
*/
|
||||
|
||||
import { camposAfluenciaFaltantes } from '../config/vocabulario.js'
|
||||
|
||||
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
||||
|
||||
@@ -73,6 +75,8 @@ export interface LeadMiDia {
|
||||
/** Vendedor asignado (nombre); '' si no tiene */
|
||||
vendedor: string
|
||||
updatedAt: string
|
||||
/** Fecha de alta (ISO) */
|
||||
creadoEn: string
|
||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
/** Último cambio conocido; null si no hay */
|
||||
@@ -85,6 +89,8 @@ export interface ActividadMiDia {
|
||||
tipo: string
|
||||
fecha: string
|
||||
estado: string
|
||||
/** Captura (lead/oportunidad/apartado) a la que pertenece la actividad; null si no está ligada */
|
||||
leadCapturaId: string | null
|
||||
}
|
||||
|
||||
export interface ApartadoPorVencer {
|
||||
@@ -127,10 +133,14 @@ export interface OportunidadMiDia {
|
||||
modeloInteres: string
|
||||
unidadCodigo: string
|
||||
updatedAt: string
|
||||
/** Fecha de alta (ISO) */
|
||||
creadoEn: string
|
||||
/** Último cambio conocido; null si no hay */
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
||||
fechaCita?: string | undefined
|
||||
/** ¿Afluencia con perfil completo? Sin esto no puede avanzar a 'Aparto' */
|
||||
afluenciaCompleta: boolean
|
||||
}
|
||||
|
||||
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
|
||||
@@ -146,10 +156,32 @@ export interface ApartadoMiDia {
|
||||
unidadCodigo: string
|
||||
estado: string
|
||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||
/** Fecha de alta (ISO) */
|
||||
creadoEn: string
|
||||
/** Último cambio conocido; null si no hay */
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Registro descartado/abandonado (tab "Descartados" de Mi día, solo admin):
|
||||
* leads descartados, oportunidades perdidas/abandonadas y apartados
|
||||
* cancelados. Reasignable a cualquier vendedor.
|
||||
*/
|
||||
export interface DescartadoMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
/** De dónde viene: lead | oportunidad | apartado */
|
||||
fuente: 'lead' | 'oportunidad' | 'apartado'
|
||||
etapa: string
|
||||
motivo: string
|
||||
/** Vendedor asignado (nombre); '' si no tiene */
|
||||
vendedor: string
|
||||
/** Fecha de alta (ISO) */
|
||||
creadoEn: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ResumenMiDia {
|
||||
generadoEn: string
|
||||
misLeads: LeadMiDia[]
|
||||
@@ -165,6 +197,8 @@ export interface ResumenMiDia {
|
||||
misApartados: ApartadoMiDia[]
|
||||
/** Todos los apartados incluyendo terminales (para Escrituración) */
|
||||
todosApartados: ApartadoMiDia[]
|
||||
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
|
||||
descartados: DescartadoMiDia[]
|
||||
conteos: {
|
||||
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
||||
actividadesAbiertas: number // mías, Estado Abierto
|
||||
@@ -206,6 +240,8 @@ export interface FilaLocalMiDia {
|
||||
usuarioId: string
|
||||
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
|
||||
mondayItemId: string | null
|
||||
/** Motivo de descarte/cancelación (columna motivo de LeadCaptura) */
|
||||
motivo: string | null
|
||||
pendienteSync: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -296,6 +332,7 @@ export function resumenMiDiaLocal(
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
creadoEn: c.createdAt.toISOString(),
|
||||
fuente: 'lead' as const,
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
pendienteSync: c.pendienteSync,
|
||||
@@ -330,12 +367,15 @@ export function resumenMiDiaLocal(
|
||||
email: c.email ?? '',
|
||||
etapa: c.etapa,
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
origen: c.origen ?? '',
|
||||
modeloInteres: c.modelo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
unidadCodigo,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
creadoEn: c.createdAt.toISOString(),
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
fechaCita,
|
||||
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
|
||||
pendienteSync: c.pendienteSync,
|
||||
}
|
||||
})
|
||||
@@ -360,6 +400,7 @@ export function resumenMiDiaLocal(
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
creadoEn: c.createdAt.toISOString(),
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
@@ -381,6 +422,7 @@ export function resumenMiDiaLocal(
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
creadoEn: c.createdAt.toISOString(),
|
||||
ultimoCambio: null,
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
@@ -449,6 +491,7 @@ export function resumenMiDiaLocal(
|
||||
tipo: a.tipo,
|
||||
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
|
||||
estado: a.estado,
|
||||
leadCapturaId: a.leadCapturaId,
|
||||
}))
|
||||
const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas
|
||||
const misActividadesHoy: ActividadMiDia[] = actividadesAbiertasOrdenadas
|
||||
@@ -529,6 +572,28 @@ export function resumenMiDiaLocal(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Descartados/abandonados (tab "Descartados", solo admin) ---
|
||||
// Leads DESCARTADO/ABANDONO, oportunidades PERDIDO/ABANDONO y apartados
|
||||
// cancelados. Reasignables a cualquier vendedor desde la UI.
|
||||
const esDescartado = (etapa: string): boolean => {
|
||||
const e = etapa.trim().toUpperCase()
|
||||
return e.includes('DESCART') || e.includes('PERDID') || e.includes('ABANDONO') || e.includes('CANCEL')
|
||||
}
|
||||
const descartados: DescartadoMiDia[] = capturas
|
||||
.filter((c) => esDescartado(c.etapa))
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
fuente: c.board === 'LEADS' ? ('lead' as const) : c.board === 'OPORTUNIDADES' ? ('oportunidad' as const) : ('apartado' as const),
|
||||
etapa: c.etapa,
|
||||
motivo: c.motivo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
creadoEn: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
|
||||
return {
|
||||
generadoEn: ahora.toISOString(),
|
||||
misLeads,
|
||||
@@ -541,6 +606,7 @@ export function resumenMiDiaLocal(
|
||||
misApartados,
|
||||
// Todos los apartados incluyendo terminales (para Escrituración)
|
||||
todosApartados: todosApartadosLocal,
|
||||
descartados,
|
||||
conteos: {
|
||||
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
|
||||
(c) => c.createdAt.getTime() >= hace7DiasMs,
|
||||
|
||||
@@ -8,6 +8,10 @@ const prismaMock = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
actividadLocal: {
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
@@ -55,6 +59,17 @@ describe('RecordatorioVendedorService', () => {
|
||||
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
||||
},
|
||||
})
|
||||
// La tarea también se agenda en el calendario del vendedor
|
||||
expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
titulo: 'Llamar a Juan',
|
||||
descripcion: 'Sobre cotización',
|
||||
usuarioId,
|
||||
leadCapturaId: null,
|
||||
fechaVencimiento: new Date('2026-09-10T10:00:00'),
|
||||
tipo: 'TAREA',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,13 +102,17 @@ describe('RecordatorioVendedorService', () => {
|
||||
})
|
||||
|
||||
describe('completar', () => {
|
||||
it('marca como completado un recordatorio existente', async () => {
|
||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
|
||||
it('marca como completado un recordatorio existente y cierra su tarea del calendario', async () => {
|
||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1', leadId: 'loc_1', usuarioId })
|
||||
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
|
||||
|
||||
const result = await svc.completar('r1')
|
||||
|
||||
expect(result.completado).toBe(true)
|
||||
expect(prismaMock.actividadLocal.updateMany).toHaveBeenCalledWith({
|
||||
where: { leadCapturaId: 'loc_1', usuarioId, tipo: 'TAREA', estado: 'ABIERTA' },
|
||||
data: { estado: 'CERRADA', fechaCierre: expect.any(Date) },
|
||||
})
|
||||
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: { completado: true },
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface OpcionesListarRecordatorio {
|
||||
|
||||
export class RecordatorioVendedorService {
|
||||
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
|
||||
return prisma.recordatorioVendedor.create({
|
||||
const recordatorio = await prisma.recordatorioVendedor.create({
|
||||
data: {
|
||||
usuarioId,
|
||||
titulo: data.titulo.trim(),
|
||||
@@ -41,6 +41,20 @@ export class RecordatorioVendedorService {
|
||||
fechaRecordar: new Date(data.fechaRecordar),
|
||||
},
|
||||
})
|
||||
// La tarea también vive en el calendario del vendedor: se crea la
|
||||
// ActividadLocal ligada al cliente para que aparezca en "Mis actividades"
|
||||
// y en el calendario de Mi día.
|
||||
await prisma.actividadLocal.create({
|
||||
data: {
|
||||
titulo: recordatorio.titulo,
|
||||
descripcion: recordatorio.descripcion,
|
||||
usuarioId,
|
||||
leadCapturaId: recordatorio.leadId,
|
||||
fechaVencimiento: recordatorio.fechaRecordar,
|
||||
tipo: 'TAREA',
|
||||
},
|
||||
})
|
||||
return recordatorio
|
||||
}
|
||||
|
||||
async listar(usuarioId: string, opciones: OpcionesListarRecordatorio = {}) {
|
||||
@@ -67,9 +81,39 @@ export class RecordatorioVendedorService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notas de la bitácora de un cliente: visibles para TODOS los usuarios
|
||||
* (así lo pidió operación: las notas hechas en la bitácora las debe ver
|
||||
* cualquier usuario que abra el cliente). Filtra por lead y/o
|
||||
* clienteClave, sin restricción de usuario.
|
||||
*/
|
||||
async listarPorCliente(filtro: { leadId?: string | null; clienteClave?: string | null }) {
|
||||
const or: Record<string, unknown>[] = []
|
||||
if (filtro.leadId) or.push({ leadId: filtro.leadId })
|
||||
if (filtro.clienteClave) or.push({ clienteClave: filtro.clienteClave })
|
||||
if (or.length === 0) return []
|
||||
return prisma.recordatorioVendedor.findMany({
|
||||
where: { OR: or },
|
||||
orderBy: { fechaRecordar: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
async completar(id: string) {
|
||||
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
||||
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
||||
// Cierra también la tarea del calendario (ActividadLocal abierta ligada al
|
||||
// mismo cliente y vendedor) para que desaparezca de "Mis actividades".
|
||||
if (existente.leadId !== null) {
|
||||
await prisma.actividadLocal.updateMany({
|
||||
where: {
|
||||
leadCapturaId: existente.leadId,
|
||||
usuarioId: existente.usuarioId,
|
||||
tipo: 'TAREA',
|
||||
estado: 'ABIERTA',
|
||||
},
|
||||
data: { estado: 'CERRADA', fechaCierre: new Date() },
|
||||
})
|
||||
}
|
||||
return prisma.recordatorioVendedor.update({
|
||||
where: { id },
|
||||
data: { completado: true },
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
// La pasada consulta un admin de respaldo para los planes sin vendedor
|
||||
// (antes caían a 'sin-destinatario' y nadie los veía).
|
||||
const prismaMock = vi.hoisted(() => ({ usuario: { findFirst: vi.fn() } }))
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import type { Pago, PlanPago } from './cobranza.service.js'
|
||||
import {
|
||||
RecordatoriosCobranzaService,
|
||||
@@ -231,7 +238,29 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
|
||||
expect(log).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sin destinatario (plan sin vendedor): solo registra en el log, sin canal', async () => {
|
||||
it('sin destinatario (plan sin vendedor): el recordatorio cae al administrador de respaldo', async () => {
|
||||
prismaMock.usuario.findFirst.mockResolvedValue({ id: 'admin-qa' })
|
||||
const planesSinVendedor = [
|
||||
plan({
|
||||
id: 'p1',
|
||||
vendedorId: null,
|
||||
pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })],
|
||||
}),
|
||||
]
|
||||
const { store, log } = storeFake(planesSinVendedor)
|
||||
const { canal, envios } = canalFake()
|
||||
const svc = new RecordatoriosCobranzaService(store, [canal])
|
||||
|
||||
const r = await svc.ejecutarPasada({ ahora: HOY })
|
||||
expect(r.enviados).toHaveLength(1)
|
||||
expect(r.enviados[0]).toMatchObject({ canal: 'fake', destinatario: 'admin-qa' })
|
||||
expect(envios).toHaveLength(1)
|
||||
expect(envios[0].destinatario).toMatchObject({ vendedorId: 'admin-qa', origen: 'admin-fallback' })
|
||||
expect(log).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sin destinatario y SIN admin en el sistema: solo registra en el log, sin canal', async () => {
|
||||
prismaMock.usuario.findFirst.mockResolvedValue(null)
|
||||
const planesSinVendedor = [
|
||||
plan({
|
||||
id: 'p1',
|
||||
|
||||
@@ -130,10 +130,11 @@ export function debeEnviar(
|
||||
export interface DestinatarioRecordatorio {
|
||||
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
|
||||
vendedorId: string | null
|
||||
origen: 'vendedor' | 'ninguno'
|
||||
origen: 'vendedor' | 'ninguno' | 'admin-fallback'
|
||||
}
|
||||
|
||||
/** Regla pura de selección: vendedor del plan → nadie */
|
||||
/** Regla pura de selección: vendedor del plan → nadie (el fallback a admin se
|
||||
* resuelve en la pasada, que tiene acceso a la BD). */
|
||||
export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio {
|
||||
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
|
||||
return { vendedorId: null, origen: 'ninguno' }
|
||||
@@ -218,9 +219,21 @@ export class RecordatoriosCobranzaService {
|
||||
const planes = await this.store.cargarPlanes()
|
||||
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
|
||||
|
||||
// Planes sin vendedor asignado (p. ej. cartera importada sin asesor): el
|
||||
// recordatorio cae al administrador para que no se pierda y pueda
|
||||
// reasignar el plan. Antes quedaban en 'sin-destinatario' y nadie los veía.
|
||||
const adminFallback = await prisma.usuario.findFirst({
|
||||
where: { rol: 'admin', activo: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
|
||||
for (const c of candidatos) {
|
||||
const destinatario = elegirDestinatario(c.vendedorId)
|
||||
let destinatario = elegirDestinatario(c.vendedorId)
|
||||
if (destinatario.origen === 'ninguno' && adminFallback) {
|
||||
destinatario = { vendedorId: adminFallback.id, origen: 'admin-fallback' }
|
||||
}
|
||||
|
||||
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
|
||||
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
|
||||
|
||||
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal file
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado (los tests no tocan BD) ----------
|
||||
// `reglaCotizadorDesarrollo` y `desarrollo` se activan/desactivan por test:
|
||||
// simulan el cliente Prisma con los modelos generados o sin ellos (lectura
|
||||
// cruda de las tablas).
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
reglaCotizadorDesarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
|
||||
desarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { DESARROLLOS, type ParametrosDesarrollo } from './cotizacion.service.js'
|
||||
import {
|
||||
getReglasDesarrollo,
|
||||
getReglasEfectivas,
|
||||
getReglasGuardadas,
|
||||
normalizarReglas,
|
||||
} from './reglas-desarrollo.service.js'
|
||||
|
||||
/** Reglas con la forma exacta que guarda la BD (JSON serializado) */
|
||||
function reglasJson(params: ParametrosDesarrollo, extras: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
...params,
|
||||
...extras,
|
||||
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
|
||||
})
|
||||
}
|
||||
|
||||
function usarModeloPrisma(): ReturnType<typeof vi.fn> {
|
||||
const findMany = vi.fn()
|
||||
prismaMock.reglaCotizadorDesarrollo = { findMany }
|
||||
return findMany
|
||||
}
|
||||
|
||||
/** Ficha del desarrollo (tabla Desarrollo) para el catálogo del servicio */
|
||||
function ficha(id: string, clave: string, extras: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
clave,
|
||||
nombre: clave.charAt(0) + clave.slice(1).toLowerCase(),
|
||||
activoEnCotizador: true,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
function usarCatalogo(filas: unknown[]): void {
|
||||
prismaMock.desarrollo = { findMany: vi.fn().mockResolvedValue(filas) }
|
||||
}
|
||||
|
||||
/** Propuesta del fixture (JSON crudo) por tipo */
|
||||
function brinda(propuestas: Record<string, unknown>[], tipo: string): Record<string, unknown> {
|
||||
const p = propuestas.find((x) => x['tipo'] === tipo)
|
||||
if (!p) throw new Error(`La propuesta ${tipo} no está en el fixture`)
|
||||
return p
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.reglaCotizadorDesarrollo = undefined
|
||||
prismaMock.desarrollo = undefined
|
||||
prismaMock.$queryRawUnsafe.mockReset()
|
||||
prismaMock.$queryRawUnsafe.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('normalizarReglas (JSON de la BD)', () => {
|
||||
it('acepta el JSON con la forma de las constantes y conserva los valores', () => {
|
||||
const reglas = normalizarReglas('DIAGONAL', JSON.parse(reglasJson(DESARROLLOS['DIAGONAL']!)))
|
||||
expect(reglas).not.toBeNull()
|
||||
expect(reglas?.id).toBe('DIAGONAL')
|
||||
expect(reglas?.propuestas).toHaveLength(4)
|
||||
expect(reglas?.propuestas[2]?.descuentoDefault).toBe(0.02)
|
||||
})
|
||||
|
||||
it('marca la propuesta desactivada por el admin (activa/activo)', () => {
|
||||
const bruto = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as {
|
||||
propuestas: Record<string, unknown>[]
|
||||
}
|
||||
brinda(bruto.propuestas, 'CONTADO').activa = false
|
||||
const reglas = normalizarReglas('SENDA', bruto)
|
||||
expect(reglas?.propuestas.find((p) => p.tipo === 'CONTADO')?.activa).toBe(false)
|
||||
})
|
||||
|
||||
it('rechaza JSON sin la forma esperada (precioUnico, propuestas, claves)', () => {
|
||||
expect(normalizarReglas('DIAGONAL', null)).toBeNull()
|
||||
expect(normalizarReglas('DIAGONAL', 'DIAGONAL')).toBeNull()
|
||||
expect(normalizarReglas('DIAGONAL', { precioUnico: true })).toBeNull()
|
||||
expect(
|
||||
normalizarReglas('DIAGONAL', {
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fijo',
|
||||
propuestas: [{ tipo: 'CONTADO' }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReglasGuardadas / getReglasEfectivas', () => {
|
||||
it('sin filas guardadas devuelve las constantes actuales (compatibilidad)', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([])
|
||||
|
||||
const efectivas = await getReglasEfectivas()
|
||||
|
||||
expect(Object.keys(efectivas).sort()).toEqual(Object.keys(DESARROLLOS).sort())
|
||||
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
|
||||
expect(efectivas[id]).toEqual(constantes)
|
||||
}
|
||||
expect(await getReglasDesarrollo('SENDA')).toEqual(DESARROLLOS['SENDA'])
|
||||
})
|
||||
|
||||
it('las reglas de la BD ganan a las constantes del desarrollo', async () => {
|
||||
const conDescuento = {
|
||||
...DESARROLLOS['DIAGONAL']!,
|
||||
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
|
||||
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.07 } : p,
|
||||
),
|
||||
}
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: reglasJson(conDescuento) },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
const contado = reglas.propuestas.find((p) => p.tipo === 'CONTADO')
|
||||
expect(contado?.descuentoDefault).toBe(0.07)
|
||||
expect(DESARROLLOS['DIAGONAL']!.propuestas[2]?.descuentoDefault).toBe(0.02)
|
||||
|
||||
// Los demás desarrollos siguen en constantes
|
||||
expect((await getReglasDesarrollo('SOLANA')).propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
|
||||
it('usa la columna activoEnCotizador y también el campo dentro del JSON', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'SOLANA',
|
||||
parametrosJson: reglasJson(DESARROLLOS['SOLANA']!),
|
||||
activoEnCotizador: false,
|
||||
},
|
||||
{
|
||||
desarrolloId: 'ESTANZA',
|
||||
parametrosJson: reglasJson(DESARROLLOS['ESTANZA']!, { activoEnCotizador: false }),
|
||||
},
|
||||
])
|
||||
|
||||
expect((await getReglasDesarrollo('SOLANA')).activoEnCotizador).toBe(false)
|
||||
expect((await getReglasDesarrollo('ESTANZA')).activoEnCotizador).toBe(false)
|
||||
expect((await getReglasDesarrollo('DIAGONAL')).activoEnCotizador).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resuelve la clave del desarrollo por su id (las reglas apuntan al uuid) y lee su ficha', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'uuid-diagonal',
|
||||
parametrosJson: reglasJson({ ...DESARROLLOS['DIAGONAL']!, nombre: undefined as never }),
|
||||
},
|
||||
])
|
||||
usarCatalogo([
|
||||
ficha('uuid-diagonal', 'DIAGONAL', { activoEnCotizador: false, nombre: 'Diagonal Residencial' }),
|
||||
ficha('uuid-senda', 'SENDA'),
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
// La clave sale del catálogo (id → clave), no del uuid
|
||||
expect(reglas.propuestas).toHaveLength(4)
|
||||
// El nombre y la bandera salen de la ficha del desarrollo
|
||||
expect(reglas.nombre).toBe('Diagonal Residencial')
|
||||
expect(reglas.activoEnCotizador).toBe(false)
|
||||
|
||||
// Las reglas efectivas y el desarrollo activo siguen saliendo de constantes
|
||||
const efectivas = await getReglasEfectivas()
|
||||
expect(efectivas['SENDA']).toEqual(DESARROLLOS['SENDA'])
|
||||
expect(efectivas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
|
||||
it('ignora filas sin desarrollo identificable o sin parámetros', async () => {
|
||||
const sinClave = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as Record<string, unknown>
|
||||
delete sinClave['id']
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: '', parametrosJson: JSON.stringify(sinClave) },
|
||||
{ desarrolloId: 'SENDA', parametrosJson: null },
|
||||
null,
|
||||
])
|
||||
|
||||
expect((await getReglasGuardadas()).size).toBe(0)
|
||||
})
|
||||
|
||||
it('lee la tabla por SQL crudo cuando el cliente Prisma no tiene el modelo', async () => {
|
||||
prismaMock.$queryRawUnsafe.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes('"ReglaCotizadorDesarrollo"')) {
|
||||
return [
|
||||
{ desarrolloId: 'uuid-senda', parametrosJson: reglasJson(DESARROLLOS['SENDA']!) },
|
||||
{ desarrollo: 'SOLANA', parametros: reglasJson(DESARROLLOS['SOLANA']!) },
|
||||
]
|
||||
}
|
||||
return [ficha('uuid-senda', 'SENDA'), ficha('uuid-solana', 'SOLANA')]
|
||||
})
|
||||
|
||||
const reglas = await getReglasEfectivas()
|
||||
|
||||
expect(prismaMock.$queryRawUnsafe).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"ReglaCotizadorDesarrollo"'),
|
||||
)
|
||||
expect(reglas['SENDA']?.propuestas).toHaveLength(4)
|
||||
expect(reglas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('respaldo en constantes (nunca falla la cotización)', () => {
|
||||
it('JSON corrupto: respaldo en las constantes con warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: '{"propuestas": [' },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('JSON válido pero con forma inesperada: respaldo en las constantes con warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: JSON.stringify({ precioUnico: true }) },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('forma inesperada'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('tabla inexistente: respaldo en las constantes, sin lanzar y con un solo warning', async () => {
|
||||
vi.resetModules()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
prismaMock.$queryRawUnsafe.mockRejectedValue(new Error('no such table: ReglaCotizadorDesarrollo'))
|
||||
const servicio = await import('./reglas-desarrollo.service.js')
|
||||
|
||||
const reglas = await servicio.getReglasDesarrollo('SENDA')
|
||||
const efectivas = await servicio.getReglasEfectivas()
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['SENDA'])
|
||||
expect(Object.keys(efectivas)).toHaveLength(Object.keys(DESARROLLOS).length)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('constantes'), expect.any(Error))
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
417
api-gateway/src/services/reglas-desarrollo.service.ts
Normal file
417
api-gateway/src/services/reglas-desarrollo.service.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Reglas comerciales del cotizador por desarrollo.
|
||||
*
|
||||
* Las reglas las configura el admin y viven en la tabla `ReglaCotizadorDesarrollo`
|
||||
* (una fila por desarrollo, con el JSON de parámetros). Este servicio las lee y
|
||||
* las valida; **nunca** puede tumbar una cotización: si el desarrollo no tiene
|
||||
* reglas guardadas, la tabla no existe todavía o el JSON viene corrupto, se cae
|
||||
* a las constantes de `cotizacion.service.ts` (los valores históricos) y se
|
||||
* registra un warning.
|
||||
*/
|
||||
import { prisma } from './prisma.js'
|
||||
import {
|
||||
DESARROLLOS,
|
||||
type BasePrecio,
|
||||
type DescuentosPropuestas,
|
||||
type ModoMensualidades,
|
||||
type ParametrosDesarrollo,
|
||||
type PropuestaConfig,
|
||||
type TipoPropuesta,
|
||||
} from './cotizacion.service.js'
|
||||
|
||||
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
|
||||
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
|
||||
|
||||
const DESARROLLO_DEFAULT = 'DIAGONAL'
|
||||
|
||||
const TIPOS: ReadonlyArray<TipoPropuesta> = [
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'CONTADO',
|
||||
'SEMI_CONTADO',
|
||||
]
|
||||
|
||||
const CLAVES_DESCUENTO: ReadonlyArray<keyof DescuentosPropuestas> = [
|
||||
'creditoBancario',
|
||||
'creditoInfonavit',
|
||||
'contado',
|
||||
'semiContado',
|
||||
]
|
||||
|
||||
const BASES: ReadonlyArray<BasePrecio> = ['lista', 'final']
|
||||
|
||||
const MODOS: ReadonlyArray<ModoMensualidades> = [
|
||||
'ninguna',
|
||||
'remanente',
|
||||
'remanenteLista',
|
||||
'porcentaje',
|
||||
]
|
||||
|
||||
function esTexto(valor: unknown): valor is string {
|
||||
return typeof valor === 'string' && valor.trim() !== ''
|
||||
}
|
||||
|
||||
function esNumero(valor: unknown): valor is number {
|
||||
return typeof valor === 'number' && Number.isFinite(valor)
|
||||
}
|
||||
|
||||
function esBase(valor: unknown): valor is BasePrecio {
|
||||
return typeof valor === 'string' && (BASES as ReadonlyArray<string>).includes(valor)
|
||||
}
|
||||
|
||||
function esModo(valor: unknown): valor is ModoMensualidades {
|
||||
return typeof valor === 'string' && (MODOS as ReadonlyArray<string>).includes(valor)
|
||||
}
|
||||
|
||||
/** Normaliza una propuesta del JSON de la BD; null si está corrupta */
|
||||
function normalizarPropuesta(bruto: unknown): PropuestaConfig | null {
|
||||
if (typeof bruto !== 'object' || bruto === null) return null
|
||||
const p = bruto as Record<string, unknown>
|
||||
const tipo = p['tipo']
|
||||
const clave = p['claveDescuento']
|
||||
if (typeof tipo !== 'string' || !(TIPOS as ReadonlyArray<string>).includes(tipo)) return null
|
||||
if (typeof clave !== 'string' || !(CLAVES_DESCUENTO as ReadonlyArray<string>).includes(clave))
|
||||
return null
|
||||
if (!esTexto(p['titulo'])) return null
|
||||
if (!esNumero(p['descuentoDefault']) || !esNumero(p['pctEnganche']) || !esNumero(p['pctPagoFinal']))
|
||||
return null
|
||||
if (!esBase(p['engancheBase']) || !esBase(p['pagoFinalBase'])) return null
|
||||
if (!esModo(p['modoMensualidades'])) return null
|
||||
|
||||
const propuesta: PropuestaConfig = {
|
||||
tipo: tipo as TipoPropuesta,
|
||||
claveDescuento: clave as keyof DescuentosPropuestas,
|
||||
titulo: p['titulo'].trim(),
|
||||
descuentoDefault: p['descuentoDefault'],
|
||||
pctEnganche: p['pctEnganche'],
|
||||
engancheBase: p['engancheBase'],
|
||||
pctPagoFinal: p['pctPagoFinal'],
|
||||
pagoFinalBase: p['pagoFinalBase'],
|
||||
modoMensualidades: p['modoMensualidades'],
|
||||
}
|
||||
if (p['engancheCeroSinApartado'] === true) propuesta.engancheCeroSinApartado = true
|
||||
const activa = p['activa'] ?? p['activo']
|
||||
if (typeof activa === 'boolean') propuesta.activa = activa
|
||||
return propuesta
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida y normaliza el JSON de reglas de un desarrollo. Devuelve null (para
|
||||
* que el llamador caiga a las constantes) si el JSON no tiene la forma esperada.
|
||||
*/
|
||||
export function normalizarReglas(
|
||||
desarrolloId: string,
|
||||
bruto: unknown,
|
||||
): ParametrosDesarrollo | null {
|
||||
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
|
||||
const r = bruto as Record<string, unknown>
|
||||
|
||||
if (typeof r['precioUnico'] !== 'boolean') return null
|
||||
const fuente = r['fuenteDeN']
|
||||
if (fuente !== 'fechaEntrega' && fuente !== 'fijo' && fuente !== 'porUnidad') return null
|
||||
|
||||
const propuestasBrutas = r['propuestas']
|
||||
if (!Array.isArray(propuestasBrutas) || propuestasBrutas.length === 0) return null
|
||||
const propuestas: PropuestaConfig[] = []
|
||||
for (const propuestaBruta of propuestasBrutas) {
|
||||
const propuesta = normalizarPropuesta(propuestaBruta)
|
||||
if (!propuesta) return null // una propuesta corrupta invalida el JSON completo
|
||||
propuestas.push(propuesta)
|
||||
}
|
||||
|
||||
const id = desarrolloId.trim().toUpperCase()
|
||||
const params: ParametrosDesarrollo = {
|
||||
id,
|
||||
precioUnico: r['precioUnico'],
|
||||
fuenteDeN: fuente,
|
||||
propuestas,
|
||||
}
|
||||
if (esTexto(r['nombre'])) params.nombre = r['nombre'].trim()
|
||||
if (esNumero(r['nFijo'])) params.nFijo = r['nFijo']
|
||||
if (esNumero(r['nDefault'])) params.nDefault = r['nDefault']
|
||||
if (r['cuotaMantenimiento'] === null) params.cuotaMantenimiento = null
|
||||
else if (esNumero(r['cuotaMantenimiento'])) params.cuotaMantenimiento = r['cuotaMantenimiento']
|
||||
if (esTexto(r['prefijoUnidadPdf'])) params.prefijoUnidadPdf = r['prefijoUnidadPdf'].trim()
|
||||
const activo = r['activoEnCotizador'] ?? r['activo']
|
||||
if (typeof activo === 'boolean') params.activoEnCotizador = activo
|
||||
return params
|
||||
}
|
||||
|
||||
/** Fila de reglas ya mapeada a nombres de campo conocidos */
|
||||
interface FilaReglas {
|
||||
desarrolloId: string
|
||||
parametros: unknown
|
||||
activoEnCotizador: boolean | undefined
|
||||
}
|
||||
|
||||
/** Ficha mínima del desarrollo (tabla `Desarrollo`): la clave y sus banderas */
|
||||
interface FichaDesarrollo {
|
||||
clave: string
|
||||
activoEnCotizador: boolean | undefined
|
||||
nombre: string | undefined
|
||||
prefijoUnidadPdf: string | undefined
|
||||
}
|
||||
|
||||
interface CatalogoDesarrollos {
|
||||
porClave: Map<string, FichaDesarrollo>
|
||||
/** Desarrollo.id → clave ('DIAGONAL', 'SENDA', …) */
|
||||
clavePorId: Map<string, string>
|
||||
}
|
||||
|
||||
/** Primer valor presente de `claves` en la fila que cumpla el filtro */
|
||||
function primerValor(
|
||||
fila: Record<string, unknown>,
|
||||
claves: ReadonlyArray<string>,
|
||||
acepta: (valor: unknown) => boolean,
|
||||
): unknown {
|
||||
for (const clave of claves) {
|
||||
const valor = fila[clave]
|
||||
if (valor !== undefined && valor !== null && acepta(valor)) return valor
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Booleanos de SQLite crudo: 1/0 además de true/false */
|
||||
function esBandera(valor: unknown): valor is boolean | 0 | 1 {
|
||||
return typeof valor === 'boolean' || valor === 0 || valor === 1
|
||||
}
|
||||
|
||||
function aBandera(valor: unknown): boolean | undefined {
|
||||
if (valor === true || valor === 1) return true
|
||||
if (valor === false || valor === 0) return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapea una fila cruda (Prisma o SQL) a `FilaReglas`. Los nombres de columna se
|
||||
* resuelven por heurística para tolerar cambios del modelo mientras otro agente
|
||||
* construye el módulo de administración: `desarrolloId`/`desarrollo` y
|
||||
* `parametrosJson`/`parametros`/`reglas`.
|
||||
*/
|
||||
function mapearFila(fila: unknown): FilaReglas | null {
|
||||
if (typeof fila !== 'object' || fila === null) return null
|
||||
const f = fila as Record<string, unknown>
|
||||
const desarrollo = primerValor(
|
||||
f,
|
||||
['desarrolloId', 'desarrollo', 'desarrollo_id', 'proyecto', 'id'],
|
||||
esTexto,
|
||||
)
|
||||
const parametros = primerValor(
|
||||
f,
|
||||
['parametrosJson', 'parametros_json', 'parametros', 'reglas'],
|
||||
(v) => typeof v === 'string' || typeof v === 'object',
|
||||
)
|
||||
if (typeof desarrollo !== 'string') return null
|
||||
const activo = primerValor(f, ['activoEnCotizador', 'activo'], esBandera)
|
||||
return {
|
||||
desarrolloId: desarrollo,
|
||||
parametros,
|
||||
activoEnCotizador: aBandera(activo),
|
||||
}
|
||||
}
|
||||
|
||||
/** Cliente Prisma con el modelo de reglas, si ya está generado */
|
||||
function modeloReglas(): { findMany: (args?: unknown) => Promise<unknown[]> } | null {
|
||||
const cliente = prisma as unknown as Record<string, unknown>
|
||||
for (const nombre of ['reglaCotizadorDesarrollo', 'reglaCotizador', 'reglasCotizador']) {
|
||||
const modelo = cliente[nombre] as { findMany?: unknown } | undefined
|
||||
if (modelo && typeof modelo.findMany === 'function') {
|
||||
return modelo as { findMany: (args?: unknown) => Promise<unknown[]> }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Filas de reglas guardadas. Primero intenta el modelo de Prisma; si el cliente
|
||||
* todavía no lo tiene generado, lee la tabla por SQL crudo. Cualquier fallo se
|
||||
* traduce en "no hay reglas guardadas" (nunca en un error de cotización).
|
||||
*/
|
||||
async function leerFilasReglas(): Promise<FilaReglas[]> {
|
||||
const modelo = modeloReglas()
|
||||
if (modelo) {
|
||||
const filas = await modelo.findMany()
|
||||
return filas.map(mapearFila).filter((f): f is FilaReglas => f !== null)
|
||||
}
|
||||
const crudo = (prisma as unknown as { $queryRawUnsafe?: unknown }).$queryRawUnsafe
|
||||
if (typeof crudo !== 'function') return []
|
||||
const filas = await prisma.$queryRawUnsafe<unknown[]>(
|
||||
'SELECT * FROM "ReglaCotizadorDesarrollo"',
|
||||
)
|
||||
return (Array.isArray(filas) ? filas : []).map(mapearFila).filter((f): f is FilaReglas => f !== null)
|
||||
}
|
||||
|
||||
let avisoCatalogoEmitido = false
|
||||
let avisoLecturaEmitido = false
|
||||
|
||||
/**
|
||||
* Catálogo de desarrollos (tabla `Desarrollo`): de ahí sale la clave real de
|
||||
* cada fila de reglas (las reglas apuntan al id del desarrollo) y la bandera
|
||||
* `activoEnCotizador`. Tolerante: si la tabla no existe todavía, el catálogo
|
||||
* queda vacío y las reglas se resuelven por la clave que traiga el JSON.
|
||||
*/
|
||||
async function leerCatalogo(): Promise<CatalogoDesarrollos> {
|
||||
const porClave = new Map<string, FichaDesarrollo>()
|
||||
const clavePorId = new Map<string, string>()
|
||||
let leidas: unknown[]
|
||||
try {
|
||||
const modelo = (prisma as unknown as Record<string, unknown>)['desarrollo'] as
|
||||
| { findMany?: unknown }
|
||||
| undefined
|
||||
if (modelo && typeof modelo.findMany === 'function') {
|
||||
leidas = await (modelo.findMany as (args?: unknown) => Promise<unknown[]>)({
|
||||
select: { id: true, clave: true, nombre: true, activoEnCotizador: true, prefijoUnidadPdf: true },
|
||||
})
|
||||
} else if (typeof prisma.$queryRawUnsafe === 'function') {
|
||||
leidas = await prisma.$queryRawUnsafe<unknown[]>('SELECT * FROM "Desarrollo"')
|
||||
} else {
|
||||
return { porClave, clavePorId }
|
||||
}
|
||||
} catch (err) {
|
||||
if (!avisoCatalogoEmitido) {
|
||||
avisoCatalogoEmitido = true
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[reglas-cotizador] No se pudo leer el catálogo de desarrollos; se usan las constantes.',
|
||||
err,
|
||||
)
|
||||
}
|
||||
return { porClave, clavePorId }
|
||||
}
|
||||
|
||||
for (const fila of Array.isArray(leidas) ? leidas : []) {
|
||||
if (typeof fila !== 'object' || fila === null) continue
|
||||
const f = fila as Record<string, unknown>
|
||||
const clave = primerValor(f, ['clave', 'desarrolloId', 'desarrollo'], esTexto)
|
||||
if (typeof clave !== 'string') continue
|
||||
const nombre = primerValor(f, ['nombre'], esTexto)
|
||||
const prefijo = primerValor(f, ['prefijoUnidadPdf'], esTexto)
|
||||
const ficha: FichaDesarrollo = {
|
||||
clave: clave.trim().toUpperCase(),
|
||||
activoEnCotizador: aBandera(primerValor(f, ['activoEnCotizador', 'activo'], esBandera)),
|
||||
nombre: typeof nombre === 'string' ? nombre : undefined,
|
||||
prefijoUnidadPdf: typeof prefijo === 'string' ? prefijo : undefined,
|
||||
}
|
||||
porClave.set(ficha.clave, ficha)
|
||||
const id = primerValor(f, ['id'], esTexto)
|
||||
if (typeof id === 'string') clavePorId.set(id, ficha.clave)
|
||||
}
|
||||
return { porClave, clavePorId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clave del desarrollo de una fila de reglas: primero el id guardado (uuid) que
|
||||
* se resuelve con el catálogo, luego el `id` del propio JSON y, si no, la clave
|
||||
* que venga en la columna.
|
||||
*/
|
||||
function claveDeFila(
|
||||
fila: FilaReglas,
|
||||
bruto: unknown,
|
||||
catalogo: CatalogoDesarrollos,
|
||||
): string | null {
|
||||
const crudo = fila.desarrolloId.trim()
|
||||
const delCatalogo = catalogo.clavePorId.get(crudo)
|
||||
if (delCatalogo !== undefined) return delCatalogo
|
||||
if (typeof bruto === 'object' && bruto !== null) {
|
||||
const idJson = (bruto as Record<string, unknown>)['id']
|
||||
if (esTexto(idJson)) return idJson.trim().toUpperCase()
|
||||
}
|
||||
return crudo === '' ? null : crudo.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reglas configuradas por el admin, indexadas por clave de desarrollo. Se
|
||||
* descartan (con warning) las filas con JSON corrupto: esos desarrollos usan sus
|
||||
* constantes.
|
||||
*/
|
||||
export async function getReglasGuardadas(): Promise<Map<string, ParametrosDesarrollo>> {
|
||||
const guardadas = new Map<string, ParametrosDesarrollo>()
|
||||
let filas: FilaReglas[]
|
||||
try {
|
||||
filas = await leerFilasReglas()
|
||||
} catch (err) {
|
||||
if (!avisoLecturaEmitido) {
|
||||
avisoLecturaEmitido = true
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[reglas-cotizador] No se pudieron leer las reglas guardadas (¿tabla ReglaCotizadorDesarrollo sin crear?); se usan las constantes.',
|
||||
err,
|
||||
)
|
||||
}
|
||||
return guardadas
|
||||
}
|
||||
if (filas.length === 0) return guardadas
|
||||
|
||||
const catalogo = await leerCatalogo()
|
||||
|
||||
for (const fila of filas) {
|
||||
if (fila.parametros === undefined) continue
|
||||
let bruto = fila.parametros
|
||||
if (typeof bruto === 'string') {
|
||||
try {
|
||||
bruto = JSON.parse(bruto)
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[reglas-cotizador] JSON inválido para el desarrollo ${fila.desarrolloId}; se usan las constantes.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
const clave = claveDeFila(fila, bruto, catalogo)
|
||||
if (clave === null) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[reglas-cotizador] Fila de reglas sin desarrollo; se ignora.')
|
||||
continue
|
||||
}
|
||||
const reglas = normalizarReglas(clave, bruto)
|
||||
if (!reglas) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[reglas-cotizador] Reglas con forma inesperada para el desarrollo ${clave}; se usan las constantes.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// La ficha del desarrollo completa lo que el JSON no traiga
|
||||
const ficha = catalogo.porClave.get(clave)
|
||||
if (reglas.nombre === undefined && ficha?.nombre !== undefined) reglas.nombre = ficha.nombre
|
||||
if (reglas.prefijoUnidadPdf === undefined && ficha?.prefijoUnidadPdf !== undefined) {
|
||||
reglas.prefijoUnidadPdf = ficha.prefijoUnidadPdf
|
||||
}
|
||||
// El desarrollo está activo solo si TODAS las fuentes que hablan de él lo
|
||||
// dejan activo (la bandera vive en la ficha del desarrollo)
|
||||
const banderas = [fila.activoEnCotizador, reglas.activoEnCotizador, ficha?.activoEnCotizador].filter(
|
||||
(valor): valor is boolean => typeof valor === 'boolean',
|
||||
)
|
||||
if (banderas.length > 0) reglas.activoEnCotizador = banderas.every((valor) => valor)
|
||||
|
||||
guardadas.set(reglas.id, reglas)
|
||||
}
|
||||
return guardadas
|
||||
}
|
||||
|
||||
/**
|
||||
* Reglas efectivas de TODOS los desarrollos: lo guardado por el admin gana; los
|
||||
* desarrollos sin reglas (o con JSON corrupto) caen a las constantes actuales.
|
||||
*/
|
||||
export async function getReglasEfectivas(): Promise<ReglasDesarrollo> {
|
||||
const guardadas = await getReglasGuardadas()
|
||||
const efectivas: ReglasDesarrollo = {}
|
||||
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
|
||||
efectivas[id] = guardadas.get(id) ?? constantes
|
||||
}
|
||||
for (const [id, reglas] of guardadas) {
|
||||
if (efectivas[id] === undefined) efectivas[id] = reglas
|
||||
}
|
||||
return efectivas
|
||||
}
|
||||
|
||||
/** Reglas efectivas de un desarrollo; cae a las constantes (DIAGONAL si es desconocido) */
|
||||
export async function getReglasDesarrollo(
|
||||
desarrolloId: string | null | undefined,
|
||||
): Promise<ParametrosDesarrollo> {
|
||||
const id = (desarrolloId ?? DESARROLLO_DEFAULT).trim().toUpperCase() || DESARROLLO_DEFAULT
|
||||
const guardadas = await getReglasGuardadas()
|
||||
return guardadas.get(id) ?? DESARROLLOS[id] ?? DESARROLLOS[DESARROLLO_DEFAULT]!
|
||||
}
|
||||
@@ -11,6 +11,8 @@ function captura(
|
||||
origen: string | null
|
||||
desarrollo: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
datosJson: string | null
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
@@ -20,6 +22,8 @@ function captura(
|
||||
origen: overrides.origen ?? 'Facebook',
|
||||
desarrollo: overrides.desarrollo ?? 'DIAGONAL',
|
||||
createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
|
||||
updatedAt: overrides.updatedAt ?? overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
|
||||
datosJson: overrides.datosJson ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,27 +77,55 @@ describe('construirMiTablero', () => {
|
||||
captura({ board: 'LEADS' }),
|
||||
captura({ board: 'LEADS' }),
|
||||
captura({ board: 'OPORTUNIDADES' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
||||
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE' }),
|
||||
captura({ board: 'APARTADO', etapa: 'CANCELO' }),
|
||||
captura({ board: 'APARTADO', etapa: 'ESCRITURADO' }),
|
||||
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
|
||||
expect(tablero.conversion.oportunidades).toBe(1)
|
||||
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
|
||||
expect(tablero.conversion.vendidos).toBe(1)
|
||||
// Cancelo no cuenta; Firma Cliente y Escriturado ya son ventas
|
||||
expect(tablero.conversion.apartados).toBe(0)
|
||||
expect(tablero.conversion.vendidos).toBe(2)
|
||||
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
||||
})
|
||||
|
||||
it('calcula ventas del mes correctamente', () => {
|
||||
it('calcula ventas del mes con ventas CERRADAS (no cotizaciones)', () => {
|
||||
// Las cotizaciones ya no cuentan como ventas: se cuentan los apartados con
|
||||
// firma de cliente o posteriores, sin repetir la misma unidad.
|
||||
const cots = [
|
||||
cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }),
|
||||
cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }),
|
||||
cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior
|
||||
]
|
||||
const tablero = construirMiTablero([], cots, [], AHORA)
|
||||
const capturas = [
|
||||
captura({
|
||||
leadId: 'loc_apt_1',
|
||||
board: 'APARTADO',
|
||||
etapa: 'Firma Cliente',
|
||||
updatedAt: new Date('2026-09-10T12:00:00'),
|
||||
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
|
||||
}),
|
||||
captura({
|
||||
leadId: 'loc_apt_2',
|
||||
board: 'APARTADO',
|
||||
etapa: 'Venta',
|
||||
updatedAt: new Date('2026-09-12T12:00:00'),
|
||||
datosJson: JSON.stringify({ precioFinal: 2_000_000 }),
|
||||
}),
|
||||
// Mismo apartado recotizado: no debe duplicarse (mismo leadId)
|
||||
captura({
|
||||
leadId: 'loc_apt_1',
|
||||
board: 'APARTADO',
|
||||
etapa: 'Firma Cliente',
|
||||
updatedAt: new Date('2026-09-15T12:00:00'),
|
||||
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
|
||||
}),
|
||||
captura({ board: 'APARTADO', etapa: 'Pago Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
|
||||
captura({ board: 'APARTADO', etapa: 'Cancelacion Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, cots, [], AHORA)
|
||||
expect(tablero.ventasMes.unidades).toBe(2)
|
||||
expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000
|
||||
expect(tablero.ventasMes.monto).toBe(3_500_000)
|
||||
})
|
||||
|
||||
it('agrupa por origen con conteo de convertidos', () => {
|
||||
@@ -116,9 +148,9 @@ describe('construirMiTablero', () => {
|
||||
|
||||
it('agrupa por desarrollo solo con vendidos', () => {
|
||||
const capturas = [
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'DIAGONAL' }),
|
||||
captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'ATELIER' }),
|
||||
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
|
||||
]
|
||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||
|
||||
@@ -80,6 +80,9 @@ export function construirMiTablero(
|
||||
origen: string | null
|
||||
desarrollo: string | null
|
||||
createdAt: Date
|
||||
/** Necesarios para las ventas cerradas del mes (fecha del cambio y precio) */
|
||||
updatedAt?: Date
|
||||
datosJson?: string | null
|
||||
}[],
|
||||
cotizaciones: {
|
||||
precioFinal: number
|
||||
@@ -119,24 +122,52 @@ export function construirMiTablero(
|
||||
// -- Conversión (funnel) --
|
||||
const leads = capturas.filter((c) => c.board === 'LEADS').length
|
||||
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
|
||||
// Apartados ACTIVOS del funnel: ni cancelados (Cancelo/Cancelacion Apartado)
|
||||
// ni ya vendidos (Firma Cliente/Escriturado/Titulado pasan a "vendidos")
|
||||
const apartados = capturas.filter(
|
||||
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO',
|
||||
(c) =>
|
||||
c.board === 'APARTADO' &&
|
||||
!['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes(
|
||||
c.etapa.toUpperCase(),
|
||||
),
|
||||
).length
|
||||
const vendidos = capturas.filter(
|
||||
(c) =>
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
||||
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
|
||||
).length
|
||||
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
||||
|
||||
// -- Ventas del mes --
|
||||
// Antes sumaba COTIZACIONES (un inmueble recotizado contaba varias veces y una
|
||||
// cotización no es una venta). Ahora cuenta ventas cerradas del mes: apartados
|
||||
// con firma de cliente o posteriores (Venta, Escriturado, Titulado), sin
|
||||
// repetir la misma unidad.
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
const cotizacionesMes = cotizaciones.filter(
|
||||
(ct) => ct.createdAt >= inicioMes,
|
||||
const esVentaCerrada = (etapa: string): boolean => {
|
||||
const e = etapa.toUpperCase()
|
||||
return (
|
||||
e.includes('FIRMA') || e.includes('VENTA') || e.includes('ESCRITURADO') || e.includes('TITULADO')
|
||||
) && !e.includes('CANCEL')
|
||||
}
|
||||
const ventasDelMes = capturas.filter(
|
||||
(c) => c.board === 'APARTADO' && esVentaCerrada(c.etapa) && (c.updatedAt ?? c.createdAt) >= inicioMes,
|
||||
)
|
||||
const unidadesVendidas = new Map<string, number>()
|
||||
for (const v of ventasDelMes) {
|
||||
let monto = 0
|
||||
try {
|
||||
const datos = JSON.parse(v.datosJson ?? '{}') as Record<string, unknown>
|
||||
const precio = Number(datos['precioFinal'] ?? datos['precioVenta'] ?? 0)
|
||||
monto = Number.isFinite(precio) ? precio : 0
|
||||
} catch {
|
||||
monto = 0
|
||||
}
|
||||
unidadesVendidas.set(v.leadId, monto)
|
||||
}
|
||||
const ventasMes = {
|
||||
monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100,
|
||||
unidades: cotizacionesMes.length,
|
||||
monto: Math.round([...unidadesVendidas.values()].reduce((a, b) => a + b, 0) * 100) / 100,
|
||||
unidades: unidadesVendidas.size,
|
||||
}
|
||||
|
||||
// -- Por origen --
|
||||
@@ -159,7 +190,7 @@ export function construirMiTablero(
|
||||
for (const c of capturas) {
|
||||
if (
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
|
||||
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
|
||||
c.desarrollo
|
||||
) {
|
||||
const dev = c.desarrollo.trim()
|
||||
@@ -212,6 +243,8 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
|
||||
origen: true,
|
||||
desarrollo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
datosJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('construirResumen', () => {
|
||||
plan('a', {
|
||||
pagos: [
|
||||
pago('p1', '2026-08-01', 50000), // vencido sin abono
|
||||
pago('p2', '2026-08-10', 50000, 20000), // parcial (no cuenta como VENCIDO)
|
||||
pago('p2', '2026-08-10', 50000, 20000), // parcial VENCIDO: sí cuenta como atraso
|
||||
pago('p3', '2026-09-01', 50000), // pendiente futuro
|
||||
],
|
||||
}),
|
||||
@@ -228,9 +228,10 @@ describe('construirResumen', () => {
|
||||
},
|
||||
AHORA,
|
||||
)
|
||||
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1 })
|
||||
expect(r.cobranza.pagosVencidos).toBe(2)
|
||||
expect(r.cobranza.montoVencido).toBe(80000)
|
||||
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1, sinPlan: 0 })
|
||||
// p1 (vencido sin abono) + p2 (PARCIAL vencido, antes se ignoraba) + p4
|
||||
expect(r.cobranza.pagosVencidos).toBe(3)
|
||||
expect(r.cobranza.montoVencido).toBe(110000) // 50,000 + 30,000 restantes + 30,000
|
||||
expect(r.cobranza.planesAlCorriente).toBe(0)
|
||||
expect(r.cobranza.planesConVencidos).toBe(2)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
|
||||
import { pagoAtrasado, statusPlan, type PlanPago } from './cobranza.service.js'
|
||||
import type { UnidadCotizable } from './cotizacion.service.js'
|
||||
import { ETAPAS_PIPELINE } from './etapas.js'
|
||||
import { prisma } from './prisma.js'
|
||||
@@ -266,7 +266,9 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
|
||||
.map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })),
|
||||
]
|
||||
|
||||
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 }
|
||||
// `sinPlan` = plan sin calendario capturado (no es lo mismo que cancelado:
|
||||
// 3 planes del maestro llegaron sin pagos y antes se contaban como cancelados)
|
||||
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0, sinPlan: 0 }
|
||||
let planesAlCorriente = 0
|
||||
let planesConVencidos = 0
|
||||
let pagosVencidos = 0
|
||||
@@ -275,9 +277,12 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
|
||||
const status = statusPlan(plan)
|
||||
if (status === 'ACTIVO') planesPorStatus.activos++
|
||||
else if (status === 'LIQUIDADO') planesPorStatus.liquidados++
|
||||
else if (status === 'SIN_PLAN') planesPorStatus.sinPlan++
|
||||
else planesPorStatus.cancelados++
|
||||
if (status !== 'ACTIVO') continue
|
||||
const vencidos = plan.pagos.filter((p) => statusPago(p, ahora) === 'VENCIDO')
|
||||
// Un pago PARCIAL vencido también es atraso (antes no se contaba y había
|
||||
// planes "al corriente" con meses de retraso)
|
||||
const vencidos = plan.pagos.filter((p) => pagoAtrasado(p, ahora))
|
||||
if (vencidos.length === 0) planesAlCorriente++
|
||||
else planesConVencidos++
|
||||
pagosVencidos += vencidos.length
|
||||
@@ -435,7 +440,7 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
|
||||
// Contar actividades abiertas
|
||||
const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } })
|
||||
|
||||
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto), actividadesAbiertas }
|
||||
return { leads, oportunidades, unidades: unidadesRows.map((u) => unidadLocalADto(u)), actividadesAbiertas }
|
||||
}
|
||||
|
||||
export class ReportesService {
|
||||
|
||||
@@ -7,11 +7,15 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
capturaDepartamento: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import {
|
||||
CAMPOS_FECHA_ENTREGA,
|
||||
getUnidadesLocal,
|
||||
getUnidadLocal,
|
||||
unidadLocalADto,
|
||||
@@ -37,10 +41,11 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(null)
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('unidadLocalADto', () => {
|
||||
it('mapea la fila local al DTO Unidad', () => {
|
||||
it('mapea la fila local al DTO Unidad (sin fecha de entrega por defecto)', () => {
|
||||
const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' }))
|
||||
expect(dto).toEqual({
|
||||
codigo: 'SENDA-101',
|
||||
@@ -58,6 +63,10 @@ describe('unidadLocalADto', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('con fecha de entrega (captura de departamentos) la deja en el DTO', () => {
|
||||
expect(unidadLocalADto(fila({ codigo: 'U-101' }), '2027-10-08').fechaEntrega).toBe('2027-10-08')
|
||||
})
|
||||
|
||||
it('etapa vacía → null', () => {
|
||||
expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull()
|
||||
})
|
||||
@@ -80,11 +89,47 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
|
||||
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
|
||||
})
|
||||
|
||||
it('getUnidadLocal: null si no existe', async () => {
|
||||
it('completa fechaEntrega desde la captura: reprogramada > programada > habitabilidad > inicio de obra', async () => {
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
|
||||
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
|
||||
fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
|
||||
fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
|
||||
fila({ codigo: 'S-3', desarrollo: 'SENDA' }),
|
||||
])
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
|
||||
{ unidadCodigo: 'U-1', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||
{ unidadCodigo: 'U-1', campo: 'entrega_programada_fecha', valor: '2027-11-20' },
|
||||
{ unidadCodigo: 'S-1', campo: 'habitabilidad', valor: '2027-05-05' },
|
||||
// Valor vacío / no-fecha: no cuenta como dato
|
||||
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
|
||||
{ unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' },
|
||||
// Reprogramada: manda sobre la programada cuando existe
|
||||
{ unidadCodigo: 'U-1', campo: 'entrega_reprogramada_fecha', valor: '2028-02-15' },
|
||||
// Reprogramada vacía: se ignora y gana la programada
|
||||
{ unidadCodigo: 'S-3', campo: 'entrega_reprogramada_fecha', valor: '' },
|
||||
{ unidadCodigo: 'S-3', campo: 'entrega_programada_fecha', valor: '2027-07-01' },
|
||||
])
|
||||
|
||||
const todas = await getUnidadesLocal()
|
||||
|
||||
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2028-02-15', null, '2027-05-05', null, '2027-07-01'])
|
||||
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('getUnidadLocal: null si no existe; trae la fecha de su captura', async () => {
|
||||
prismaMock.unidad.findUnique.mockImplementation(async ({ where }) =>
|
||||
where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null,
|
||||
)
|
||||
expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9')
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-9', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||
])
|
||||
const dto = await getUnidadLocal('U-9')
|
||||
expect(dto?.codigo).toBe('U-9')
|
||||
expect(dto?.fechaEntrega).toBe('2027-10-08')
|
||||
expect(await getUnidadLocal('U-X')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,55 @@ import type { UnidadCotizable } from './cotizacion.service.js'
|
||||
* scripts/seed-unidades-locales.js). Es la única fuente de lectura del
|
||||
* inventario: misma forma de DTO que consumen controllers y fronts.
|
||||
*
|
||||
* La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el
|
||||
* seed curado): salen en null, que el cotizador ya tolera (plazo desconocido).
|
||||
* La tabla `Unidad` no guarda la fecha de entrega: se completa desde la captura
|
||||
* de obra/escrituración (`CapturaDepartamento`), que es la que llenan los
|
||||
* departamentos. Sin ese dato el cotizador asume el plazo por defecto del
|
||||
* desarrollo (ver DESARROLLOS en cotizacion.service.ts).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
|
||||
* en orden de prioridad (el primero con fecha válida gana):
|
||||
* 1. `entrega_reprogramada_fecha` — Fecha Reprogramada Entrega (la más reciente,
|
||||
* cuando obra movió la entrega es la fecha que manda)
|
||||
* 2. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda
|
||||
* 3. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL
|
||||
* 4. `inicio_obra_fecha` — último recurso (ESCRITURACION)
|
||||
*/
|
||||
export const CAMPOS_FECHA_ENTREGA = [
|
||||
'entrega_reprogramada_fecha',
|
||||
'entrega_programada_fecha',
|
||||
'habitabilidad',
|
||||
'inicio_obra_fecha',
|
||||
] as const
|
||||
|
||||
/** Normaliza 'YYYY-MM-DD' (o ISO completo); null si el valor no es una fecha usable */
|
||||
function fechaIso(valor: string | null | undefined): string | null {
|
||||
if (typeof valor !== 'string') return null
|
||||
const fecha = valor.trim().slice(0, 10)
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(fecha) ? fecha : null
|
||||
}
|
||||
|
||||
/** Fecha de entrega (ISO) por código de unidad, según la captura de los departamentos */
|
||||
async function fechasEntregaPorUnidad(codigos?: string[]): Promise<Map<string, string>> {
|
||||
const filas = await prisma.capturaDepartamento.findMany({
|
||||
where: {
|
||||
campo: { in: [...CAMPOS_FECHA_ENTREGA] },
|
||||
...(codigos ? { unidadCodigo: { in: codigos } } : {}),
|
||||
},
|
||||
select: { unidadCodigo: true, campo: true, valor: true },
|
||||
})
|
||||
const porUnidad = new Map<string, string>()
|
||||
for (const campo of CAMPOS_FECHA_ENTREGA) {
|
||||
for (const fila of filas) {
|
||||
if (fila.campo !== campo || porUnidad.has(fila.unidadCodigo)) continue
|
||||
const fecha = fechaIso(fila.valor)
|
||||
if (fecha !== null) porUnidad.set(fila.unidadCodigo, fecha)
|
||||
}
|
||||
}
|
||||
return porUnidad
|
||||
}
|
||||
|
||||
/** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */
|
||||
export interface FilaUnidad {
|
||||
codigo: string
|
||||
@@ -25,11 +70,11 @@ export interface FilaUnidad {
|
||||
}
|
||||
|
||||
/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */
|
||||
export function unidadLocalADto(u: FilaUnidad): UnidadCotizable {
|
||||
export function unidadLocalADto(u: FilaUnidad, fechaEntrega: string | null = null): UnidadCotizable {
|
||||
return {
|
||||
codigo: u.codigo,
|
||||
modelo: u.modelo,
|
||||
fechaEntrega: null, // no está en el espejo local: plazo desconocido
|
||||
fechaEntrega,
|
||||
supEstacionamiento: u.supEstacionamiento,
|
||||
supConstruccion: u.supConstruccion,
|
||||
supExcedente: u.supExcedente,
|
||||
@@ -47,7 +92,8 @@ export async function getUnidadesLocal(
|
||||
filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {},
|
||||
): Promise<UnidadCotizable[]> {
|
||||
const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } })
|
||||
let lista = rows.map(unidadLocalADto)
|
||||
const fechas = await fechasEntregaPorUnidad()
|
||||
let lista = rows.map((u) => unidadLocalADto(u, fechas.get(u.codigo) ?? null))
|
||||
if (filtro.soloDisponibles === true) {
|
||||
lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE')
|
||||
}
|
||||
@@ -61,5 +107,7 @@ export async function getUnidadesLocal(
|
||||
/** Una unidad local por código; null si no existe en el espejo */
|
||||
export async function getUnidadLocal(codigo: string): Promise<UnidadCotizable | null> {
|
||||
const row = await prisma.unidad.findUnique({ where: { codigo } })
|
||||
return row === null ? null : unidadLocalADto(row)
|
||||
if (row === null) return null
|
||||
const fechas = await fechasEntregaPorUnidad([codigo])
|
||||
return unidadLocalADto(row, fechas.get(codigo) ?? null)
|
||||
}
|
||||
|
||||
155
api-gateway/src/services/wacrm.service.test.ts
Normal file
155
api-gateway/src/services/wacrm.service.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Tests de integración del servicio wacrm contra el Supabase real.
|
||||
*
|
||||
* Se saltan automáticamente si WACRM_PG_URL no está definida (CI/entornos
|
||||
* sin wacrm). Crean datos de prueba en la base de wacrm y los limpian al
|
||||
* final (afterAll) aunque fallen las assertions.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Pool } from 'pg'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { prisma } from './prisma.js'
|
||||
import { ejecutarRoundRobinWacrm, miBandeja, setWacrmPoolForTesting } from './wacrm.service.js'
|
||||
import { actualizarConfigs, obtenerConfig } from './config.service.js'
|
||||
|
||||
const HABILITADO = Boolean(process.env.WACRM_PG_URL)
|
||||
|
||||
describe.skipIf(!HABILITADO)('wacrm.service (integración)', () => {
|
||||
let pool: Pool
|
||||
let cuentaId: string
|
||||
let authUsers: string[] = []
|
||||
const idsUsuariosCrm: string[] = []
|
||||
const idsConversaciones: string[] = []
|
||||
const CLAVES_CONFIG = ['wacrm_cuenta_id', 'wacrm_rr_activo', 'wacrm_rr_idx']
|
||||
const configPrev: Record<string, string | null> = {}
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = new Pool({ connectionString: process.env.WACRM_PG_URL, max: 2 })
|
||||
setWacrmPoolForTesting(pool as unknown as never)
|
||||
|
||||
// Usuarios auth de prueba (2) + cuenta + perfiles agente
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const uid = randomUUID()
|
||||
authUsers.push(uid)
|
||||
await pool.query(
|
||||
`INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, created_at, updated_at)
|
||||
VALUES ($1, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', $2, 'x', now(), now())`,
|
||||
[uid, `wacrm-test-${i}-${randomUUID()}@test.local`],
|
||||
)
|
||||
}
|
||||
// handle_new_user crea automáticamente una cuenta personal + profile por
|
||||
// usuario: reusamos la cuenta del primer usuario y movemos al segundo.
|
||||
const { rows: cuentas } = await pool.query(
|
||||
`SELECT id FROM accounts WHERE owner_user_id = $1`,
|
||||
[authUsers[0]],
|
||||
)
|
||||
cuentaId = cuentas[0].id
|
||||
// Primero mover el profile ( CASCADE del account borraría el profile ),
|
||||
// luego eliminar la cuenta personal sobrante del segundo usuario.
|
||||
await pool.query(
|
||||
`UPDATE profiles SET account_id = $1, account_role = 'agent' WHERE user_id = $2`,
|
||||
[cuentaId, authUsers[1]],
|
||||
)
|
||||
await pool.query(`DELETE FROM accounts WHERE owner_user_id = $1`, [authUsers[1]])
|
||||
// Vendedores CRM vinculados a esos usuarios auth
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const u = await prisma.usuario.create({
|
||||
data: {
|
||||
username: `wacrmtest${i}-${randomUUID().slice(0, 8)}`,
|
||||
nombre: `Vendedor Test ${i}`,
|
||||
passwordHash: 'x',
|
||||
rol: 'vendedor',
|
||||
wacrmUserId: authUsers[i],
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
idsUsuariosCrm.push(u.id)
|
||||
}
|
||||
// Guarda config previa y activa round-robin sobre la cuenta de prueba
|
||||
for (const k of CLAVES_CONFIG) configPrev[k] = await obtenerConfig(k)
|
||||
await actualizarConfigs({ 'wacrm_cuenta_id': cuentaId, 'wacrm_rr_activo': '1', 'wacrm_rr_idx': '0' })
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query('DELETE FROM conversations WHERE account_id = $1', [cuentaId])
|
||||
await pool.query('DELETE FROM contacts WHERE account_id = $1', [cuentaId])
|
||||
await pool.query('DELETE FROM profiles WHERE user_id = ANY($1)', [authUsers])
|
||||
await pool.query('DELETE FROM accounts WHERE id = $1', [cuentaId])
|
||||
await pool.query('DELETE FROM auth.users WHERE id = ANY($1)', [authUsers])
|
||||
await prisma.usuario.deleteMany({ where: { id: { in: idsUsuariosCrm } } })
|
||||
const restaurar: Record<string, string> = {}
|
||||
for (const k of CLAVES_CONFIG) if (configPrev[k] !== null) restaurar[k] = configPrev[k] as string
|
||||
if (Object.keys(restaurar).length > 0) await actualizarConfigs(restaurar)
|
||||
for (const k of CLAVES_CONFIG) {
|
||||
if (configPrev[k] === null) await prisma.configuracionSistema.deleteMany({ where: { clave: k } })
|
||||
}
|
||||
setWacrmPoolForTesting(null)
|
||||
await pool.end()
|
||||
}, 60_000)
|
||||
|
||||
async function crearConversacion(telefono: string): Promise<string> {
|
||||
const contactId = randomUUID()
|
||||
await pool.query(
|
||||
`INSERT INTO contacts (id, user_id, phone, name, account_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now(), now())`,
|
||||
[contactId, authUsers[0], telefono, `Contacto ${telefono}`, cuentaId],
|
||||
)
|
||||
const convId = randomUUID()
|
||||
await pool.query(
|
||||
`INSERT INTO conversations (id, user_id, contact_id, status, account_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'open', $4, now(), now())`,
|
||||
[convId, authUsers[0], contactId, cuentaId],
|
||||
)
|
||||
idsConversaciones.push(convId)
|
||||
return convId
|
||||
}
|
||||
|
||||
it('round-robin turna conversaciones sin asignar entre los vendedores', async () => {
|
||||
await crearConversacion('521550000001')
|
||||
await crearConversacion('521550000002')
|
||||
await crearConversacion('521550000003')
|
||||
|
||||
const r = await ejecutarRoundRobinWacrm()
|
||||
expect(r.asignados).toBe(3)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT assigned_agent_id FROM conversations WHERE account_id = $1 ORDER BY created_at`,
|
||||
[cuentaId],
|
||||
)
|
||||
const asignados = rows.map((r2: { assigned_agent_id: string }) => r2.assigned_agent_id)
|
||||
// 3 conversaciones, 2 vendedores: reparte 2-1 y todos quedan asignados
|
||||
expect(asignados.every((a: string | null) => a !== null)).toBe(true)
|
||||
expect(new Set(asignados).size).toBe(2)
|
||||
// Segunda pasada: nada nuevo que asignar
|
||||
const r2 = await ejecutarRoundRobinWacrm()
|
||||
expect(r2.asignados).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('miBandeja devuelve solo las conversaciones del vendedor', async () => {
|
||||
const bandeja0 = await miBandeja(idsUsuariosCrm[0])
|
||||
const bandeja1 = await miBandeja(idsUsuariosCrm[1])
|
||||
expect(bandeja0.vinculado).toBe(true)
|
||||
expect(bandeja1.vinculado).toBe(true)
|
||||
expect(bandeja0.conversaciones.length + bandeja1.conversaciones.length).toBe(3)
|
||||
// Sin solapamiento
|
||||
const ids0 = new Set(bandeja0.conversaciones.map((c) => c.id))
|
||||
for (const c of bandeja1.conversaciones) expect(ids0.has(c.id)).toBe(false)
|
||||
expect(bandeja0.conversaciones[0]?.contacto.telefono).toBeTruthy()
|
||||
}, 30_000)
|
||||
|
||||
it('miBandeja marca como no vinculado a un usuario sin wacrmUserId', async () => {
|
||||
const u = await prisma.usuario.create({
|
||||
data: {
|
||||
username: `wacrmsinvinc-${randomUUID().slice(0, 8)}`,
|
||||
nombre: 'Sin Vinculo',
|
||||
passwordHash: 'x',
|
||||
rol: 'vendedor',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
idsUsuariosCrm.push(u.id)
|
||||
const bandeja = await miBandeja(u.id)
|
||||
expect(bandeja.vinculado).toBe(false)
|
||||
expect(bandeja.conversaciones).toEqual([])
|
||||
}, 30_000)
|
||||
})
|
||||
284
api-gateway/src/services/wacrm.service.ts
Normal file
284
api-gateway/src/services/wacrm.service.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Servicio de integración con WA CRM (wacrm — WhatsApp).
|
||||
*
|
||||
* wacrm vive en su propia base (Supabase Postgres self-hosted, esquema
|
||||
* public) y comparte usuarios con el CRM mediante la columna
|
||||
* `Usuario.wacrmUserId` (id de auth.users en Supabase).
|
||||
*
|
||||
* Responsabilidades:
|
||||
* - Round-robin: cada conversación entrante sin `assigned_agent_id` se
|
||||
* asigna al siguiente vendedor del pool (rol 'vendedor' vinculado Y
|
||||
* miembro agente/admin/owner de la cuenta wacrm configurada).
|
||||
* - Bandeja: exponer "mis conversaciones" al vendedor logueado.
|
||||
* - Admin: estado general, configuración de cuenta/activo y reasignación.
|
||||
*
|
||||
* La conexión es lazy: si WACRM_PG_URL no está definida o la base no
|
||||
* responde, las funciones lanzan AppError 503 (el resto del CRM sigue
|
||||
* funcionando igual).
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg'
|
||||
import { prisma } from './prisma.js'
|
||||
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import { obtenerConfig, actualizarConfigs } from './config.service.js'
|
||||
|
||||
// ── Config en ConfiguracionSistema ─────────────────────────────────────
|
||||
export const CLAVE_CUENTA = 'wacrm_cuenta_id'
|
||||
export const CLAVE_RR_ACTIVO = 'wacrm_rr_activo'
|
||||
const CLAVE_RR_IDX = 'wacrm_rr_idx'
|
||||
|
||||
// ── Pool lazy ──────────────────────────────────────────────────────────
|
||||
let pool: Pool | null = null
|
||||
let poolUrlUsada = ''
|
||||
|
||||
function obtenerPool(): Pool {
|
||||
const url = process.env.WACRM_PG_URL ?? ''
|
||||
if (!url) throw new AppError(503, 'WA CRM no configurado (falta WACRM_PG_URL)')
|
||||
if (!pool || poolUrlUsada !== url) {
|
||||
pool?.end().catch(() => undefined)
|
||||
pool = new Pool({ connectionString: url, max: 3, idleTimeoutMillis: 30_000 })
|
||||
poolUrlUsada = url
|
||||
// No dejar que un error de conexión tumbe el proceso
|
||||
pool.on('error', (err) => console.error('[wacrm] error en pool pg:', err.message))
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
/** Para tests: inyectar un pool falso / resetear al real. */
|
||||
export function setWacrmPoolForTesting(fake: Pool | null): void {
|
||||
pool = fake
|
||||
// Marcar la URL como "ya cargada" para que obtenerPool() no cierre el pool
|
||||
// inyectado al comparar contra la URL del entorno.
|
||||
poolUrlUsada = fake ? (process.env.WACRM_PG_URL ?? '') : ''
|
||||
}
|
||||
|
||||
interface ConversacionBandeja {
|
||||
id: string
|
||||
status: string
|
||||
lastMessage: string | null
|
||||
lastMessageAt: string | null
|
||||
unreadCount: number
|
||||
contacto: { nombre: string | null; telefono: string }
|
||||
}
|
||||
|
||||
// ── Helpers internos ───────────────────────────────────────────────────
|
||||
|
||||
/** Vendedores CRM vinculados a wacrm que además son agentes de la cuenta. */
|
||||
async function poolVendedoresCuenta(cuentaId: string) {
|
||||
const vendedores = await prisma.usuario.findMany({
|
||||
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
|
||||
orderBy: { nombre: 'asc' },
|
||||
select: { id: true, nombre: true, wacrmUserId: true },
|
||||
})
|
||||
if (vendedores.length === 0) return []
|
||||
const ids = vendedores.map((v) => v.wacrmUserId as string)
|
||||
const { rows } = await obtenerPool().query(
|
||||
`SELECT user_id FROM profiles
|
||||
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
|
||||
AND user_id = ANY($2::uuid[])`,
|
||||
[cuentaId, ids],
|
||||
)
|
||||
const miembros = new Set(rows.map((r: { user_id: string }) => r.user_id))
|
||||
return vendedores.filter((v) => miembros.has(v.wacrmUserId as string))
|
||||
}
|
||||
|
||||
// ── Round-robin ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Asigna las conversaciones sin agente de la cuenta configurada,
|
||||
* turnándose entre los vendedores del pool. Devuelve cuántas asignó.
|
||||
*/
|
||||
export async function ejecutarRoundRobinWacrm(): Promise<{ asignados: number }> {
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const activo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
|
||||
if (!activo || !cuentaId) return { asignados: 0 }
|
||||
|
||||
const poolVendedores = await poolVendedoresCuenta(cuentaId)
|
||||
if (poolVendedores.length === 0) return { asignados: 0 }
|
||||
|
||||
const db = obtenerPool()
|
||||
const { rows: sinAsignar } = await db.query(
|
||||
`SELECT id FROM conversations
|
||||
WHERE account_id = $1 AND assigned_agent_id IS NULL AND status = 'open'
|
||||
ORDER BY created_at ASC LIMIT 50`,
|
||||
[cuentaId],
|
||||
)
|
||||
|
||||
let idx = Number.parseInt((await obtenerConfig(CLAVE_RR_IDX)) ?? '0', 10)
|
||||
if (!Number.isFinite(idx) || idx < 0) idx = 0
|
||||
|
||||
let asignados = 0
|
||||
for (const conv of sinAsignar as { id: string }[]) {
|
||||
const vend = poolVendedores[idx % poolVendedores.length]!
|
||||
idx += 1
|
||||
const r = await db.query(
|
||||
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now()
|
||||
WHERE id = $1 AND assigned_agent_id IS NULL`,
|
||||
[conv.id, vend.wacrmUserId],
|
||||
)
|
||||
if (r.rowCount === 1) {
|
||||
asignados += 1
|
||||
console.log(`[wacrm round-robin] conversación ${conv.id} → ${vend.nombre}`)
|
||||
}
|
||||
}
|
||||
if (asignados > 0 || sinAsignar.length > 0) {
|
||||
await actualizarConfigs({ [CLAVE_RR_IDX]: String(idx) })
|
||||
}
|
||||
return { asignados }
|
||||
}
|
||||
|
||||
// ── Bandeja del vendedor ───────────────────────────────────────────────
|
||||
|
||||
/** Conversaciones de WhatsApp asignadas al usuario del CRM (por su vínculo). */
|
||||
export async function miBandeja(usuarioCrmId: string): Promise<{
|
||||
vinculado: boolean
|
||||
conversaciones: ConversacionBandeja[]
|
||||
}> {
|
||||
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioCrmId } })
|
||||
if (!usuario?.wacrmUserId) return { vinculado: false, conversaciones: [] }
|
||||
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const params: string[] = [usuario.wacrmUserId]
|
||||
let filtroCuenta = ''
|
||||
if (cuentaId) {
|
||||
params.push(cuentaId)
|
||||
filtroCuenta = 'AND c.account_id = $2'
|
||||
}
|
||||
const { rows } = await obtenerPool().query(
|
||||
`SELECT c.id, c.status, c.last_message_text, c.last_message_at, c.unread_count,
|
||||
ct.name AS contacto_nombre, ct.phone AS contacto_telefono
|
||||
FROM conversations c JOIN contacts ct ON ct.id = c.contact_id
|
||||
WHERE c.assigned_agent_id = $1 ${filtroCuenta}
|
||||
ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC
|
||||
LIMIT 100`,
|
||||
params,
|
||||
)
|
||||
return {
|
||||
vinculado: true,
|
||||
conversaciones: rows.map((r) => ({
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
lastMessage: r.last_message_text,
|
||||
lastMessageAt: r.last_message_at ? new Date(r.last_message_at).toISOString() : null,
|
||||
unreadCount: r.unread_count ?? 0,
|
||||
contacto: { nombre: r.contacto_nombre, telefono: r.contacto_telefono },
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: estado / configuración / reasignación ───────────────────────
|
||||
|
||||
export async function estadoWacrm(): Promise<{
|
||||
cuentaId: string | null
|
||||
roundRobinActivo: boolean
|
||||
sinAsignar: number
|
||||
totalConversaciones: number
|
||||
vendedores: Array<{
|
||||
id: string
|
||||
nombre: string
|
||||
wacrmUserId: string
|
||||
esMiembro: boolean
|
||||
asignadas: number
|
||||
}>
|
||||
}> {
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const roundRobinActivo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
|
||||
if (!cuentaId) {
|
||||
return { cuentaId: null, roundRobinActivo, sinAsignar: 0, totalConversaciones: 0, vendedores: [] }
|
||||
}
|
||||
const db = obtenerPool()
|
||||
const { rows: conteos } = await db.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE assigned_agent_id IS NULL AND status = 'open') AS sin_asignar,
|
||||
COUNT(*) AS total
|
||||
FROM conversations WHERE account_id = $1`,
|
||||
[cuentaId],
|
||||
)
|
||||
const candidatos = await prisma.usuario.findMany({
|
||||
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
|
||||
orderBy: { nombre: 'asc' },
|
||||
select: { id: true, nombre: true, wacrmUserId: true },
|
||||
})
|
||||
const { rows: miembros } = await db.query(
|
||||
`SELECT user_id FROM profiles
|
||||
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
|
||||
AND user_id = ANY($2::uuid[])`,
|
||||
[cuentaId, candidatos.map((v) => v.wacrmUserId as string)],
|
||||
)
|
||||
const setMiembros = new Set(miembros.map((r: { user_id: string }) => r.user_id))
|
||||
const { rows: asignadas } = await db.query(
|
||||
`SELECT assigned_agent_id AS uid, COUNT(*)::int AS n
|
||||
FROM conversations WHERE account_id = $1 AND assigned_agent_id IS NOT NULL
|
||||
GROUP BY assigned_agent_id`,
|
||||
[cuentaId],
|
||||
)
|
||||
const mapAsignadas = new Map(asignadas.map((r: { uid: string; n: number }) => [r.uid, r.n]))
|
||||
return {
|
||||
cuentaId,
|
||||
roundRobinActivo,
|
||||
sinAsignar: Number(conteos[0]?.sin_asignar ?? 0),
|
||||
totalConversaciones: Number(conteos[0]?.total ?? 0),
|
||||
vendedores: candidatos.map((v) => ({
|
||||
id: v.id,
|
||||
nombre: v.nombre,
|
||||
wacrmUserId: v.wacrmUserId as string,
|
||||
esMiembro: setMiembros.has(v.wacrmUserId as string),
|
||||
asignadas: mapAsignadas.get(v.wacrmUserId as string) ?? 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function configurarWacrm(datos: {
|
||||
cuentaId?: string | undefined
|
||||
activo?: boolean | undefined
|
||||
}): Promise<{ cuentaId: string | null; activo: boolean }> {
|
||||
const cambios: Record<string, string> = {}
|
||||
if (datos.cuentaId !== undefined) {
|
||||
if (datos.cuentaId && !/^[0-9a-f-]{36}$/i.test(datos.cuentaId)) {
|
||||
throw new BadRequestError('cuentaId debe ser un UUID válido (o vacío para quitar)')
|
||||
}
|
||||
if (datos.cuentaId) {
|
||||
// Verificar que la cuenta exista en wacrm antes de guardarla
|
||||
const { rows } = await obtenerPool().query(`SELECT id FROM accounts WHERE id = $1`, [
|
||||
datos.cuentaId,
|
||||
])
|
||||
if (rows.length === 0) throw new NotFoundError('La cuenta no existe en WA CRM')
|
||||
}
|
||||
cambios[CLAVE_CUENTA] = datos.cuentaId
|
||||
}
|
||||
if (datos.activo !== undefined) cambios[CLAVE_RR_ACTIVO] = datos.activo ? '1' : '0'
|
||||
if (Object.keys(cambios).length > 0) await actualizarConfigs(cambios)
|
||||
const [cuentaId, activo] = await Promise.all([
|
||||
obtenerConfig(CLAVE_CUENTA),
|
||||
obtenerConfig(CLAVE_RR_ACTIVO),
|
||||
])
|
||||
return { cuentaId, activo: activo === '1' }
|
||||
}
|
||||
|
||||
export async function reasignarConversacion(datos: {
|
||||
conversacionId: string
|
||||
vendedorId: string
|
||||
}): Promise<void> {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(datos.conversacionId)) {
|
||||
throw new BadRequestError('conversacionId debe ser un UUID')
|
||||
}
|
||||
const vendedor = await prisma.usuario.findFirst({
|
||||
where: { id: datos.vendedorId, rol: 'vendedor', activo: true },
|
||||
})
|
||||
if (!vendedor?.wacrmUserId) throw new NotFoundError('Vendedor sin vínculo a WA CRM')
|
||||
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||
const db = obtenerPool()
|
||||
if (cuentaId) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT 1 FROM profiles WHERE account_id = $1 AND user_id = $2
|
||||
AND account_role IN ('owner','admin','agent')`,
|
||||
[cuentaId, vendedor.wacrmUserId],
|
||||
)
|
||||
if (rows.length === 0) throw new BadRequestError('El vendedor no es miembro de la cuenta WA CRM')
|
||||
}
|
||||
const r = await db.query(
|
||||
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now() WHERE id = $1`,
|
||||
[datos.conversacionId, vendedor.wacrmUserId],
|
||||
)
|
||||
if (r.rowCount === 0) throw new NotFoundError('Conversación no encontrada')
|
||||
}
|
||||
@@ -125,7 +125,7 @@ describe('AltaAfluenciaForm', () => {
|
||||
fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } })
|
||||
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
|
||||
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: 'F-25001.00-30000.00' } })
|
||||
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '20,000-40,000' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } })
|
||||
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
|
||||
@@ -141,7 +141,7 @@ describe('AltaAfluenciaForm', () => {
|
||||
estadoCivil: 'Casado(a)',
|
||||
tipoEmpleo: 'Asalariado',
|
||||
ocupacion: 'Ingeniero',
|
||||
rangoSalarial: 'F-25001.00-30000.00',
|
||||
rangoSalarial: '20,000-40,000',
|
||||
credito: 'Infonavit',
|
||||
tipoEconomia: 'Economía mexicana',
|
||||
califica: 'Sí',
|
||||
|
||||
@@ -155,7 +155,12 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
nombrePila,
|
||||
apellidoPaterno,
|
||||
apellidoMaterno,
|
||||
telefono: numero,
|
||||
telefono: (() => {
|
||||
// Normaliza a dígitos y toma los últimos 10 (limpia ladas duplicadas,
|
||||
// espacios inseparables y marcas invisibles de copiar/pegar)
|
||||
const soloDigitos = numero.replace(/\D/g, '')
|
||||
return soloDigitos.length > 10 ? soloDigitos.slice(-10) : soloDigitos
|
||||
})(),
|
||||
email: lead.email,
|
||||
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
|
||||
desarrollo,
|
||||
@@ -173,6 +178,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
const nombreCompuesto = nombreCompleto()
|
||||
if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio'
|
||||
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono es obligatorio'
|
||||
else if (form.telefono.replace(/\D/g, '').length !== 10) {
|
||||
nuevos.telefono = 'El teléfono debe tener exactamente 10 dígitos'
|
||||
}
|
||||
if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
|
||||
nuevos.email = 'Correo electrónico inválido'
|
||||
}
|
||||
@@ -423,12 +431,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
<input
|
||||
id="afluencia-telefono"
|
||||
type="tel"
|
||||
placeholder="664 123 4567"
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
placeholder="6641234567"
|
||||
value={form.telefono}
|
||||
onChange={cambiar('telefono')}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({ ...prev, telefono: e.target.value.replace(/\D/g, '').slice(0, 10) }))
|
||||
setErrores((prev) => ({ ...prev, telefono: undefined }))
|
||||
}}
|
||||
required
|
||||
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada (la lada va en el selector)</p>
|
||||
{errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
|
||||
</div>
|
||||
|
||||
@@ -582,19 +597,14 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
className={inputClase}
|
||||
>
|
||||
<option value="">— Selecciona —</option>
|
||||
<option value="A-0-10000.00">A-0-10000.00</option>
|
||||
<option value="B-10000.01-15000.00">B-10000.01-15000.00</option>
|
||||
<option value="C-15000.01-20000.00">C-15000.01-20000.00</option>
|
||||
<option value="D-17000.01-22000.00">D-17000.01-22000.00</option>
|
||||
<option value="E-20001.00-25000.00">E-20001.00-25000.00</option>
|
||||
<option value="F-25001.00-30000.00">F-25001.00-30000.00</option>
|
||||
<option value="G-30001.00-35000.00">G-30001.00-35000.00</option>
|
||||
<option value="H-35001.00-40000.00">H-35001.00-40000.00</option>
|
||||
<option value="I-40001.00-45000.00">I-40001.00-45000.00</option>
|
||||
<option value="J-45001.00-50000.00">J-45001.00-50000.00</option>
|
||||
<option value="K-50001.00-75000.00">K-50001.00-75000.00</option>
|
||||
<option value="L-75001.00-100000.00">L-75001.00-100000.00</option>
|
||||
<option value="M-100001.00-500000.00">M-100001.00-500000.00</option>
|
||||
<option value="0-20,000">A. 0 - 20,000</option>
|
||||
<option value="20,000-40,000">B. 20,000 - 40,000</option>
|
||||
<option value="40,001-50,000">C. 40,001 - 50,000</option>
|
||||
<option value="60,001-80,000">D. 60,001 - 80,000</option>
|
||||
<option value="80,001-100,000">E. 80,001 - 100,000</option>
|
||||
<option value="120,001-140,000">F. 120,001 - 140,000</option>
|
||||
<option value="140,001-180,000">G. 140,001 - 180,000</option>
|
||||
<option value="200,000-500,000">H. 200,000 - 500,000</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -117,10 +117,17 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
|
||||
const { watch, setValue, formState: { errors } } = useFormContext()
|
||||
const valor = ((watch(nombre) as string) ?? '').trim()
|
||||
const lada = valor.startsWith('+1') ? '+1' : '+52'
|
||||
const numero = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
|
||||
// Normaliza lo que se muestra: dígitos, últimos 10 (limpia ladas duplicadas
|
||||
// o caracteres invisibles heredados de registros viejos)
|
||||
const numero = (() => {
|
||||
const crudo = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
|
||||
const d = crudo.replace(/\D/g, '')
|
||||
return d.length > 10 ? d.slice(-10) : d
|
||||
})()
|
||||
|
||||
const actualizar = (nuevaLada: string, nuevoNumero: string) => {
|
||||
const limpio = nuevoNumero.trim()
|
||||
// Sólo dígitos y máximo 10 (la lada va en el selector)
|
||||
const limpio = nuevoNumero.replace(/\D/g, '').slice(0, 10)
|
||||
// Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
|
||||
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
|
||||
}
|
||||
@@ -144,12 +151,15 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
|
||||
<input
|
||||
id={nombre}
|
||||
type="tel"
|
||||
placeholder="664 123 4567"
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
placeholder="6641234567"
|
||||
value={numero}
|
||||
onChange={(e) => actualizar(lada, e.target.value)}
|
||||
className={claseInput(nombre, errors)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada</p>
|
||||
<MensajeError nombre={nombre} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import SignatureCanvas from 'react-signature-canvas'
|
||||
|
||||
interface Props {
|
||||
@@ -9,11 +9,10 @@ interface Props {
|
||||
export default function SignaturePad({ onFirma }: Props) {
|
||||
const ref = useRef<SignatureCanvas | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Notifica firma vacía al montar (estado inicial)
|
||||
onFirma('')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
// NOTA: no se notifica '' al montar. El pad se desmonta/remonta cada vez
|
||||
// que el asesor sale y vuelve al paso de firmas, y ese reset borraba la
|
||||
// firma ya capturada (los PDFs salían sin firma). El estado vacío inicial
|
||||
// ya lo maneja el formulario padre.
|
||||
|
||||
const limpiar = () => {
|
||||
ref.current?.clear()
|
||||
|
||||
@@ -62,8 +62,16 @@ export const datosGeneralesSchema = z
|
||||
peso: textoOpcional,
|
||||
estatura: textoOpcional,
|
||||
email: z.string().trim().email('Correo electrónico inválido'),
|
||||
telCasa: textoOpcional,
|
||||
celular: z.string().trim().min(8, 'El celular es requerido'),
|
||||
// Teléfonos: 10 dígitos (con o sin lada compuesta; el campo sólo acepta
|
||||
// dígitos en la UI, así que aquí sólo se valida la longitud mínima)
|
||||
telCasa: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v === '' || v.replace(/\D/g, '').length >= 10, 'El teléfono debe tener 10 dígitos'),
|
||||
celular: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v.replace(/\D/g, '').length >= 10, 'El celular debe tener 10 dígitos'),
|
||||
viviendaAnios: textoOpcional,
|
||||
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
|
||||
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
eliminarBorrador,
|
||||
generarLigaRemota,
|
||||
guardarBorrador,
|
||||
importarInformacionEnkontrol,
|
||||
iniciarProgreso,
|
||||
listarBorradores,
|
||||
obtenerBorrador,
|
||||
@@ -27,7 +28,9 @@ import {
|
||||
subirDocumentos,
|
||||
type BorradorResumen,
|
||||
type LeadResumen,
|
||||
type ResultadoImportacionEnkontrol,
|
||||
} from '../services/api'
|
||||
import { getSesion } from '../services/auth'
|
||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||
import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
||||
import { leadAPrellenado } from '../lib/leadAlta'
|
||||
@@ -137,6 +140,29 @@ export default function AltaClientePage() {
|
||||
const [ligaCopiada, setLigaCopiada] = useState(false)
|
||||
const [errorLiga, setErrorLiga] = useState('')
|
||||
|
||||
// ---------- Importación del documento de Enkontrol (solo administración) ----------
|
||||
const rolSesion = getSesion()?.usuario.rol ?? ''
|
||||
const puedeImportarEnkontrol = rolSesion === 'admin' || rolSesion === 'manager'
|
||||
const [importandoEnkontrol, setImportandoEnkontrol] = useState(false)
|
||||
const [resultadoImportacion, setResultadoImportacion] = useState<ResultadoImportacionEnkontrol | null>(null)
|
||||
const [errorImportacion, setErrorImportacion] = useState('')
|
||||
const inputImportacionRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const importarEnkontrol = async (archivo: File | undefined) => {
|
||||
if (!archivo) return
|
||||
setImportandoEnkontrol(true)
|
||||
setErrorImportacion('')
|
||||
setResultadoImportacion(null)
|
||||
try {
|
||||
setResultadoImportacion(await importarInformacionEnkontrol(archivo))
|
||||
} catch (err) {
|
||||
setErrorImportacion(err instanceof Error ? err.message : 'No se pudo importar el documento.')
|
||||
} finally {
|
||||
setImportandoEnkontrol(false)
|
||||
if (inputImportacionRef.current) inputImportacionRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la
|
||||
// oportunidad existente en vez de crear una nueva
|
||||
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
|
||||
@@ -337,8 +363,8 @@ export default function AltaClientePage() {
|
||||
const res = datos.progresoId
|
||||
? await completarProgreso(datos.progresoId, alta)
|
||||
: await crearAltaCliente(alta)
|
||||
toast.success("Alta de cliente completada")
|
||||
if (res.ok) {
|
||||
toast.success('Alta de cliente completada')
|
||||
const { fallidos } = await subirDocumentos(res.id, alta)
|
||||
setRegistro(res.id)
|
||||
setSinConexion(false)
|
||||
@@ -610,6 +636,51 @@ export default function AltaClientePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{puedeImportarEnkontrol && (
|
||||
<div className="mt-6 rounded-lg border border-[#E5E7EB] bg-white p-4 dark:border-[#334155] dark:bg-[#0F172A]">
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
Importar información de Enkontrol
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Sube el documento de Enkontrol (Excel o CSV). Cada fila se empareja con la ficha
|
||||
del cliente usando el ID de Enkontrol como campo determinante y su información se
|
||||
guarda en la ficha.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputImportacionRef.current?.click()}
|
||||
disabled={importandoEnkontrol}
|
||||
className="rounded-lg border border-accent bg-white px-4 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
||||
>
|
||||
{importandoEnkontrol ? 'Importando…' : 'Seleccionar documento…'}
|
||||
</button>
|
||||
<input
|
||||
ref={inputImportacionRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="sr-only"
|
||||
onChange={(e) => void importarEnkontrol(e.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
{resultadoImportacion && (
|
||||
<p role="status" className="mt-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-xs text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
Importación lista: {resultadoImportacion.actualizados} de {resultadoImportacion.totalFilas}{' '}
|
||||
clientes actualizados.
|
||||
{resultadoImportacion.noEncontrados.length > 0 && (
|
||||
<> Sin ficha local: {resultadoImportacion.noEncontrados.slice(0, 10).join(', ')}
|
||||
{resultadoImportacion.noEncontrados.length > 10 ? '…' : ''}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{errorImportacion && (
|
||||
<p role="alert" className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{errorImportacion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
|
||||
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
|
||||
celular (vigencia 72 h, sin usuario ni contraseña).
|
||||
|
||||
@@ -73,8 +73,7 @@ async function enviarAlta(ruta: string, payload: AltaCliente): Promise<Resultado
|
||||
/**
|
||||
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
||||
* SQLite.
|
||||
*/
|
||||
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||
*/export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||
return enviarAlta('/clientes', payload)
|
||||
}
|
||||
|
||||
@@ -414,3 +413,30 @@ export async function subirDocumentoRemoto(token: string, tipo: string, archivo:
|
||||
})
|
||||
manejarErrorLiga(res)
|
||||
}
|
||||
|
||||
|
||||
/** Resultado de la importación del documento de Enkontrol */
|
||||
export interface ResultadoImportacionEnkontrol {
|
||||
totalFilas: number
|
||||
actualizados: number
|
||||
noEncontrados: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /clientes/importar-enkontrol — sube el documento de Enkontrol (Excel o
|
||||
* CSV). El ID de Enkontrol es el campo determinante: cada fila actualiza la
|
||||
* ficha del cliente con ese usuarioEnkontrol.
|
||||
*/
|
||||
export async function importarInformacionEnkontrol(
|
||||
archivo: File,
|
||||
): Promise<ResultadoImportacionEnkontrol> {
|
||||
const form = new FormData()
|
||||
form.append('archivo', archivo, archivo.name)
|
||||
const res = await fetch(`${API_URL}/clientes/importar-enkontrol`, conAuth({ method: 'POST', body: form }))
|
||||
manejar401(res)
|
||||
if (!res.ok) {
|
||||
const cuerpo = (await res.json().catch(() => null)) as { error?: string } | null
|
||||
throw new Error(cuerpo?.error ?? `No se pudo importar (HTTP ${res.status})`)
|
||||
}
|
||||
return (await res.json()) as ResultadoImportacionEnkontrol
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import DarkModeToggle from './DarkModeToggle'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
// El toggle "Mis planes / Todos" vive en ?vista=: se conserva al navegar
|
||||
// entre Cobranza y Cobranza del día para que el alcance no se pierda.
|
||||
const { search } = useLocation()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
@@ -23,13 +27,13 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
<nav className="flex items-center gap-3 text-sm sm:gap-6">
|
||||
<DarkModeToggle />
|
||||
<Link
|
||||
to="/"
|
||||
to={`/${search}`}
|
||||
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Cobranza
|
||||
</Link>
|
||||
<Link
|
||||
to="/hoy"
|
||||
to={`/hoy${search}`}
|
||||
className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5"
|
||||
>
|
||||
Cobranza del día
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza'
|
||||
import { diasParaProximoVencimiento, pagoAtrasado, statusPago, statusPlan } from '../lib/cobranza'
|
||||
|
||||
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS'
|
||||
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS' | 'SIN_PLAN'
|
||||
|
||||
const ETIQUETA: Record<Semaforo, string> = {
|
||||
VERDE: 'Al corriente',
|
||||
AMBAR: 'Por vencer / parcial',
|
||||
ROJO: 'Vencido',
|
||||
GRIS: 'Cerrado',
|
||||
SIN_PLAN: 'Sin plan capturado',
|
||||
}
|
||||
|
||||
const CLASE_DOT: Record<Semaforo, string> = {
|
||||
@@ -15,20 +16,23 @@ const CLASE_DOT: Record<Semaforo, string> = {
|
||||
AMBAR: 'bg-amber-50 text-amber-700',
|
||||
ROJO: 'bg-red-50 text-red-700',
|
||||
GRIS: 'bg-gray-100 text-gray-600',
|
||||
SIN_PLAN: 'bg-orange-50 text-orange-700',
|
||||
}
|
||||
|
||||
/**
|
||||
* Semáforo del plan:
|
||||
* - "sin plan capturado": no hay calendario de pagos (no es lo mismo que liquidado)
|
||||
* - gris: LIQUIDADO o CANCELADO
|
||||
* - rojo: tiene pagos VENCIDOS
|
||||
* - rojo: tiene pagos VENCIDOS o PARCIALES vencidos
|
||||
* - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos
|
||||
* - verde: al corriente
|
||||
*/
|
||||
export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
|
||||
if (statusPlan(plan) !== 'ACTIVO') return 'GRIS'
|
||||
const estados = plan.pagos.map((p) => statusPago(p, hoy))
|
||||
if (estados.includes('VENCIDO')) return 'ROJO'
|
||||
if (estados.includes('PARCIAL')) return 'AMBAR'
|
||||
const status = statusPlan(plan)
|
||||
if (status === 'SIN_PLAN') return 'SIN_PLAN'
|
||||
if (status !== 'ACTIVO') return 'GRIS'
|
||||
if (plan.pagos.some((p) => pagoAtrasado(p, hoy))) return 'ROJO'
|
||||
if (plan.pagos.some((p) => statusPago(p, hoy) === 'PARCIAL')) return 'AMBAR'
|
||||
const dias = diasParaProximoVencimiento(plan, hoy)
|
||||
if (dias !== null && dias <= 7) return 'AMBAR'
|
||||
return 'VERDE'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user