Compare commits
30 Commits
d6548999bf
...
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 |
160
api-gateway/package-lock.json
generated
160
api-gateway/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
@@ -1086,6 +1088,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -2658,6 +2672,95 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2719,6 +2822,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prisma": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||||
@@ -3098,6 +3240,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/ssf": {
|
||||||
"version": "0.11.2",
|
"version": "0.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
@@ -3600,6 +3751,15 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
|
|||||||
@@ -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
|
clienteNombre String
|
||||||
clienteEmail String?
|
clienteEmail String?
|
||||||
clienteTelefono String?
|
clienteTelefono String?
|
||||||
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO
|
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO | FRASA_2026 (esquema único)
|
||||||
acabados Boolean
|
acabados Boolean
|
||||||
apartado Float // TODO prod: Decimal en Postgres
|
apartado Float // TODO prod: Decimal en Postgres
|
||||||
descuento Float // fracción (0.02 = 2%)
|
descuento Float // fracción (0.02 = 2%)
|
||||||
@@ -31,6 +31,20 @@ model Cotizacion {
|
|||||||
numMensualidades Int
|
numMensualidades Int
|
||||||
mensualidad Float? // null => N/A (Contado)
|
mensualidad Float? // null => N/A (Contado)
|
||||||
pagoFinal Float
|
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?
|
mondayItemId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -173,6 +187,10 @@ model LeadCaptura {
|
|||||||
usuarioId String // Usuario.id del capturista
|
usuarioId String // Usuario.id del capturista
|
||||||
nombreLead String
|
nombreLead String
|
||||||
telefono 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 del embudo donde vive el registro: LEADS | OPORTUNIDADES | APARTADO
|
||||||
board String @default("LEADS")
|
board String @default("LEADS")
|
||||||
// Etapa/estado local actual (NUEVO al capturar)
|
// Etapa/estado local actual (NUEVO al capturar)
|
||||||
@@ -198,6 +216,7 @@ model LeadCaptura {
|
|||||||
@@index([usuarioId])
|
@@index([usuarioId])
|
||||||
@@index([mondayItemId])
|
@@index([mondayItemId])
|
||||||
@@index([nombreLead])
|
@@index([nombreLead])
|
||||||
|
@@index([telefonoNorm])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Espejo local del inventario (tablero Unidades de Monday). Lo siembra
|
// Espejo local del inventario (tablero Unidades de Monday). Lo siembra
|
||||||
@@ -299,6 +318,9 @@ model Usuario {
|
|||||||
// User ID de Monday (people). null = el usuario aún no tiene cuenta Monday;
|
// 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
|
// los filtros "míos" de /api/mi-dia responden vacíos con sinMapeoMonday: true
|
||||||
mondayUserId Int?
|
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)
|
activo Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
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())
|
||||||
@@ -16,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', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
|
||||||
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
||||||
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
{ 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' },
|
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
||||||
],
|
],
|
||||||
VENTAS: [
|
VENTAS: [
|
||||||
@@ -31,13 +40,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
|
|||||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||||
],
|
],
|
||||||
ESCRITURACION: [
|
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: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||||
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
||||||
|
|||||||
@@ -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'
|
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[]> = {
|
const ESPERADOS: Record<string, string[]> = {
|
||||||
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||||
administracion_venta: [
|
administracion_venta: [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const MODULOS = [
|
|||||||
'cobranza',
|
'cobranza',
|
||||||
'contactos',
|
'contactos',
|
||||||
'chat',
|
'chat',
|
||||||
|
'whatsapp',
|
||||||
'clientes-maestro',
|
'clientes-maestro',
|
||||||
'captura',
|
'captura',
|
||||||
'soporte',
|
'soporte',
|
||||||
@@ -39,6 +40,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
|
|||||||
'mi-tablero',
|
'mi-tablero',
|
||||||
'contactos',
|
'contactos',
|
||||||
'chat',
|
'chat',
|
||||||
|
'whatsapp',
|
||||||
'clientes-maestro',
|
'clientes-maestro',
|
||||||
'captura',
|
'captura',
|
||||||
'soporte',
|
'soporte',
|
||||||
@@ -49,7 +51,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
|||||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
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: mismos módulos que admin, sin poder crear usuarios ni editar permisos
|
||||||
// ni dar de alta desarrollos (módulo exclusivo del admin)
|
// ni dar de alta desarrollos (módulo exclusivo del admin)
|
||||||
manager: MODULOS.filter((m) => m !== 'desarrollos'),
|
manager: MODULOS.filter((m) => m !== 'desarrollos'),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
export const VOCABULARIO = {
|
export const VOCABULARIO = {
|
||||||
// Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del
|
// Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del
|
||||||
// API + NUEVO (captura) y DESCARTADO (terminal)
|
// 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')
|
// Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado')
|
||||||
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
||||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la
|
// 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
|
// Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO
|
||||||
// agregados 2026-09 — existen en captura e inventario)
|
// agregados 2026-09 — existen en captura e inventario)
|
||||||
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'],
|
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
|
} 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 FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
|
||||||
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
|
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
|
||||||
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[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' } } },
|
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
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({
|
res.json({
|
||||||
id: conversacion.id,
|
id: conversacion.id,
|
||||||
createdAt: conversacion.createdAt,
|
createdAt: conversacion.createdAt,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const cambiarEstadoSchema = z.object({
|
|||||||
motivo: z.string().optional(),
|
motivo: z.string().optional(),
|
||||||
subcategoria: z.string().optional(),
|
subcategoria: z.string().optional(),
|
||||||
comentario: 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 body = cambiarEstadoSchema.parse(req.body)
|
||||||
const itemId = String(req.params['itemId'])
|
const itemId = String(req.params['itemId'])
|
||||||
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
||||||
|
fecha: body.fecha,
|
||||||
usuarioId: req.usuarioToken?.sub,
|
usuarioId: req.usuarioToken?.sub,
|
||||||
sombra: !esIdLocal(itemId),
|
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
|
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
|
||||||
// para los reportes de cancelación (Cliente/Motivo).
|
// para los reportes de cancelación (Cliente/Motivo).
|
||||||
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
|
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
|
|||||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: 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([])
|
buscarMock.mockResolvedValue([])
|
||||||
const r = res()
|
const r = res()
|
||||||
await buscarEndpoint(req({ q: 'María' }), r)
|
await buscarEndpoint(req({ q: 'María' }), r)
|
||||||
expect(buscarMock).toHaveBeenCalledWith('María', 20)
|
expect(buscarMock).toHaveBeenCalledWith('María', 20, undefined)
|
||||||
expect(r.json).toHaveBeenCalledWith([])
|
expect(r.json).toHaveBeenCalledWith([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -34,25 +34,47 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
|
|||||||
buscarMock.mockResolvedValue([])
|
buscarMock.mockResolvedValue([])
|
||||||
const r = res()
|
const r = res()
|
||||||
await buscarEndpoint(req({}), r)
|
await buscarEndpoint(req({}), r)
|
||||||
expect(buscarMock).toHaveBeenCalledWith('', 20)
|
expect(buscarMock).toHaveBeenCalledWith('', 20, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('limite válido del query string', async () => {
|
it('limite válido del query string', async () => {
|
||||||
buscarMock.mockResolvedValue([])
|
buscarMock.mockResolvedValue([])
|
||||||
const r = res()
|
const r = res()
|
||||||
await buscarEndpoint(req({ q: 'x', limite: '10' }), r)
|
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 () => {
|
it('limite inválido o > 50 se clampa a 20', async () => {
|
||||||
buscarMock.mockResolvedValue([])
|
buscarMock.mockResolvedValue([])
|
||||||
const r = res()
|
const r = res()
|
||||||
await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r)
|
await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r)
|
||||||
expect(buscarMock).toHaveBeenCalledWith('x', 20)
|
expect(buscarMock).toHaveBeenCalledWith('x', 20, undefined)
|
||||||
|
|
||||||
const r2 = res()
|
const r2 = res()
|
||||||
await buscarEndpoint(req({ q: 'x', limite: '100' }), r2)
|
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 () => {
|
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> {
|
export async function buscarEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
const q = typeof req.query.q === 'string' ? req.query.q : ''
|
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 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)
|
res.json(resultados)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe('formaPagoLabel (tipos de crédito del alta)', () => {
|
|||||||
|
|
||||||
describe('crearClienteSchema', () => {
|
describe('crearClienteSchema', () => {
|
||||||
const base = {
|
const base = {
|
||||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
|
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
|
||||||
viviendaCredito: { proyecto: 'SENDA' },
|
viviendaCredito: { proyecto: 'SENDA' },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ describe('crearClienteSchema', () => {
|
|||||||
// ---------- Alta local (POST /api/clientes) ----------
|
// ---------- Alta local (POST /api/clientes) ----------
|
||||||
|
|
||||||
const altaBase = {
|
const altaBase = {
|
||||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
|
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
|
||||||
viviendaCredito: { proyecto: 'SENDA' },
|
viviendaCredito: { proyecto: 'SENDA' },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +124,15 @@ const oportunidadLocal = {
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modelo: 'SOHO',
|
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', () => {
|
describe('crearCliente (POST /api/clientes) — alta local', () => {
|
||||||
@@ -352,7 +360,7 @@ describe('alta progresiva — local', () => {
|
|||||||
mondayApartadoId: null,
|
mondayApartadoId: null,
|
||||||
})
|
})
|
||||||
const altaCompleta = {
|
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 },
|
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
|
||||||
leadMondayId: 'loc_op_9',
|
leadMondayId: 'loc_op_9',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
|
import * as XLSX from 'xlsx'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
@@ -20,7 +21,8 @@ export const crearClienteSchema = z
|
|||||||
curp: z.string().min(1),
|
curp: z.string().min(1),
|
||||||
rfc: z.string().min(1),
|
rfc: z.string().min(1),
|
||||||
email: z.string().optional(),
|
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(),
|
direccion: z.string().optional(),
|
||||||
// Perfil demográfico (opcionales; viajan en el payload del alta)
|
// Perfil demográfico (opcionales; viajan en el payload del alta)
|
||||||
fechaNacimiento: z.string().optional(),
|
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 {
|
return {
|
||||||
apartado: {
|
apartado: {
|
||||||
id: captura.leadId,
|
id: captura.leadId,
|
||||||
@@ -545,6 +563,7 @@ async function prefillApartadoLocal(itemId: string) {
|
|||||||
Email: cliente.email ?? '',
|
Email: cliente.email ?? '',
|
||||||
CURP: cliente.curp,
|
CURP: cliente.curp,
|
||||||
RFC: cliente.rfc,
|
RFC: cliente.rfc,
|
||||||
|
...contactoExtra,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
oportunidad: oportunidadLocal
|
oportunidad: oportunidadLocal
|
||||||
@@ -558,3 +577,132 @@ async function prefillApartadoLocal(itemId: string) {
|
|||||||
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
|
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`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
|
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
|
||||||
import { DESARROLLOS } from '../services/cotizacion.service.js'
|
import { DESARROLLOS, calcularNumMensualidades } from '../services/cotizacion.service.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
|
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
|
||||||
@@ -69,7 +69,28 @@ const bodyBase = {
|
|||||||
acabados: false,
|
acabados: false,
|
||||||
clienteNombre: 'Cliente Demo',
|
clienteNombre: 'Cliente Demo',
|
||||||
clienteTelefono: '4771234567',
|
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 {
|
function reqFake(body: unknown): Request {
|
||||||
@@ -110,10 +131,54 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
|||||||
expect(status).toHaveBeenCalledWith(201)
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
|
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||||
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
|
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
|
||||||
expect(respuesta['propuesta']).toBeDefined()
|
expect(respuesta['esquema']).toBeDefined()
|
||||||
expect(respuesta['folio']).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 () => {
|
it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
|
||||||
prismaMock.unidad.findUnique.mockResolvedValue(null)
|
prismaMock.unidad.findUnique.mockResolvedValue(null)
|
||||||
const { res } = resFake()
|
const { res } = resFake()
|
||||||
@@ -195,7 +260,7 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
|||||||
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
|
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toma la fecha de entrega de la captura: mensualidad y total ya no salen nulos', async () => {
|
it('sin numMensualidades usa el default (14), acotado a los meses que faltan para la entrega', async () => {
|
||||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||||
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
|
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||||
])
|
])
|
||||||
@@ -203,15 +268,15 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
|||||||
|
|
||||||
await crearCotizacion(reqFake(bodyBase), res)
|
await crearCotizacion(reqFake(bodyBase), res)
|
||||||
|
|
||||||
|
const esperadas = Math.min(14, calcularNumMensualidades('2027-10-08'))
|
||||||
const respuesta = json.mock.calls[0]?.[0] as {
|
const respuesta = json.mock.calls[0]?.[0] as {
|
||||||
numMensualidades: number
|
numMensualidades: number
|
||||||
mensualidad: number
|
mensualidad: number
|
||||||
propuesta: { numMensualidades: number; mensualidad: number; mensualidadesTotal: number }
|
esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number }
|
||||||
}
|
}
|
||||||
expect(respuesta.numMensualidades).toBeGreaterThan(0)
|
expect(respuesta.numMensualidades).toBe(esperadas)
|
||||||
expect(respuesta.numMensualidades).toBe(respuesta.propuesta.numMensualidades)
|
expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades)
|
||||||
expect(respuesta.propuesta.mensualidad).toBeGreaterThan(0)
|
expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / esperadas, 6)
|
||||||
expect(respuesta.propuesta.mensualidadesTotal).toBeGreaterThan(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
|
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
|
||||||
@@ -219,13 +284,79 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
|||||||
|
|
||||||
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
|
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
|
||||||
|
|
||||||
// DIAGONAL bancario sin acabados: remanente 75,000 en 12 pagos
|
// Enganche diferible $75,000 en 12 pagos
|
||||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }),
|
data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }),
|
||||||
})
|
})
|
||||||
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; propuesta: { numMensualidades: number } }
|
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
|
||||||
expect(respuesta.numMensualidades).toBe(12)
|
expect(respuesta.numMensualidades).toBe(12)
|
||||||
expect(respuesta.propuesta.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 () => {
|
it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => {
|
||||||
@@ -266,47 +397,41 @@ describe('crearCotizacion — reglas comerciales del admin', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => {
|
it('sin reglas guardadas cotiza con el esquema único (respaldo en constantes)', async () => {
|
||||||
const { res, status } = resFake()
|
const { res, status } = resFake()
|
||||||
|
|
||||||
await crearCotizacion(reqFake(bodyBase), res)
|
await crearCotizacion(reqFake(bodyBase), res)
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(201)
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0 }),
|
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0, precioFinal: 1_000_000 }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('usa el descuento configurado por el admin', async () => {
|
it('los descuentos que configure el admin YA NO aplican: el esquema único no los usa', async () => {
|
||||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||||
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
|
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
|
||||||
)
|
)
|
||||||
const { res, status } = resFake()
|
const { res, status } = resFake()
|
||||||
|
|
||||||
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res)
|
await crearCotizacion(reqFake(bodyBase), res)
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(201)
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }),
|
data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => {
|
it('propuesta desactivada por el admin ya NO bloquea: el esquema único no usa propuestas', async () => {
|
||||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||||
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
|
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
|
||||||
)
|
)
|
||||||
const { res } = resFake()
|
const { res, status } = resFake()
|
||||||
|
|
||||||
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
|
await crearCotizacion(reqFake(bodyBase), res)
|
||||||
status: 409,
|
|
||||||
message: expect.stringContaining('La propuesta BANCARIO no está disponible'),
|
|
||||||
})
|
|
||||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// Las otras propuestas siguen cotizándose
|
|
||||||
const { res: res2, status } = resFake()
|
|
||||||
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2)
|
|
||||||
expect(status).toHaveBeenCalledWith(201)
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(prismaMock.cotizacion.create).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
|
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
|
||||||
@@ -391,3 +516,27 @@ describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario)
|
|||||||
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
|
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,12 +1,16 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getUnidadLocal } from '../services/unidades-local.service.js'
|
import { getUnidadLocal } from '../services/unidades-local.service.js'
|
||||||
|
import { calcularNumMensualidades, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||||
import {
|
import {
|
||||||
calcularPropuesta,
|
calcularEsquemaFrasa,
|
||||||
obtenerPrecioLista,
|
MENSUALIDADES_DEFAULT,
|
||||||
propuestasVigentes,
|
MENSUALIDADES_MAX,
|
||||||
TIPO_PROPUESTA_API,
|
MENSUALIDADES_MIN,
|
||||||
} from '../services/cotizacion.service.js'
|
PCT_ENGANCHE_FIRMA,
|
||||||
|
PCT_ESCRITURACION,
|
||||||
|
type EsquemaFrasaResultado,
|
||||||
|
} from '../services/cotizacion-esquema.service.js'
|
||||||
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
|
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
|
||||||
import {
|
import {
|
||||||
procesarAlertasInventario,
|
procesarAlertasInventario,
|
||||||
@@ -27,19 +31,30 @@ const crearCotizacionSchema = z.object({
|
|||||||
clienteNombre: z.string().min(1),
|
clienteNombre: z.string().min(1),
|
||||||
clienteEmail: z.string().email().optional(),
|
clienteEmail: z.string().email().optional(),
|
||||||
clienteTelefono: z.string().min(7).optional(),
|
clienteTelefono: z.string().min(7).optional(),
|
||||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
// Esquema único FRASA 2026: mensualidades del diferimiento (elección del
|
||||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
// vendedor, 1..36; sin ellas va el default de 14) y si la escrituración
|
||||||
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
|
// (5.5%) se difiere con el enganche o se paga a la firma
|
||||||
// esquemas con crédito (bancario/Infonavit). El Excel permite 2.5%–5%.
|
numMensualidades: z
|
||||||
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
|
.number()
|
||||||
// Mensualidades elegidas por el asesor; sin él se resuelve con la fecha de
|
.int('Las mensualidades deben ser un número entero')
|
||||||
// entrega de la unidad (o el plazo por defecto del desarrollo)
|
.min(MENSUALIDADES_MIN, `Mínimo ${MENSUALIDADES_MIN} mensualidad`)
|
||||||
numMensualidades: z.number().int().min(1).max(120).optional(),
|
.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
|
// 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)
|
// día manda el id; sin él se busca por teléfono)
|
||||||
oportunidadId: z.string().trim().min(1).optional(),
|
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) */
|
/** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */
|
||||||
function validarUnidadDisponible(unidad: { codigo: string; status: string }): void {
|
function validarUnidadDisponible(unidad: { codigo: string; status: string }): void {
|
||||||
const status = unidad.status.trim().toUpperCase()
|
const status = unidad.status.trim().toUpperCase()
|
||||||
@@ -83,8 +98,9 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
|||||||
validarUnidadDisponible(unidad)
|
validarUnidadDisponible(unidad)
|
||||||
|
|
||||||
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
|
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
|
||||||
// respaldo en las constantes históricas. Un desarrollo o una propuesta
|
// respaldo en las constantes históricas. Un desarrollo desactivado se
|
||||||
// desactivados se rechazan con el mensaje que muestra el cotizador.
|
// 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)
|
const reglas = await getReglasDesarrollo(unidad.desarrollo)
|
||||||
if (reglas.activoEnCotizador === false) {
|
if (reglas.activoEnCotizador === false) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -92,26 +108,33 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
|||||||
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
|
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const tipoInterno = TIPO_PROPUESTA_API[body.tipoPropuesta]
|
|
||||||
if (!propuestasVigentes(reglas).some((p) => p.tipo === tipoInterno)) {
|
|
||||||
throw new AppError(
|
|
||||||
409,
|
|
||||||
`La propuesta ${body.tipoPropuesta} no está disponible para el desarrollo ${reglas.id}. Elige otra propuesta.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
// El API RECALCULA el esquema server-side con el precio de lista de la
|
||||||
const propuesta = calcularPropuesta(
|
// unidad: no se confía en los montos que manda el cliente.
|
||||||
unidad,
|
const apartado = body.apartado ?? APARTADO_DEFAULT
|
||||||
body.tipoPropuesta,
|
// El diferimiento no puede rebasar la entrega estimada de la unidad (captura
|
||||||
body.acabados,
|
// de obra): se acota a los meses que faltan (36 sigue siendo el tope global)
|
||||||
apartado,
|
const topeMensualidades = unidad.fechaEntrega
|
||||||
body.descuento,
|
? Math.min(MENSUALIDADES_MAX, Math.max(MENSUALIDADES_MIN, calcularNumMensualidades(unidad.fechaEntrega)))
|
||||||
body.enganchePct,
|
: MENSUALIDADES_MAX
|
||||||
body.numMensualidades,
|
const numMensualidades = Math.min(body.numMensualidades ?? MENSUALIDADES_DEFAULT, topeMensualidades)
|
||||||
reglas,
|
const escrituracionDiferida = body.escrituracionDiferida ?? false
|
||||||
)
|
|
||||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
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({
|
const cotizacion = await prisma.cotizacion.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -121,18 +144,30 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
|||||||
clienteNombre: body.clienteNombre,
|
clienteNombre: body.clienteNombre,
|
||||||
clienteEmail: body.clienteEmail ?? null,
|
clienteEmail: body.clienteEmail ?? null,
|
||||||
clienteTelefono: body.clienteTelefono ?? null,
|
clienteTelefono: body.clienteTelefono ?? null,
|
||||||
tipoPropuesta: body.tipoPropuesta,
|
|
||||||
acabados: body.acabados,
|
acabados: body.acabados,
|
||||||
apartado,
|
apartado,
|
||||||
descuento: propuesta.descuento,
|
|
||||||
precioLista,
|
precioLista,
|
||||||
precioFinal: propuesta.precioFinal,
|
// Columnas nuevas del esquema único
|
||||||
enganche: propuesta.enganche,
|
esquema: 'FRASA_2026',
|
||||||
// 0 => no se pudo resolver el plazo (no debería ocurrir: los desarrollos
|
saldoFirma: esquema.saldoFirma,
|
||||||
// tienen plazo por defecto). Si no, el N elegido por el vendedor.
|
pctEngancheFirma: PCT_ENGANCHE_FIRMA,
|
||||||
numMensualidades: propuesta.numMensualidades ?? 0,
|
engancheDiferible: esquema.enganche,
|
||||||
mensualidad: propuesta.mensualidad,
|
pctEscrituracion: PCT_ESCRITURACION,
|
||||||
pagoFinal: propuesta.pagoFinal,
|
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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -149,8 +184,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
|||||||
mondayItemId = opExistente
|
mondayItemId = opExistente
|
||||||
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
||||||
try {
|
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', {
|
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
|
||||||
sombra: !esIdLocal(opExistente),
|
sombra: false,
|
||||||
usuarioId: req.usuarioToken?.sub,
|
usuarioId: req.usuarioToken?.sub,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -159,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)
|
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +254,15 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro
|
|||||||
folio: folioCotizacion(cotizacion.id),
|
folio: folioCotizacion(cotizacion.id),
|
||||||
desarrollo: cotizacion.desarrollo,
|
desarrollo: cotizacion.desarrollo,
|
||||||
unidadCodigo: cotizacion.unidadCodigo,
|
unidadCodigo: cotizacion.unidadCodigo,
|
||||||
|
// Esquema único FRASA 2026 (filas nuevas) + legacy (filas BANCARIO, …)
|
||||||
|
esquema: cotizacion.esquema,
|
||||||
tipoPropuesta: cotizacion.tipoPropuesta,
|
tipoPropuesta: cotizacion.tipoPropuesta,
|
||||||
|
precioLista: cotizacion.precioLista,
|
||||||
|
apartado: cotizacion.apartado,
|
||||||
|
saldoFirma: cotizacion.saldoFirma,
|
||||||
|
engancheDiferible: cotizacion.engancheDiferible,
|
||||||
|
escrituracion: cotizacion.escrituracion,
|
||||||
|
escrituracionDiferida: cotizacion.escrituracionDiferida,
|
||||||
precioFinal: cotizacion.precioFinal,
|
precioFinal: cotizacion.precioFinal,
|
||||||
enganche: cotizacion.enganche,
|
enganche: cotizacion.enganche,
|
||||||
numMensualidades: cotizacion.numMensualidades,
|
numMensualidades: cotizacion.numMensualidades,
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ export async function eliminarUnidadEndpoint(req: Request, res: Response): Promi
|
|||||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
|
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) ──────────────────────────────────────────────
|
// ── Público (micrositio) ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** GET /api/publico/desarrollos */
|
/** GET /api/publico/desarrollos */
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
automatizacionState: {
|
automatizacionState: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -83,6 +84,7 @@ beforeEach(() => {
|
|||||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||||
|
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
|
||||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
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({
|
reqCon({
|
||||||
body: {
|
body: {
|
||||||
nombre: 'Lead Prueba',
|
nombre: 'Lead Prueba',
|
||||||
|
telefono: '4771234567',
|
||||||
oportunidadId: 'loc_op_3',
|
oportunidadId: 'loc_op_3',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modelo: 'SOHO',
|
modelo: 'SOHO',
|
||||||
@@ -448,17 +451,30 @@ describe('admin CRUD de leads — local', () => {
|
|||||||
expect(json).toHaveBeenCalledWith({ eliminado: true, itemId: 'loc_1' })
|
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.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()
|
const { res, json } = resFake()
|
||||||
|
|
||||||
await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res)
|
await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res)
|
||||||
|
|
||||||
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
|
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
|
||||||
where: { leadId: 'loc_1' },
|
where: { leadId: { in: ['loc_1'] } },
|
||||||
data: { usuarioId: 'usr-9' },
|
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 () => {
|
it('asignarLeadEndpoint con usuario inexistente → 404', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { VOCABULARIO } from '../config/vocabulario.js'
|
import { VOCABULARIO, afluenciaCompleta } from '../config/vocabulario.js'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||||
import * as auditoria from '../services/auditoria.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 */
|
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
|
||||||
const crearAfluenciaSchema = z.object({
|
const crearAfluenciaSchema = z.object({
|
||||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
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(),
|
email: z.string().trim().email('Email inválido').optional(),
|
||||||
origen: z.string().trim().min(1).optional(),
|
origen: z.string().trim().min(1).optional(),
|
||||||
desarrollo: 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)
|
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
|
* 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)
|
* 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'),
|
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> {
|
export async function asignarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
const itemId = String(req.params['itemId'])
|
const itemId = String(req.params['itemId'])
|
||||||
const { usuarioId } = asignarLeadSchema.parse(req.body)
|
const { usuarioId } = asignarLeadSchema.parse(req.body)
|
||||||
@@ -328,13 +351,9 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise<
|
|||||||
select: { nombre: true },
|
select: { nombre: true },
|
||||||
})
|
})
|
||||||
if (!usuario) throw new NotFoundError('Usuario no encontrado')
|
if (!usuario) throw new NotFoundError('Usuario no encontrado')
|
||||||
// La asignación local es la verdad: se actualiza la fila del lead
|
const movidas = await crmLocal.reasignarCadena(itemId, usuarioId)
|
||||||
await prisma.leadCaptura.updateMany({
|
res.json({ itemId, usuarioId, nombre: usuario.nombre, movidas })
|
||||||
where: { leadId: itemId },
|
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, `${usuario.nombre} (${movidas} fila(s))`)
|
||||||
data: { usuarioId },
|
|
||||||
})
|
|
||||||
res.json({ itemId, usuarioId, nombre: usuario.nombre })
|
|
||||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const moverLeadSchema = z.object({
|
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> {
|
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
const q = String(req.query['q'] ?? '').trim()
|
const q = String(req.query['q'] ?? '').trim()
|
||||||
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson 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({
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||||
leadId: 'loc_op',
|
leadId: 'loc_op',
|
||||||
usuarioId: 'u-1',
|
usuarioId: 'u-1',
|
||||||
@@ -163,7 +165,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: 'ATELIER',
|
desarrollo: 'ATELIER',
|
||||||
modelo: 'SOHO',
|
modelo: 'SOHO',
|
||||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
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()
|
const { res, json } = resFake()
|
||||||
|
|
||||||
@@ -179,7 +189,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
board: 'APARTADO',
|
board: 'APARTADO',
|
||||||
etapa: 'Pago Apartado',
|
etapa: 'Pago Apartado',
|
||||||
nombreLead: 'Luis Demo',
|
nombreLead: 'Luis Demo',
|
||||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
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
|
// La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
|
||||||
@@ -190,6 +208,26 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
|
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',
|
||||||
|
nombreLead: 'Luis Demo',
|
||||||
|
telefono: '6641112233',
|
||||||
|
email: null,
|
||||||
|
desarrollo: 'ATELIER',
|
||||||
|
modelo: 'SOHO',
|
||||||
|
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
||||||
|
})
|
||||||
|
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 () => {
|
it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
|
||||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||||
leadId: 'loc_op',
|
leadId: 'loc_op',
|
||||||
@@ -199,7 +237,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modelo: 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',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
|
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
|
||||||
const { res } = resFake()
|
const { res } = resFake()
|
||||||
@@ -212,7 +257,17 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
select: { unidadCodigo: true },
|
select: { unidadCodigo: true },
|
||||||
})
|
})
|
||||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ datosJson: JSON.stringify({ unidadCodigo: 'SENDA-411-1-7' }) }),
|
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',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
|
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
|
||||||
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
|
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
|
||||||
@@ -229,7 +284,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: null,
|
desarrollo: null,
|
||||||
modelo: 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()
|
const { res } = resFake()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
const svcMock = vi.hoisted(() => ({
|
const svcMock = vi.hoisted(() => ({
|
||||||
crear: vi.fn(),
|
crear: vi.fn(),
|
||||||
listar: vi.fn(),
|
listar: vi.fn(),
|
||||||
|
listarPorCliente: vi.fn(),
|
||||||
completar: vi.fn(),
|
completar: vi.fn(),
|
||||||
eliminar: vi.fn(),
|
eliminar: vi.fn(),
|
||||||
proximos: vi.fn(),
|
proximos: vi.fn(),
|
||||||
@@ -72,6 +73,33 @@ describe('recordatorio-vendedor.controller', () => {
|
|||||||
expect(res.json).toHaveBeenCalledWith([])
|
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 () => {
|
it('completar: llama al service con el id', async () => {
|
||||||
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
|
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
|
||||||
const res = resJson()
|
const res = resJson()
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export async function listarRecordatoriosEndpoint(req: Request, res: Response):
|
|||||||
const usuarioId = req.usuarioToken?.sub
|
const usuarioId = req.usuarioToken?.sub
|
||||||
if (!usuarioId) throw new Error('Auth requerida')
|
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 completadosStr = req.query['completados'] as string | undefined
|
||||||
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = {
|
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = {
|
||||||
desde: (req.query['desde'] as 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> => {
|
listar: async (req: Request, res: Response): Promise<void> => {
|
||||||
const usuarioId = req.usuarioToken?.sub
|
const usuarioId = req.usuarioToken?.sub
|
||||||
if (!usuarioId) throw new Error('Auth requerida')
|
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 completadosStr = req.query['completados'] as string | undefined
|
||||||
const opts: {
|
const opts: {
|
||||||
completados?: boolean
|
completados?: boolean
|
||||||
|
|||||||
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 { config } from './config/index.js'
|
||||||
import { automatizacionesService } from './services/automatizaciones.service.js'
|
import { automatizacionesService } from './services/automatizaciones.service.js'
|
||||||
import * as configService from './services/config.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'
|
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
|
||||||
|
|
||||||
const app = crearApp()
|
const app = crearApp()
|
||||||
@@ -64,6 +65,18 @@ if (config.automatizaciones.enabled) {
|
|||||||
})
|
})
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
|
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.)
|
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import {
|
|||||||
actualizarProgreso,
|
actualizarProgreso,
|
||||||
completarProgreso,
|
completarProgreso,
|
||||||
crearCliente,
|
crearCliente,
|
||||||
|
importarEnkontrolEndpoint,
|
||||||
iniciarProgreso,
|
iniciarProgreso,
|
||||||
listarDocumentos,
|
listarDocumentos,
|
||||||
obtenerCliente,
|
obtenerCliente,
|
||||||
prefillApartadoEndpoint,
|
prefillApartadoEndpoint,
|
||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
uploadDocumentos,
|
uploadDocumentos,
|
||||||
|
uploadImportacionEnkontrol,
|
||||||
} from '../controllers/clientes.controller.js'
|
} from '../controllers/clientes.controller.js'
|
||||||
import {
|
import {
|
||||||
eliminarBorrador,
|
eliminarBorrador,
|
||||||
@@ -57,6 +59,7 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda
|
|||||||
import {
|
import {
|
||||||
cambiarEstadoLeadEndpoint,
|
cambiarEstadoLeadEndpoint,
|
||||||
crearAfluenciaEndpoint,
|
crearAfluenciaEndpoint,
|
||||||
|
obtenerAfluenciaEndpoint,
|
||||||
crearLeadEndpoint,
|
crearLeadEndpoint,
|
||||||
buscarLeadsEndpoint,
|
buscarLeadsEndpoint,
|
||||||
convertirLeadEndpoint,
|
convertirLeadEndpoint,
|
||||||
@@ -93,6 +96,14 @@ import {
|
|||||||
subirDocumento,
|
subirDocumento,
|
||||||
} from '../controllers/ai.controller.js'
|
} from '../controllers/ai.controller.js'
|
||||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.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 { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
|
||||||
import {
|
import {
|
||||||
requireRolReportes,
|
requireRolReportes,
|
||||||
@@ -167,6 +178,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
actualizarDesarrolloEndpoint,
|
actualizarDesarrolloEndpoint,
|
||||||
actualizarModeloEndpoint,
|
actualizarModeloEndpoint,
|
||||||
|
actualizarUnidadEndpoint,
|
||||||
agregarModeloEndpoint,
|
agregarModeloEndpoint,
|
||||||
crearDesarrolloEndpoint,
|
crearDesarrolloEndpoint,
|
||||||
crearUnidadesEndpoint,
|
crearUnidadesEndpoint,
|
||||||
@@ -206,6 +218,14 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
|||||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||||
|
|
||||||
apiRouter.post('/clientes', crearCliente)
|
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"
|
// Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
|
||||||
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
|
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
|
||||||
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
|
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
|
||||||
@@ -274,6 +294,15 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
|||||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||||
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
||||||
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
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
|
// 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)
|
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
||||||
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
||||||
@@ -305,6 +334,8 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
|
|||||||
|
|
||||||
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
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)
|
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
||||||
|
|
||||||
@@ -339,9 +370,11 @@ apiRouter.post('/ai/chat/stream', chatIAStream)
|
|||||||
apiRouter.get('/ai/conversations', listarConversaciones)
|
apiRouter.get('/ai/conversations', listarConversaciones)
|
||||||
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||||
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
|
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
|
||||||
apiRouter.post('/ai/documents', subirDocumento)
|
// Mantenimiento de la base de conocimiento: SOLO admin (admin/manager no
|
||||||
apiRouter.get('/ai/documents', listarDocumentosIA)
|
// basta: borrar o inyectar documentos afecta a todos los usuarios del chat).
|
||||||
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
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
|
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
|
||||||
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
|
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
|
||||||
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
|
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
|
||||||
@@ -404,4 +437,5 @@ apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEnd
|
|||||||
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
|
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
|
||||||
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
|
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
|
||||||
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
|
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
|
||||||
|
apiRouter.patch('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, actualizarUnidadEndpoint)
|
||||||
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)
|
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
|
|||||||
import { prisma } from './prisma.js'
|
import { prisma } from './prisma.js'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
import { esAdminOManager } from '../utils/roles.js'
|
||||||
import { getUnidadesLocal } from './unidades-local.service.js'
|
import { getUnidadesLocal } from './unidades-local.service.js'
|
||||||
import {
|
import {
|
||||||
APARTADO_DEFAULT,
|
APARTADO_DEFAULT,
|
||||||
@@ -1020,10 +1021,12 @@ export function crearEjecutoresHerramientas(
|
|||||||
})
|
})
|
||||||
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
|
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
|
||||||
const hoy = new Date()
|
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) => {
|
return planes.map((p) => {
|
||||||
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
|
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 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)
|
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
|
||||||
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
|
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
|
||||||
const abonos = p.pagos
|
const abonos = p.pagos
|
||||||
@@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas(
|
|||||||
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
|
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
|
||||||
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
|
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
|
||||||
prisma.pago.findMany({
|
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: {
|
select: {
|
||||||
monto: true,
|
monto: true,
|
||||||
montoPagado: true,
|
montoPagado: true,
|
||||||
@@ -1505,6 +1509,18 @@ export async function chat(
|
|||||||
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
|
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
|
||||||
: null
|
: null
|
||||||
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
|
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({
|
conversacion ??= await prisma.aIConversacion.create({
|
||||||
data: { usuarioId: opciones.usuarioId ?? null },
|
data: { usuarioId: opciones.usuarioId ?? null },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ describe('buscar', () => {
|
|||||||
prismaMock.usuario.findMany.mockResolvedValue([])
|
prismaMock.usuario.findMany.mockResolvedValue([])
|
||||||
await buscar({ desde, hasta })
|
await buscar({ desde, hasta })
|
||||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
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' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
|
|||||||
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
|
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
|
||||||
where['createdAt'] = {
|
where['createdAt'] = {
|
||||||
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
|
...(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({
|
const logs = await prisma.auditoriaLog.findMany({
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ describe('buscar (GET /api/buscar)', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const r = await buscar('X')
|
const r = await buscar('X')
|
||||||
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
|
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/hoy/')
|
||||||
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
|
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 === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
|
||||||
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
|
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,43 +13,59 @@ export interface BuscadoItem {
|
|||||||
* (oportunidades con cotización), y Unidad (inventario).
|
* (oportunidades con cotización), y Unidad (inventario).
|
||||||
* Case-insensitive sobre los campos indicados; limita a `limite` resultados.
|
* 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()
|
const q = termino.trim()
|
||||||
if (q === '') return []
|
if (q === '') return []
|
||||||
const pat = `%${q}%`
|
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([
|
const [leads, clientes, cotizaciones, unidades] = await Promise.all([
|
||||||
prisma.leadCaptura.findMany({
|
prisma.leadCaptura.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
AND: [propioLead, {
|
||||||
{ nombreLead: { contains: q } },
|
OR: [
|
||||||
{ telefono: { contains: q } },
|
{ nombreLead: { contains: q } },
|
||||||
{ email: { contains: q } },
|
{ telefono: { contains: q } },
|
||||||
],
|
{ email: { contains: q } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
take: limite,
|
take: limite,
|
||||||
}),
|
}),
|
||||||
prisma.cliente.findMany({
|
prisma.cliente.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
AND: [propioCliente, {
|
||||||
{ nombreCompleto: { contains: q } },
|
OR: [
|
||||||
{ curp: { contains: q } },
|
{ nombreCompleto: { contains: q } },
|
||||||
{ rfc: { contains: q } },
|
{ curp: { contains: q } },
|
||||||
{ email: { contains: q } },
|
{ rfc: { contains: q } },
|
||||||
{ celular: { contains: q } },
|
{ email: { contains: q } },
|
||||||
],
|
{ celular: { contains: q } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limite,
|
take: limite,
|
||||||
}),
|
}),
|
||||||
prisma.cotizacion.findMany({
|
prisma.cotizacion.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
AND: [propiaCotizacion, {
|
||||||
{ clienteNombre: { contains: q } },
|
OR: [
|
||||||
{ clienteEmail: { contains: q } },
|
{ clienteNombre: { contains: q } },
|
||||||
{ unidadCodigo: { contains: q } },
|
{ clienteEmail: { contains: q } },
|
||||||
],
|
{ unidadCodigo: { contains: q } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limite,
|
take: limite,
|
||||||
@@ -72,14 +88,14 @@ export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[
|
|||||||
id: l.leadId,
|
id: l.leadId,
|
||||||
nombre: l.nombreLead,
|
nombre: l.nombreLead,
|
||||||
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
|
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
|
||||||
url: `/app/#leads`,
|
url: `/hoy/`,
|
||||||
})),
|
})),
|
||||||
...clientes.map((c) => ({
|
...clientes.map((c) => ({
|
||||||
tipo: 'contacto' as const,
|
tipo: 'contacto' as const,
|
||||||
id: c.id,
|
id: c.id,
|
||||||
nombre: c.nombreCompleto,
|
nombre: c.nombreCompleto,
|
||||||
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
|
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
|
||||||
url: `/app/#clientes`,
|
url: `/`,
|
||||||
})),
|
})),
|
||||||
...cotizaciones.map((ct) => ({
|
...cotizaciones.map((ct) => ({
|
||||||
tipo: 'oportunidad' as const,
|
tipo: 'oportunidad' as const,
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ describe('fechas de Obra bloqueadas', () => {
|
|||||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
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([
|
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||||
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
|
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
|
||||||
])
|
])
|
||||||
@@ -201,11 +201,11 @@ describe('fechas de Obra bloqueadas', () => {
|
|||||||
guardarCampo(
|
guardarCampo(
|
||||||
'SENDA',
|
'SENDA',
|
||||||
'U-101',
|
'U-101',
|
||||||
'ESCRITURACION',
|
'OBRA',
|
||||||
'entrega_programada_fecha',
|
'entrega_programada_fecha',
|
||||||
'2026-10-15',
|
'2026-10-15',
|
||||||
'user-1',
|
'user-1',
|
||||||
'escrituracion',
|
'obra',
|
||||||
),
|
),
|
||||||
).rejects.toThrow('solo puede modificarlas un administrador')
|
).rejects.toThrow('solo puede modificarlas un administrador')
|
||||||
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()
|
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
const prismaMock = vi.hoisted(() => ({
|
const prismaMock = vi.hoisted(() => ({
|
||||||
cliente: { findMany: vi.fn() },
|
cliente: { findMany: vi.fn() },
|
||||||
leadCaptura: { findMany: vi.fn() },
|
leadCaptura: { findMany: vi.fn() },
|
||||||
|
usuario: { findMany: vi.fn() },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
@@ -13,12 +14,42 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||||
|
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', username: 'alfonso castillo' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('exportarCSV', () => {
|
describe('exportarCSV', () => {
|
||||||
it('genera CSV con encabezados cuando no hay contactos', async () => {
|
it('genera CSV con encabezados cuando no hay contactos', async () => {
|
||||||
const csv = await exportarCSV()
|
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 () => {
|
it('incluye clientes y capturas en el CSV', async () => {
|
||||||
|
|||||||
@@ -212,7 +212,8 @@ describe('listarContactosLocal', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
|
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(
|
prismaMock.cliente.findMany.mockResolvedValue(
|
||||||
Array.from({ length: 101 }, (_, i) =>
|
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) }),
|
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
|
||||||
@@ -225,13 +226,12 @@ describe('listarContactosLocal', () => {
|
|||||||
expect(pagina1.contactos).toHaveLength(100)
|
expect(pagina1.contactos).toHaveLength(100)
|
||||||
expect(pagina1.hayMas).toBe(true)
|
expect(pagina1.hayMas).toBe(true)
|
||||||
|
|
||||||
// La página 2 recibe el siguiente bloque (skip=100, take=101)
|
// La página 2 trae la fila más vieja (orden desc por fecha: la 101 va
|
||||||
prismaMock.cliente.findMany.mockResolvedValue([
|
// primero en la página 1)
|
||||||
clienteFake({ id: 'c101', celular: '4770000100' }),
|
|
||||||
])
|
|
||||||
const pagina2 = await listarContactosLocal(undefined, 2)
|
const pagina2 = await listarContactosLocal(undefined, 2)
|
||||||
expect(pagina2.pagina).toBe(2)
|
expect(pagina2.pagina).toBe(2)
|
||||||
expect(pagina2.contactos).toHaveLength(1)
|
expect(pagina2.contactos).toHaveLength(1)
|
||||||
|
expect(pagina2.contactos[0]?.id).toBe('c1')
|
||||||
expect(pagina2.hayMas).toBe(false)
|
expect(pagina2.hayMas).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
|||||||
const porPagina = CONTACTOS_POR_PAGINA
|
const porPagina = CONTACTOS_POR_PAGINA
|
||||||
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
||||||
const skip = (paginaValida - 1) * porPagina
|
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({
|
const clientes = await prisma.cliente.findMany({
|
||||||
where: usuarioId ? { capturadoPorId: usuarioId } : {},
|
where: usuarioId ? { capturadoPorId: usuarioId } : {},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip,
|
|
||||||
take: porPagina + 1,
|
|
||||||
})
|
})
|
||||||
const capturas = await prisma.leadCaptura.findMany({
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -105,8 +105,6 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
|||||||
...(usuarioId ? { usuarioId } : {}),
|
...(usuarioId ? { usuarioId } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
skip,
|
|
||||||
take: porPagina + 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const lista: Candidato[] = []
|
const lista: Candidato[] = []
|
||||||
@@ -207,12 +205,11 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
const hayMas = ordenada.length > porPagina
|
|
||||||
return {
|
return {
|
||||||
contactos: ordenada.slice(0, porPagina),
|
contactos: ordenada.slice(skip, skip + porPagina),
|
||||||
pagina: paginaValida,
|
pagina: paginaValida,
|
||||||
porPagina,
|
porPagina,
|
||||||
hayMas,
|
hayMas: ordenada.length > skip + porPagina,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +447,9 @@ export async function exportarCSV(
|
|||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
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 {
|
interface FilaCSV {
|
||||||
Nombre: string
|
Nombre: string
|
||||||
@@ -458,6 +458,7 @@ export async function exportarCSV(
|
|||||||
Desarrollo: string
|
Desarrollo: string
|
||||||
Estatus: string
|
Estatus: string
|
||||||
'Última actividad': string
|
'Última actividad': string
|
||||||
|
Asesor: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const filas: FilaCSV[] = []
|
const filas: FilaCSV[] = []
|
||||||
@@ -479,6 +480,7 @@ export async function exportarCSV(
|
|||||||
Desarrollo: c.proyecto,
|
Desarrollo: c.proyecto,
|
||||||
Estatus: 'Cliente',
|
Estatus: 'Cliente',
|
||||||
'Última actividad': c.createdAt.toISOString().slice(0, 10),
|
'Última actividad': c.createdAt.toISOString().slice(0, 10),
|
||||||
|
Asesor: c.capturadoPorId ? (nombreAsesor.get(c.capturadoPorId) ?? '') : '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +496,7 @@ export async function exportarCSV(
|
|||||||
Desarrollo: cap.desarrollo ?? '',
|
Desarrollo: cap.desarrollo ?? '',
|
||||||
Estatus: cap.etapa,
|
Estatus: cap.etapa,
|
||||||
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
|
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
|
||||||
|
Asesor: cap.usuarioId ? (nombreAsesor.get(cap.usuarioId) ?? '') : '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,10 +512,10 @@ export async function exportarCSV(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generar CSV
|
// 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 escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
|
||||||
const lineas = filtradas.map((f) =>
|
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')
|
return [encabezado, ...lineas].join('\n')
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
automatizacionState: {
|
automatizacionState: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -43,6 +44,7 @@ beforeEach(() => {
|
|||||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
|
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
|
||||||
prismaMock.leadOportunidad.create.mockResolvedValue({})
|
prismaMock.leadOportunidad.create.mockResolvedValue({})
|
||||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||||
|
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
|
||||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -234,9 +236,26 @@ describe('CrmLocalService.apartar', () => {
|
|||||||
email: 'luis@correo.com',
|
email: 'luis@correo.com',
|
||||||
desarrollo: 'ATELIER',
|
desarrollo: 'ATELIER',
|
||||||
modelo: 'SOHO',
|
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 () => {
|
it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
|
||||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
|
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
|
||||||
const apartado = await svc.apartar('loc_op', 'u-1')
|
const apartado = await svc.apartar('loc_op', 'u-1')
|
||||||
@@ -311,8 +330,326 @@ describe('CrmLocalService.buscarLocales', () => {
|
|||||||
modeloInteres: 'SOHO',
|
modeloInteres: 'SOHO',
|
||||||
})
|
})
|
||||||
expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' })
|
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(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 { randomUUID } from 'node:crypto'
|
||||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
import { afluenciaCompleta } from '../config/vocabulario.js'
|
||||||
import { claveApartadoCreado } from './automatizaciones.service.js'
|
import { claveApartadoCreado } from './automatizaciones.service.js'
|
||||||
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
||||||
import { prisma } from './prisma.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).
|
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
|
||||||
*/
|
*/
|
||||||
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
||||||
// Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER
|
const telNorm =
|
||||||
// tablero: evita que la misma persona quede como lead y como afluencia.
|
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
|
||||||
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
|
const emailNorm =
|
||||||
const telLimpio = soloDigitos(datos.telefono).slice(-10)
|
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
|
||||||
if (telLimpio.length >= 7) {
|
// Serializa la captura por teléfono/email: dos envíos simultáneos no deben
|
||||||
const existente = await buscarPorTelefono(telLimpio)
|
// pasar juntos la validación de duplicado.
|
||||||
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
|
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
|
// Dedup por email (minúsculas): mismo criterio que el teléfono (cualquier
|
||||||
if (datos.email !== undefined && datos.email.trim() !== '') {
|
// 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({
|
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 } }
|
if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } }
|
||||||
}
|
}
|
||||||
@@ -100,7 +110,8 @@ export class CrmLocalService {
|
|||||||
usuarioId: usuarioId ?? 'desconocido',
|
usuarioId: usuarioId ?? 'desconocido',
|
||||||
nombreLead: datos.nombre,
|
nombreLead: datos.nombre,
|
||||||
telefono: datos.telefono ?? null,
|
telefono: datos.telefono ?? null,
|
||||||
email: datos.email ?? null,
|
telefonoNorm: telNorm,
|
||||||
|
email: emailNorm,
|
||||||
origen: datos.origen ?? null,
|
origen: datos.origen ?? null,
|
||||||
desarrollo: datos.desarrollo ?? null,
|
desarrollo: datos.desarrollo ?? null,
|
||||||
board: 'LEADS',
|
board: 'LEADS',
|
||||||
@@ -110,6 +121,7 @@ export class CrmLocalService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { id: fila.leadId, oportunidadId: null }
|
return { id: fila.leadId, oportunidadId: null }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,35 +135,22 @@ export class CrmLocalService {
|
|||||||
|
|
||||||
if (oportunidadId !== undefined) {
|
if (oportunidadId !== undefined) {
|
||||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
||||||
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
|
// Sin fila local: id inválido o liga guardada de una limpieza anterior.
|
||||||
// ese id para que la captura no se pierda en silencio.
|
// Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
|
||||||
|
// rechaza para no sembrar registros huérfanos en Oportunidades.
|
||||||
if (!existente) {
|
if (!existente) {
|
||||||
await prisma.leadCaptura.create({
|
throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sólo se pisan los campos capturados: lo que no viene en el payload se
|
// 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).
|
// conserva (antes se borraba con null y se perdía la cita agendada).
|
||||||
const cambio: Record<string, unknown> = { pendienteSync: true }
|
const cambio: Record<string, unknown> = { pendienteSync: true }
|
||||||
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
|
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
|
||||||
if (datos.telefono !== undefined) cambio['telefono'] = datos.telefono
|
if (datos.telefono !== undefined) {
|
||||||
if (datos.email !== undefined) cambio['email'] = datos.email
|
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.origen !== undefined) cambio['origen'] = datos.origen
|
||||||
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
|
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
|
||||||
if (modelo !== undefined) cambio['modelo'] = modelo
|
if (modelo !== undefined) cambio['modelo'] = modelo
|
||||||
@@ -176,12 +175,16 @@ export class CrmLocalService {
|
|||||||
return { id: oportunidadId, oportunidadId, actualizada: true }
|
return { id: oportunidadId, oportunidadId, actualizada: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
|
const telNorm =
|
||||||
const telLimpio = soloDigitos(datos.telefono).slice(-10)
|
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
|
||||||
if (telLimpio.length >= 7) {
|
const emailNorm =
|
||||||
const duplicado = await buscarPorTelefono(telLimpio)
|
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
|
||||||
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
|
// 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()
|
const id = idLocal()
|
||||||
@@ -193,7 +196,8 @@ export class CrmLocalService {
|
|||||||
usuarioId: usuarioId ?? 'desconocido',
|
usuarioId: usuarioId ?? 'desconocido',
|
||||||
nombreLead: datos.nombre,
|
nombreLead: datos.nombre,
|
||||||
telefono: datos.telefono ?? null,
|
telefono: datos.telefono ?? null,
|
||||||
email: datos.email ?? null,
|
telefonoNorm: telNorm,
|
||||||
|
email: emailNorm,
|
||||||
origen: datos.origen ?? null,
|
origen: datos.origen ?? null,
|
||||||
desarrollo: datos.desarrollo ?? null,
|
desarrollo: datos.desarrollo ?? null,
|
||||||
modelo: modelo ?? null,
|
modelo: modelo ?? null,
|
||||||
@@ -208,6 +212,7 @@ export class CrmLocalService {
|
|||||||
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
|
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
|
||||||
])
|
])
|
||||||
return { id, oportunidadId: id }
|
return { id, oportunidadId: id }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,9 +249,10 @@ export class CrmLocalService {
|
|||||||
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
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 }
|
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 } })
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
|
||||||
let datos: Record<string, unknown> = {}
|
let datos: Record<string, unknown> = {}
|
||||||
if (existente?.datosJson) {
|
if (existente?.datosJson) {
|
||||||
@@ -317,6 +323,7 @@ export class CrmLocalService {
|
|||||||
usuarioId: lead.usuarioId,
|
usuarioId: lead.usuarioId,
|
||||||
nombreLead: lead.nombreLead,
|
nombreLead: lead.nombreLead,
|
||||||
telefono: lead.telefono,
|
telefono: lead.telefono,
|
||||||
|
telefonoNorm: lead.telefonoNorm,
|
||||||
email: lead.email,
|
email: lead.email,
|
||||||
origen: lead.origen,
|
origen: lead.origen,
|
||||||
desarrollo: lead.desarrollo,
|
desarrollo: lead.desarrollo,
|
||||||
@@ -362,6 +369,11 @@ export class CrmLocalService {
|
|||||||
!esIdLocal(oportunidadIdLocal),
|
!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 clave = claveApartadoCreado(oportunidadIdLocal)
|
||||||
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
||||||
if (previo !== null) {
|
if (previo !== null) {
|
||||||
@@ -382,6 +394,7 @@ export class CrmLocalService {
|
|||||||
usuarioId: oportunidad.usuarioId,
|
usuarioId: oportunidad.usuarioId,
|
||||||
nombreLead: oportunidad.nombreLead,
|
nombreLead: oportunidad.nombreLead,
|
||||||
telefono: oportunidad.telefono,
|
telefono: oportunidad.telefono,
|
||||||
|
telefonoNorm: oportunidad.telefonoNorm,
|
||||||
email: oportunidad.email,
|
email: oportunidad.email,
|
||||||
origen: oportunidad.origen,
|
origen: oportunidad.origen,
|
||||||
desarrollo: oportunidad.desarrollo,
|
desarrollo: oportunidad.desarrollo,
|
||||||
@@ -413,20 +426,81 @@ export class CrmLocalService {
|
|||||||
/**
|
/**
|
||||||
* Búsqueda local para el picker del alta de clientes (y el buscador del
|
* Búsqueda local para el picker del alta de clientes (y el buscador del
|
||||||
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular.
|
* 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({
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] },
|
where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
take: 15,
|
take: 50,
|
||||||
})
|
})
|
||||||
const clientes = await prisma.cliente.findMany({
|
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' },
|
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 = [
|
const candidatos = [
|
||||||
...capturas.map((c) => ({
|
...capturas
|
||||||
|
.filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino))
|
||||||
|
.map((c) => ({
|
||||||
id: c.leadId,
|
id: c.leadId,
|
||||||
nombre: c.nombreLead,
|
nombre: c.nombreLead,
|
||||||
telefono: c.telefono ?? '',
|
telefono: c.telefono ?? '',
|
||||||
@@ -438,7 +512,9 @@ export class CrmLocalService {
|
|||||||
modeloInteres: c.modelo ?? '',
|
modeloInteres: c.modelo ?? '',
|
||||||
orden: c.updatedAt,
|
orden: c.updatedAt,
|
||||||
})),
|
})),
|
||||||
...clientes.map((c) => ({
|
...clientes
|
||||||
|
.filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino))
|
||||||
|
.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
nombre: c.nombreCompleto,
|
nombre: c.nombreCompleto,
|
||||||
telefono: c.celular ?? '',
|
telefono: c.celular ?? '',
|
||||||
@@ -456,6 +532,62 @@ export class CrmLocalService {
|
|||||||
.slice(0, 15)
|
.slice(0, 15)
|
||||||
.map(({ orden: _orden, ...resto }) => resto)
|
.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) */
|
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
|
||||||
@@ -481,6 +613,38 @@ function soloDigitos(telefono: string): string {
|
|||||||
return telefono.replace(/\D/g, '')
|
return telefono.replace(/\D/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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
|
* `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva
|
||||||
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
|
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
|
||||||
@@ -501,12 +665,25 @@ function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero.
|
* Etapas que cierran el ciclo de un registro: descartados, perdidos y
|
||||||
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados.
|
* 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.
|
||||||
*/
|
*/
|
||||||
async function buscarPorTelefono(diezDigitos: string) {
|
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({
|
return prisma.leadCaptura.findFirst({
|
||||||
where: { telefono: { contains: diezDigitos } },
|
where: { telefonoNorm: telefonoNormalizado, etapa: { notIn: ETAPAS_CERRADAS } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,24 @@ export const crearUnidadesSchema = z
|
|||||||
|
|
||||||
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
|
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 ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ModeloFila {
|
interface ModeloFila {
|
||||||
@@ -635,6 +653,39 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
|
|||||||
await prisma.unidad.delete({ where: { codigo } })
|
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) ────────────────────────────────────
|
// ── Público (micrositio, sin auth) ────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ describe('obtenerScores', () => {
|
|||||||
where: {
|
where: {
|
||||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||||
usuarioId: 'u1',
|
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> = {
|
const ETAPA_SCORE: Record<string, number> = {
|
||||||
NUEVO: 20,
|
NUEVO: 20,
|
||||||
|
'INTENTO DE CONTACTO': 30,
|
||||||
CONTACTADO: 40,
|
CONTACTADO: 40,
|
||||||
INTERESADO: 60,
|
INTERESADO: 60,
|
||||||
AGENDO: 80,
|
'AGENDO CITA': 80,
|
||||||
CALIFICADO: 90,
|
CALIFICADO: 90,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,8 @@ export function calcularScore(lead: {
|
|||||||
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
|
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||||
|
// Los cerrados (descartados/perdidos) no reciben score ni salen en el ranking
|
||||||
|
etapa: { notIn: ['Descartado', 'Perdido'] },
|
||||||
}
|
}
|
||||||
if (usuarioId) {
|
if (usuarioId) {
|
||||||
where['usuarioId'] = usuarioId
|
where['usuarioId'] = usuarioId
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* resuelve por el usuarioId de cada fila.
|
* 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
|
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
||||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
||||||
|
|
||||||
@@ -73,6 +75,8 @@ export interface LeadMiDia {
|
|||||||
/** Vendedor asignado (nombre); '' si no tiene */
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
vendedor: string
|
vendedor: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
@@ -85,6 +89,8 @@ export interface ActividadMiDia {
|
|||||||
tipo: string
|
tipo: string
|
||||||
fecha: string
|
fecha: string
|
||||||
estado: string
|
estado: string
|
||||||
|
/** Captura (lead/oportunidad/apartado) a la que pertenece la actividad; null si no está ligada */
|
||||||
|
leadCapturaId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApartadoPorVencer {
|
export interface ApartadoPorVencer {
|
||||||
@@ -127,10 +133,14 @@ export interface OportunidadMiDia {
|
|||||||
modeloInteres: string
|
modeloInteres: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
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') */
|
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
||||||
fechaCita?: string | undefined
|
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) */
|
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
|
||||||
@@ -146,10 +156,32 @@ export interface ApartadoMiDia {
|
|||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
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 {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
@@ -165,6 +197,8 @@ export interface ResumenMiDia {
|
|||||||
misApartados: ApartadoMiDia[]
|
misApartados: ApartadoMiDia[]
|
||||||
/** Todos los apartados incluyendo terminales (para Escrituración) */
|
/** Todos los apartados incluyendo terminales (para Escrituración) */
|
||||||
todosApartados: ApartadoMiDia[]
|
todosApartados: ApartadoMiDia[]
|
||||||
|
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
|
||||||
|
descartados: DescartadoMiDia[]
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
||||||
actividadesAbiertas: number // mías, Estado Abierto
|
actividadesAbiertas: number // mías, Estado Abierto
|
||||||
@@ -206,6 +240,8 @@ export interface FilaLocalMiDia {
|
|||||||
usuarioId: string
|
usuarioId: string
|
||||||
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
|
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
|
||||||
mondayItemId: string | null
|
mondayItemId: string | null
|
||||||
|
/** Motivo de descarte/cancelación (columna motivo de LeadCaptura) */
|
||||||
|
motivo: string | null
|
||||||
pendienteSync: boolean
|
pendienteSync: boolean
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -296,6 +332,7 @@ export function resumenMiDiaLocal(
|
|||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
fuente: 'lead' as const,
|
fuente: 'lead' as const,
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
@@ -330,12 +367,15 @@ export function resumenMiDiaLocal(
|
|||||||
email: c.email ?? '',
|
email: c.email ?? '',
|
||||||
etapa: c.etapa,
|
etapa: c.etapa,
|
||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
|
origen: c.origen ?? '',
|
||||||
modeloInteres: c.modelo ?? '',
|
modeloInteres: c.modelo ?? '',
|
||||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
unidadCodigo,
|
unidadCodigo,
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
fechaCita,
|
fechaCita,
|
||||||
|
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -360,6 +400,7 @@ export function resumenMiDiaLocal(
|
|||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}))
|
}))
|
||||||
@@ -381,6 +422,7 @@ export function resumenMiDiaLocal(
|
|||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}))
|
}))
|
||||||
@@ -449,6 +491,7 @@ export function resumenMiDiaLocal(
|
|||||||
tipo: a.tipo,
|
tipo: a.tipo,
|
||||||
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
|
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
|
||||||
estado: a.estado,
|
estado: a.estado,
|
||||||
|
leadCapturaId: a.leadCapturaId,
|
||||||
}))
|
}))
|
||||||
const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas
|
const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas
|
||||||
const misActividadesHoy: 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 {
|
return {
|
||||||
generadoEn: ahora.toISOString(),
|
generadoEn: ahora.toISOString(),
|
||||||
misLeads,
|
misLeads,
|
||||||
@@ -541,6 +606,7 @@ export function resumenMiDiaLocal(
|
|||||||
misApartados,
|
misApartados,
|
||||||
// Todos los apartados incluyendo terminales (para Escrituración)
|
// Todos los apartados incluyendo terminales (para Escrituración)
|
||||||
todosApartados: todosApartadosLocal,
|
todosApartados: todosApartadosLocal,
|
||||||
|
descartados,
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
|
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
|
||||||
(c) => c.createdAt.getTime() >= hace7DiasMs,
|
(c) => c.createdAt.getTime() >= hace7DiasMs,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
},
|
},
|
||||||
|
actividadLocal: {
|
||||||
|
create: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
@@ -55,6 +59,17 @@ describe('RecordatorioVendedorService', () => {
|
|||||||
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
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', () => {
|
describe('completar', () => {
|
||||||
it('marca como completado un recordatorio existente', async () => {
|
it('marca como completado un recordatorio existente y cierra su tarea del calendario', async () => {
|
||||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
|
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1', leadId: 'loc_1', usuarioId })
|
||||||
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
|
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
|
||||||
|
|
||||||
const result = await svc.completar('r1')
|
const result = await svc.completar('r1')
|
||||||
|
|
||||||
expect(result.completado).toBe(true)
|
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({
|
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'r1' },
|
where: { id: 'r1' },
|
||||||
data: { completado: true },
|
data: { completado: true },
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface OpcionesListarRecordatorio {
|
|||||||
|
|
||||||
export class RecordatorioVendedorService {
|
export class RecordatorioVendedorService {
|
||||||
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
|
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
|
||||||
return prisma.recordatorioVendedor.create({
|
const recordatorio = await prisma.recordatorioVendedor.create({
|
||||||
data: {
|
data: {
|
||||||
usuarioId,
|
usuarioId,
|
||||||
titulo: data.titulo.trim(),
|
titulo: data.titulo.trim(),
|
||||||
@@ -41,6 +41,20 @@ export class RecordatorioVendedorService {
|
|||||||
fechaRecordar: new Date(data.fechaRecordar),
|
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 = {}) {
|
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) {
|
async completar(id: string) {
|
||||||
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
||||||
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
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({
|
return prisma.recordatorioVendedor.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { completado: true },
|
data: { completado: true },
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
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 type { Pago, PlanPago } from './cobranza.service.js'
|
||||||
import {
|
import {
|
||||||
RecordatoriosCobranzaService,
|
RecordatoriosCobranzaService,
|
||||||
@@ -231,7 +238,29 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
|
|||||||
expect(log).toHaveLength(1)
|
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 = [
|
const planesSinVendedor = [
|
||||||
plan({
|
plan({
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
|
|||||||
@@ -130,10 +130,11 @@ export function debeEnviar(
|
|||||||
export interface DestinatarioRecordatorio {
|
export interface DestinatarioRecordatorio {
|
||||||
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
|
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
|
||||||
vendedorId: string | null
|
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 {
|
export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio {
|
||||||
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
|
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
|
||||||
return { vendedorId: null, origen: 'ninguno' }
|
return { vendedorId: null, origen: 'ninguno' }
|
||||||
@@ -218,9 +219,21 @@ export class RecordatoriosCobranzaService {
|
|||||||
const planes = await this.store.cargarPlanes()
|
const planes = await this.store.cargarPlanes()
|
||||||
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
|
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: [] }
|
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
|
||||||
for (const c of candidatos) {
|
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)
|
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
|
||||||
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
|
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
|
||||||
|
|||||||
@@ -77,15 +77,17 @@ describe('construirMiTablero', () => {
|
|||||||
captura({ board: 'LEADS' }),
|
captura({ board: 'LEADS' }),
|
||||||
captura({ board: 'LEADS' }),
|
captura({ board: 'LEADS' }),
|
||||||
captura({ board: 'OPORTUNIDADES' }),
|
captura({ board: 'OPORTUNIDADES' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
captura({ board: 'APARTADO', etapa: 'CANCELO' }),
|
||||||
|
captura({ board: 'APARTADO', etapa: 'ESCRITURADO' }),
|
||||||
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
||||||
]
|
]
|
||||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||||
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
|
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
|
||||||
expect(tablero.conversion.oportunidades).toBe(1)
|
expect(tablero.conversion.oportunidades).toBe(1)
|
||||||
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
|
// Cancelo no cuenta; Firma Cliente y Escriturado ya son ventas
|
||||||
expect(tablero.conversion.vendidos).toBe(1)
|
expect(tablero.conversion.apartados).toBe(0)
|
||||||
|
expect(tablero.conversion.vendidos).toBe(2)
|
||||||
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -146,9 +148,9 @@ describe('construirMiTablero', () => {
|
|||||||
|
|
||||||
it('agrupa por desarrollo solo con vendidos', () => {
|
it('agrupa por desarrollo solo con vendidos', () => {
|
||||||
const capturas = [
|
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: '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
|
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
|
||||||
]
|
]
|
||||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||||
|
|||||||
@@ -122,13 +122,19 @@ export function construirMiTablero(
|
|||||||
// -- Conversión (funnel) --
|
// -- Conversión (funnel) --
|
||||||
const leads = capturas.filter((c) => c.board === 'LEADS').length
|
const leads = capturas.filter((c) => c.board === 'LEADS').length
|
||||||
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').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(
|
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
|
).length
|
||||||
const vendidos = capturas.filter(
|
const vendidos = capturas.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.board === 'APARTADO' &&
|
c.board === 'APARTADO' &&
|
||||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
|
||||||
).length
|
).length
|
||||||
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
||||||
|
|
||||||
@@ -184,7 +190,7 @@ export function construirMiTablero(
|
|||||||
for (const c of capturas) {
|
for (const c of capturas) {
|
||||||
if (
|
if (
|
||||||
c.board === 'APARTADO' &&
|
c.board === 'APARTADO' &&
|
||||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
|
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
|
||||||
c.desarrollo
|
c.desarrollo
|
||||||
) {
|
) {
|
||||||
const dev = c.desarrollo.trim()
|
const dev = c.desarrollo.trim()
|
||||||
|
|||||||
@@ -89,12 +89,13 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
|
|||||||
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
|
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('completa fechaEntrega desde la captura: entrega programada > habitabilidad > inicio de obra', async () => {
|
it('completa fechaEntrega desde la captura: reprogramada > programada > habitabilidad > inicio de obra', async () => {
|
||||||
prismaMock.unidad.findMany.mockResolvedValue([
|
prismaMock.unidad.findMany.mockResolvedValue([
|
||||||
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
|
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
|
||||||
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
|
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
|
||||||
fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
|
fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
|
||||||
fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
|
fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
|
||||||
|
fila({ codigo: 'S-3', desarrollo: 'SENDA' }),
|
||||||
])
|
])
|
||||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||||
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
|
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
|
||||||
@@ -104,11 +105,16 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
|
|||||||
// Valor vacío / no-fecha: no cuenta como dato
|
// Valor vacío / no-fecha: no cuenta como dato
|
||||||
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
|
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
|
||||||
{ unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' },
|
{ 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()
|
const todas = await getUnidadesLocal()
|
||||||
|
|
||||||
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2027-11-20', null, '2027-05-05', null])
|
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2028-02-15', null, '2027-05-05', null, '2027-07-01'])
|
||||||
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
|
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
|
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ import type { UnidadCotizable } from './cotizacion.service.js'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
|
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
|
||||||
* en orden de prioridad:
|
* en orden de prioridad (el primero con fecha válida gana):
|
||||||
* 1. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda (ESCRITURACION)
|
* 1. `entrega_reprogramada_fecha` — Fecha Reprogramada Entrega (la más reciente,
|
||||||
* 2. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL
|
* cuando obra movió la entrega es la fecha que manda)
|
||||||
* 3. `inicio_obra_fecha` — último recurso (ESCRITURACION)
|
* 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 = [
|
export const CAMPOS_FECHA_ENTREGA = [
|
||||||
|
'entrega_reprogramada_fecha',
|
||||||
'entrega_programada_fecha',
|
'entrega_programada_fecha',
|
||||||
'habitabilidad',
|
'habitabilidad',
|
||||||
'inicio_obra_fecha',
|
'inicio_obra_fecha',
|
||||||
|
|||||||
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(/^Estado civil/), { target: { value: 'Casado(a)' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
|
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
|
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(/^Crédito/), { target: { value: 'Infonavit' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
|
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
|
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
|
||||||
@@ -141,7 +141,7 @@ describe('AltaAfluenciaForm', () => {
|
|||||||
estadoCivil: 'Casado(a)',
|
estadoCivil: 'Casado(a)',
|
||||||
tipoEmpleo: 'Asalariado',
|
tipoEmpleo: 'Asalariado',
|
||||||
ocupacion: 'Ingeniero',
|
ocupacion: 'Ingeniero',
|
||||||
rangoSalarial: 'F-25001.00-30000.00',
|
rangoSalarial: '20,000-40,000',
|
||||||
credito: 'Infonavit',
|
credito: 'Infonavit',
|
||||||
tipoEconomia: 'Economía mexicana',
|
tipoEconomia: 'Economía mexicana',
|
||||||
califica: 'Sí',
|
califica: 'Sí',
|
||||||
|
|||||||
@@ -155,7 +155,12 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
nombrePila,
|
nombrePila,
|
||||||
apellidoPaterno,
|
apellidoPaterno,
|
||||||
apellidoMaterno,
|
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,
|
email: lead.email,
|
||||||
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
|
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
|
||||||
desarrollo,
|
desarrollo,
|
||||||
@@ -173,6 +178,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
const nombreCompuesto = nombreCompleto()
|
const nombreCompuesto = nombreCompleto()
|
||||||
if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio'
|
if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio'
|
||||||
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono 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) {
|
if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
|
||||||
nuevos.email = 'Correo electrónico inválido'
|
nuevos.email = 'Correo electrónico inválido'
|
||||||
}
|
}
|
||||||
@@ -423,12 +431,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
<input
|
<input
|
||||||
id="afluencia-telefono"
|
id="afluencia-telefono"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="664 123 4567"
|
inputMode="numeric"
|
||||||
|
maxLength={10}
|
||||||
|
placeholder="6641234567"
|
||||||
value={form.telefono}
|
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' : ''}`}
|
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>}
|
{errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -582,19 +597,14 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
className={inputClase}
|
className={inputClase}
|
||||||
>
|
>
|
||||||
<option value="">— Selecciona —</option>
|
<option value="">— Selecciona —</option>
|
||||||
<option value="A-0-10000.00">A-0-10000.00</option>
|
<option value="0-20,000">A. 0 - 20,000</option>
|
||||||
<option value="B-10000.01-15000.00">B-10000.01-15000.00</option>
|
<option value="20,000-40,000">B. 20,000 - 40,000</option>
|
||||||
<option value="C-15000.01-20000.00">C-15000.01-20000.00</option>
|
<option value="40,001-50,000">C. 40,001 - 50,000</option>
|
||||||
<option value="D-17000.01-22000.00">D-17000.01-22000.00</option>
|
<option value="60,001-80,000">D. 60,001 - 80,000</option>
|
||||||
<option value="E-20001.00-25000.00">E-20001.00-25000.00</option>
|
<option value="80,001-100,000">E. 80,001 - 100,000</option>
|
||||||
<option value="F-25001.00-30000.00">F-25001.00-30000.00</option>
|
<option value="120,001-140,000">F. 120,001 - 140,000</option>
|
||||||
<option value="G-30001.00-35000.00">G-30001.00-35000.00</option>
|
<option value="140,001-180,000">G. 140,001 - 180,000</option>
|
||||||
<option value="H-35001.00-40000.00">H-35001.00-40000.00</option>
|
<option value="200,000-500,000">H. 200,000 - 500,000</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>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -117,10 +117,17 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
|
|||||||
const { watch, setValue, formState: { errors } } = useFormContext()
|
const { watch, setValue, formState: { errors } } = useFormContext()
|
||||||
const valor = ((watch(nombre) as string) ?? '').trim()
|
const valor = ((watch(nombre) as string) ?? '').trim()
|
||||||
const lada = valor.startsWith('+1') ? '+1' : '+52'
|
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 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
|
// Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
|
||||||
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
|
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
|
||||||
}
|
}
|
||||||
@@ -144,12 +151,15 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
|
|||||||
<input
|
<input
|
||||||
id={nombre}
|
id={nombre}
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="664 123 4567"
|
inputMode="numeric"
|
||||||
|
maxLength={10}
|
||||||
|
placeholder="6641234567"
|
||||||
value={numero}
|
value={numero}
|
||||||
onChange={(e) => actualizar(lada, e.target.value)}
|
onChange={(e) => actualizar(lada, e.target.value)}
|
||||||
className={claseInput(nombre, errors)}
|
className={claseInput(nombre, errors)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada</p>
|
||||||
<MensajeError nombre={nombre} />
|
<MensajeError nombre={nombre} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef } from 'react'
|
||||||
import SignatureCanvas from 'react-signature-canvas'
|
import SignatureCanvas from 'react-signature-canvas'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,11 +9,10 @@ interface Props {
|
|||||||
export default function SignaturePad({ onFirma }: Props) {
|
export default function SignaturePad({ onFirma }: Props) {
|
||||||
const ref = useRef<SignatureCanvas | null>(null)
|
const ref = useRef<SignatureCanvas | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
// NOTA: no se notifica '' al montar. El pad se desmonta/remonta cada vez
|
||||||
// Notifica firma vacía al montar (estado inicial)
|
// que el asesor sale y vuelve al paso de firmas, y ese reset borraba la
|
||||||
onFirma('')
|
// firma ya capturada (los PDFs salían sin firma). El estado vacío inicial
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// ya lo maneja el formulario padre.
|
||||||
}, [])
|
|
||||||
|
|
||||||
const limpiar = () => {
|
const limpiar = () => {
|
||||||
ref.current?.clear()
|
ref.current?.clear()
|
||||||
|
|||||||
@@ -62,8 +62,16 @@ export const datosGeneralesSchema = z
|
|||||||
peso: textoOpcional,
|
peso: textoOpcional,
|
||||||
estatura: textoOpcional,
|
estatura: textoOpcional,
|
||||||
email: z.string().trim().email('Correo electrónico inválido'),
|
email: z.string().trim().email('Correo electrónico inválido'),
|
||||||
telCasa: textoOpcional,
|
// Teléfonos: 10 dígitos (con o sin lada compuesta; el campo sólo acepta
|
||||||
celular: z.string().trim().min(8, 'El celular es requerido'),
|
// 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,
|
viviendaAnios: textoOpcional,
|
||||||
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
|
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
|
||||||
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),
|
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
eliminarBorrador,
|
eliminarBorrador,
|
||||||
generarLigaRemota,
|
generarLigaRemota,
|
||||||
guardarBorrador,
|
guardarBorrador,
|
||||||
|
importarInformacionEnkontrol,
|
||||||
iniciarProgreso,
|
iniciarProgreso,
|
||||||
listarBorradores,
|
listarBorradores,
|
||||||
obtenerBorrador,
|
obtenerBorrador,
|
||||||
@@ -27,7 +28,9 @@ import {
|
|||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
type BorradorResumen,
|
type BorradorResumen,
|
||||||
type LeadResumen,
|
type LeadResumen,
|
||||||
|
type ResultadoImportacionEnkontrol,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
|
import { getSesion } from '../services/auth'
|
||||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||||
import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
||||||
import { leadAPrellenado } from '../lib/leadAlta'
|
import { leadAPrellenado } from '../lib/leadAlta'
|
||||||
@@ -137,6 +140,29 @@ export default function AltaClientePage() {
|
|||||||
const [ligaCopiada, setLigaCopiada] = useState(false)
|
const [ligaCopiada, setLigaCopiada] = useState(false)
|
||||||
const [errorLiga, setErrorLiga] = useState('')
|
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
|
// Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la
|
||||||
// oportunidad existente en vez de crear una nueva
|
// oportunidad existente en vez de crear una nueva
|
||||||
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
|
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
|
||||||
@@ -337,8 +363,8 @@ export default function AltaClientePage() {
|
|||||||
const res = datos.progresoId
|
const res = datos.progresoId
|
||||||
? await completarProgreso(datos.progresoId, alta)
|
? await completarProgreso(datos.progresoId, alta)
|
||||||
: await crearAltaCliente(alta)
|
: await crearAltaCliente(alta)
|
||||||
toast.success("Alta de cliente completada")
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
toast.success('Alta de cliente completada')
|
||||||
const { fallidos } = await subirDocumentos(res.id, alta)
|
const { fallidos } = await subirDocumentos(res.id, alta)
|
||||||
setRegistro(res.id)
|
setRegistro(res.id)
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
@@ -610,6 +636,51 @@ export default function AltaClientePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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
|
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
|
||||||
celular (vigencia 72 h, sin usuario ni contraseña).
|
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
|
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
||||||
* SQLite.
|
* SQLite.
|
||||||
*/
|
*/export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||||
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
|
||||||
return enviarAlta('/clientes', payload)
|
return enviarAlta('/clientes', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,3 +413,30 @@ export async function subirDocumentoRemoto(token: string, tipo: string, archivo:
|
|||||||
})
|
})
|
||||||
manejarErrorLiga(res)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export function crearDocumento(
|
|||||||
doc.setFontSize(9)
|
doc.setFontSize(9)
|
||||||
doc.text('_______________________________', 105, y, { align: 'center' })
|
doc.text('_______________________________', 105, y, { align: 'center' })
|
||||||
y += 5
|
y += 5
|
||||||
doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' })
|
// Quien recibe el pago es FRASA (el cliente es quien paga)
|
||||||
|
doc.text('Recibió — FRASA DESARROLLOS S.A.P.I. DE C.V.', 105, y, { align: 'center' })
|
||||||
|
|
||||||
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { crearPlanAsync } from '../services/repository'
|
import { crearPlanAsync, listarProyectosAsync } from '../services/repository'
|
||||||
import { generarCalendario } from '../lib/cobranza'
|
import { generarCalendario } from '../lib/cobranza'
|
||||||
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||||
|
|
||||||
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const
|
// Proyecto libre (con sugerencias del datalist): la cartera real incluye
|
||||||
|
// desarrollos que un enum hardcodeado no cubría (ZAFIRO, UMBRAL, SOLE,
|
||||||
|
// DIAGONAL 40, SOLANA I/II...), así que ya no se limita a una lista fija.
|
||||||
|
|
||||||
const esquema = z.object({
|
const esquema = z.object({
|
||||||
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
||||||
clienteTelefono: z.string().trim().optional(),
|
clienteTelefono: z.string().trim().optional(),
|
||||||
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
||||||
proyecto: z.enum(PROYECTOS),
|
proyecto: z.string().trim().min(1, 'Proyecto requerido'),
|
||||||
ventaRef: z.string().trim(),
|
ventaRef: z.string().trim(),
|
||||||
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
||||||
engancheTotal: z
|
engancheTotal: z
|
||||||
@@ -46,6 +48,11 @@ export default function NuevoPlanPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [guardando, setGuardando] = useState(false)
|
const [guardando, setGuardando] = useState(false)
|
||||||
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
|
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
|
||||||
|
// Proyectos existentes en la cartera: alimentan el datalist de sugerencias
|
||||||
|
const [proyectosSugeridos, setProyectosSugeridos] = useState<string[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
void listarProyectosAsync().then(setProyectosSugeridos)
|
||||||
|
}, [])
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -56,7 +63,7 @@ export default function NuevoPlanPage() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
clienteNombre: '',
|
clienteNombre: '',
|
||||||
unidadCodigo: '',
|
unidadCodigo: '',
|
||||||
proyecto: 'SENDA',
|
proyecto: '',
|
||||||
ventaRef: '',
|
ventaRef: '',
|
||||||
fechaInicio: fechaISOHoy(),
|
fechaInicio: fechaISOHoy(),
|
||||||
numPagos: 6,
|
numPagos: 6,
|
||||||
@@ -122,13 +129,19 @@ export default function NuevoPlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
||||||
<select id="proyecto" className={claseInput} {...register('proyecto')}>
|
<input
|
||||||
{PROYECTOS.map((p) => (
|
id="proyecto"
|
||||||
<option key={p} value={p}>
|
list="proyectos-cobranza"
|
||||||
{p}
|
className={claseInput}
|
||||||
</option>
|
placeholder="Ej. SENDA, ZAFIRO, DIAGONAL 40…"
|
||||||
|
{...register('proyecto')}
|
||||||
|
/>
|
||||||
|
<datalist id="proyectos-cobranza">
|
||||||
|
{proyectosSugeridos.map((p) => (
|
||||||
|
<option key={p} value={p} />
|
||||||
))}
|
))}
|
||||||
</select>
|
</datalist>
|
||||||
|
{errors.proyecto && <p className={claseError}>{errors.proyecto.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
||||||
|
|||||||
@@ -365,3 +365,13 @@ export async function actualizarPlanAsync(
|
|||||||
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
|
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
|
||||||
return mios && resultado.origen === 'api' && resultado.planes.length === 0
|
return mios && resultado.origen === 'api' && resultado.planes.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /planes — proyectos distintos de la cartera (datalist del alta de plan) */
|
||||||
|
export async function listarProyectosAsync(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const planes = await request<PlanApi[]>('/planes')
|
||||||
|
return [...new Set(planes.map((p) => p.proyecto).filter((p) => typeof p === 'string' && p.trim() !== ''))].sort()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import type { PropuestaResultado } from '../lib/calculations'
|
|
||||||
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
|
|
||||||
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
precioLista: number
|
|
||||||
apartado: number
|
|
||||||
propuestas: PropuestaResultado[]
|
|
||||||
titulos: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Fila {
|
|
||||||
etiqueta: string
|
|
||||||
obtener: (p: PropuestaResultado, i: number) => string
|
|
||||||
destacada?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sección colapsable con la tabla comparativa de las 4 propuestas */
|
|
||||||
export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulos }: Props) {
|
|
||||||
const [abierto, setAbierto] = useState(false)
|
|
||||||
|
|
||||||
const totales = propuestas.map((p) => totalPagado(p, apartado))
|
|
||||||
// null (plazo desconocido) nunca gana el menor total
|
|
||||||
const ganador = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
|
|
||||||
|
|
||||||
const filas: Fila[] = [
|
|
||||||
{ etiqueta: 'Precio de lista', obtener: () => formatoMoneda(precioLista) },
|
|
||||||
{
|
|
||||||
etiqueta: 'Descuento',
|
|
||||||
obtener: (p) => `${formatoMoneda(p.montoDescuento)} (${formatoPorcentaje(p.descuento)})`,
|
|
||||||
},
|
|
||||||
{ etiqueta: 'Precio final c/desc.', obtener: (p) => formatoMoneda(p.precioFinal) },
|
|
||||||
{ etiqueta: 'Enganche', obtener: (p) => formatoMoneda(p.enganche) },
|
|
||||||
{
|
|
||||||
etiqueta: 'Mensualidad',
|
|
||||||
obtener: (p) =>
|
|
||||||
p.notaMensualidades
|
|
||||||
? `— (${p.notaMensualidades})`
|
|
||||||
: p.mensualidad === null
|
|
||||||
? 'N/A'
|
|
||||||
: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etiqueta: 'Total mensualidades',
|
|
||||||
obtener: (p) =>
|
|
||||||
p.notaMensualidades
|
|
||||||
? '—'
|
|
||||||
: p.mensualidadesTotal === null
|
|
||||||
? 'N/A'
|
|
||||||
: formatoMoneda(p.mensualidadesTotal),
|
|
||||||
},
|
|
||||||
{ etiqueta: 'Pago final', obtener: (p) => formatoMoneda(p.pagoFinal) },
|
|
||||||
{
|
|
||||||
etiqueta: 'Total pagado',
|
|
||||||
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i])),
|
|
||||||
destacada: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etiqueta: 'Diferencia vs precio de lista',
|
|
||||||
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i] - precioLista)),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Borde acento que enmarca la columna con el menor total pagado */
|
|
||||||
const bordeColumna = (i: number, posicion: 'inicio' | 'media' | 'fin'): string => {
|
|
||||||
if (i !== ganador) return ''
|
|
||||||
const laterales = 'border-x-2 border-x-accent'
|
|
||||||
if (posicion === 'inicio') return `${laterales} border-t-2 border-t-accent`
|
|
||||||
if (posicion === 'fin') return `${laterales} border-b-2 border-b-accent`
|
|
||||||
return laterales
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-labelledby="comparador-titulo">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<h2
|
|
||||||
id="comparador-titulo"
|
|
||||||
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
|
||||||
>
|
|
||||||
Comparar propuestas
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-expanded={abierto}
|
|
||||||
aria-controls="comparador-contenido"
|
|
||||||
onClick={() => setAbierto((v) => !v)}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
{abierto ? 'Ocultar comparación' : 'Comparar propuestas'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{abierto && (
|
|
||||||
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[720px] border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
||||||
<th scope="col" className="w-48 p-3 text-left align-bottom">
|
|
||||||
<span className="sr-only">Concepto</span>
|
|
||||||
</th>
|
|
||||||
{propuestas.map((p, i) => (
|
|
||||||
<th
|
|
||||||
key={p.tipo}
|
|
||||||
scope="col"
|
|
||||||
className={`p-3 text-right align-bottom ${bordeColumna(i, 'inicio')}`}
|
|
||||||
>
|
|
||||||
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
|
|
||||||
{titulos[i] ?? p.tipo}
|
|
||||||
</span>
|
|
||||||
{i === ganador && (
|
|
||||||
<span className="mt-2 inline-block rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
|
||||||
Menor total
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filas.map((fila, indiceFila) => (
|
|
||||||
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
|
|
||||||
>
|
|
||||||
{fila.etiqueta}
|
|
||||||
</th>
|
|
||||||
{propuestas.map((p, i) => (
|
|
||||||
<td
|
|
||||||
key={p.tipo}
|
|
||||||
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
|
|
||||||
fila.destacada
|
|
||||||
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'}`
|
|
||||||
: 'text-[#111827] dark:text-[#E2E8F0]'
|
|
||||||
} ${bordeColumna(
|
|
||||||
i,
|
|
||||||
indiceFila === filas.length - 1 ? 'fin' : 'media',
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{fila.obtener(p, i)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
El enganche ya incluye la deducción del apartado. El total pagado suma apartado +
|
|
||||||
enganche + mensualidades + pago final.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import type { Unidad, TipoPropuesta } from '../types'
|
|
||||||
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
|
|
||||||
import { formatoMoneda } from '../lib/formatters'
|
|
||||||
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
unidades: Unidad[]
|
|
||||||
/** Tipo de propuesta activa en el cotizador (se aplica a ambas unidades) */
|
|
||||||
tipo: TipoPropuesta
|
|
||||||
apartado: number
|
|
||||||
acabados: boolean
|
|
||||||
/** Reglas comerciales por desarrollo (vacío => constantes) */
|
|
||||||
reglas?: ReglasDesarrollo
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
|
|
||||||
|
|
||||||
interface Fila {
|
|
||||||
etiqueta: string
|
|
||||||
obtener: (c: ColumnaComparador) => string
|
|
||||||
destacada?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
|
|
||||||
* con la propuesta activa calculada por el motor con los parámetros de cada
|
|
||||||
* desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece
|
|
||||||
* unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara
|
|
||||||
* (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado.
|
|
||||||
*/
|
|
||||||
export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) {
|
|
||||||
const [abierto, setAbierto] = useState(false)
|
|
||||||
const [codigoA, setCodigoA] = useState('')
|
|
||||||
const [codigoB, setCodigoB] = useState('')
|
|
||||||
|
|
||||||
const desarrollos = useMemo(() => idsCotizables(reglas), [reglas])
|
|
||||||
|
|
||||||
const disponibles = useMemo(
|
|
||||||
() =>
|
|
||||||
unidades.filter(
|
|
||||||
(u) =>
|
|
||||||
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
|
|
||||||
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
|
|
||||||
),
|
|
||||||
[unidades, desarrollos],
|
|
||||||
)
|
|
||||||
|
|
||||||
const porCodigo = useMemo(() => {
|
|
||||||
const m = new Map<string, Unidad>()
|
|
||||||
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
|
|
||||||
return m
|
|
||||||
}, [disponibles])
|
|
||||||
|
|
||||||
const unidadA = porCodigo.get(codigoA) ?? null
|
|
||||||
const unidadB = porCodigo.get(codigoB) ?? null
|
|
||||||
|
|
||||||
const comparacion = useMemo(
|
|
||||||
() =>
|
|
||||||
unidadA && unidadB
|
|
||||||
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
|
|
||||||
: null,
|
|
||||||
[unidadA, unidadB, tipo, apartado, acabados, reglas],
|
|
||||||
)
|
|
||||||
|
|
||||||
const filas: Fila[] = [
|
|
||||||
{ etiqueta: 'Desarrollo', obtener: (c) => c.desarrolloNombre },
|
|
||||||
{ etiqueta: 'Modelo', obtener: (c) => c.unidad.modelo },
|
|
||||||
{ etiqueta: 'Estatus', obtener: (c) => c.unidad.status },
|
|
||||||
{ etiqueta: 'Sup. construcción', obtener: (c) => formatoM2(c.supConstruccion) },
|
|
||||||
{ etiqueta: 'Precio de lista', obtener: (c) => formatoMoneda(c.precioLista) },
|
|
||||||
{ etiqueta: 'Precio final c/desc.', obtener: (c) => formatoMoneda(c.propuesta.precioFinal) },
|
|
||||||
{ etiqueta: 'Enganche', obtener: (c) => formatoMoneda(c.propuesta.enganche) },
|
|
||||||
{
|
|
||||||
etiqueta: 'Mensualidad',
|
|
||||||
obtener: (c) =>
|
|
||||||
c.propuesta.notaMensualidades
|
|
||||||
? `— (${c.propuesta.notaMensualidades})`
|
|
||||||
: c.propuesta.mensualidad === null
|
|
||||||
? 'N/A'
|
|
||||||
: `${c.propuesta.numMensualidades} × ${formatoMoneda(c.propuesta.mensualidad)}`,
|
|
||||||
},
|
|
||||||
{ etiqueta: 'Pago final', obtener: (c) => formatoMoneda(c.propuesta.pagoFinal) },
|
|
||||||
{
|
|
||||||
etiqueta: 'Total pagado',
|
|
||||||
obtener: (c) => (c.total === null ? '—' : formatoMoneda(c.total)),
|
|
||||||
destacada: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => (
|
|
||||||
<div>
|
|
||||||
<label htmlFor={id} className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
|
||||||
{etiqueta}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id={id}
|
|
||||||
value={valor}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Selecciona una unidad…</option>
|
|
||||||
{desarrollos.map((dev) => {
|
|
||||||
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
|
|
||||||
if (delDesarrollo.length === 0) return null
|
|
||||||
return (
|
|
||||||
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
|
|
||||||
{delDesarrollo.map((u) => (
|
|
||||||
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
|
|
||||||
{u.codigo} — {u.modelo}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-labelledby="comparador-unidades-titulo">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<h2
|
|
||||||
id="comparador-unidades-titulo"
|
|
||||||
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
|
||||||
>
|
|
||||||
Comparar unidades
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-expanded={abierto}
|
|
||||||
aria-controls="comparador-unidades-contenido"
|
|
||||||
onClick={() => setAbierto((v) => !v)}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
{abierto ? 'Ocultar comparación' : 'Comparar 2 unidades'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{abierto && (
|
|
||||||
<div id="comparador-unidades-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
{selector('comparador-a', codigoA, setCodigoA, 'Unidad A')}
|
|
||||||
{selector('comparador-b', codigoB, setCodigoB, 'Unidad B')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!comparacion ? (
|
|
||||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Selecciona dos unidades para ver la comparación lado a lado.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[480px] border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
||||||
<th scope="col" className="w-48 p-3 text-left">
|
|
||||||
<span className="sr-only">Concepto</span>
|
|
||||||
</th>
|
|
||||||
{[comparacion.a, comparacion.b].map((c) => (
|
|
||||||
<th
|
|
||||||
key={`${c.desarrolloId}|${c.unidad.codigo}`}
|
|
||||||
scope="col"
|
|
||||||
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]"
|
|
||||||
>
|
|
||||||
{c.unidad.codigo}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filas.map((fila) => (
|
|
||||||
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
|
|
||||||
>
|
|
||||||
{fila.etiqueta}
|
|
||||||
</th>
|
|
||||||
{[comparacion.a, comparacion.b].map((c) => (
|
|
||||||
<td
|
|
||||||
key={`${c.desarrolloId}|${c.unidad.codigo}`}
|
|
||||||
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
|
|
||||||
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{fila.obtener(c)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
|
|
||||||
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
217
apps/cotizador/src/components/EsquemaPanel.tsx
Normal file
217
apps/cotizador/src/components/EsquemaPanel.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import {
|
||||||
|
MENSUALIDADES_MAX,
|
||||||
|
MENSUALIDADES_MIN,
|
||||||
|
PCT_ENGANCHE,
|
||||||
|
PCT_ENGANCHE_FIRMA,
|
||||||
|
PCT_ENGANCHE_MAXIMO,
|
||||||
|
PCT_ESCRITURACION,
|
||||||
|
type EsquemaFrasaResultado,
|
||||||
|
} from '../lib/esquema-frasa'
|
||||||
|
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Esquema FRASA 2026 calculado (siempre presente: solo se renderiza con unidad) */
|
||||||
|
esquema: EsquemaFrasaResultado
|
||||||
|
/** Fecha de entrega estimada de la unidad (captura de obra); alimenta la nota del diferimiento */
|
||||||
|
fechaEntrega?: string | null
|
||||||
|
/** Tope de mensualidades: los meses que faltan para la entrega (default 36 sin fecha) */
|
||||||
|
maxMensualidades?: number
|
||||||
|
/** Enganche base del esquema (7.5% del precio, neto del excedente del apartado) */
|
||||||
|
engancheBase: number
|
||||||
|
/** Enganche total capturado (base + extra); el input vive en el renglón del enganche */
|
||||||
|
engancheTotal: number
|
||||||
|
onEngancheTotal: (v: number) => void
|
||||||
|
/** Máximo de enganche total permitido por el tope del 90% de anticipo */
|
||||||
|
engancheMax: number
|
||||||
|
numMensualidades: number
|
||||||
|
onNumMensualidades: (n: number) => void
|
||||||
|
escrituracionDiferida: boolean
|
||||||
|
onEscrituracionDiferida: (v: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasoBase =
|
||||||
|
'flex-1 rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#0F172A]'
|
||||||
|
const pasoTitulo = 'text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]'
|
||||||
|
const pasoMonto = 'mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel único del esquema FRASA 2026 (reemplaza a las 4 propuestas):
|
||||||
|
* línea de tiempo Hoy → A la firma → Mensualidades, el detalle de conceptos
|
||||||
|
* (enganche de firma 2.5%, enganche 7.5%, escrituración 5.5%) y los controles
|
||||||
|
* del diferimiento: número de mensualidades (1..tope, donde el tope son los
|
||||||
|
* meses que faltan para la entrega estimada de la unidad) y si la escrituración
|
||||||
|
* se difiere con el enganche o se paga a la firma.
|
||||||
|
*/
|
||||||
|
export function EsquemaPanel({
|
||||||
|
esquema,
|
||||||
|
fechaEntrega,
|
||||||
|
maxMensualidades = MENSUALIDADES_MAX,
|
||||||
|
engancheBase,
|
||||||
|
engancheTotal,
|
||||||
|
onEngancheTotal,
|
||||||
|
engancheMax,
|
||||||
|
numMensualidades,
|
||||||
|
onNumMensualidades,
|
||||||
|
escrituracionDiferida,
|
||||||
|
onEscrituracionDiferida,
|
||||||
|
}: Props) {
|
||||||
|
// El diferimiento no puede rebasar la entrega de la vivienda: se ofrecen
|
||||||
|
// sólo las mensualidades dentro del tope (con fecha, meses que faltan)
|
||||||
|
const tope = Math.max(MENSUALIDADES_MIN, Math.min(MENSUALIDADES_MAX, maxMensualidades))
|
||||||
|
const mensualidadEfectiva = Math.min(numMensualidades, tope)
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Línea de tiempo del esquema */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
|
<div className={pasoBase}>
|
||||||
|
<p className={pasoTitulo}>Hoy</p>
|
||||||
|
<p className={pasoMonto}>{formatoMoneda(esquema.apartado)}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">Apartado (aparta la unidad)</p>
|
||||||
|
</div>
|
||||||
|
<div className={pasoBase}>
|
||||||
|
<p className={pasoTitulo}>A la firma</p>
|
||||||
|
<p className={pasoMonto}>{formatoMoneda(esquema.pagoUnicoFirma)}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Saldo de enganche de firma
|
||||||
|
{!escrituracionDiferida && ' + escrituración'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={pasoBase}>
|
||||||
|
<p className={pasoTitulo}>Después</p>
|
||||||
|
<p className={pasoMonto}>
|
||||||
|
{esquema.numMensualidades} × {formatoMoneda(esquema.mensualidad)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Total diferible {formatoMoneda(esquema.baseDiferible)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detalle de conceptos */}
|
||||||
|
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||||
|
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Esquema FRASA 2026 — conceptos
|
||||||
|
</h2>
|
||||||
|
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
|
||||||
|
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Enganche de firma ({formatoPorcentaje(PCT_ENGANCHE_FIRMA)})
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{formatoMoneda(esquema.engancheFirmaTotal)}
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
− apartado acreditado → saldo a la firma {formatoMoneda(esquema.saldoFirma)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{esquema.excedenteApartado > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Apartado que excede el 2.5%
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{formatoMoneda(esquema.excedenteApartado)}
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
acreditado al enganche
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Enganche diferible
|
||||||
|
<span className="mt-0.5 block text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Mín. {formatoPorcentaje(PCT_ENGANCHE)} del precio; puedes subirlo hasta el tope del{' '}
|
||||||
|
{formatoPorcentaje(PCT_ENGANCHE_MAXIMO)} de anticipo total
|
||||||
|
</span>
|
||||||
|
</dt>
|
||||||
|
<dd className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">$</span>
|
||||||
|
<input
|
||||||
|
id="enganche-diferible"
|
||||||
|
type="number"
|
||||||
|
min={engancheBase}
|
||||||
|
max={engancheMax}
|
||||||
|
step={1000}
|
||||||
|
value={Math.round(engancheTotal)}
|
||||||
|
onChange={(e) => onEngancheTotal(e.target.value === '' ? engancheBase : Number(e.target.value))}
|
||||||
|
className="w-36 rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-right text-sm font-medium text-[#111827] transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Gastos de escrituración ({formatoPorcentaje(PCT_ESCRITURACION)})
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{formatoMoneda(esquema.escrituracion)}
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{escrituracionDiferida ? 'diferidos con el enganche' : 'pagados a la firma'}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Total del esquema
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{formatoPorcentaje(esquema.totalPagado / esquema.precio)} del precio
|
||||||
|
</span>
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{formatoMoneda(esquema.totalPagado)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Saldo a financiar
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{formatoMoneda(esquema.saldoAFinanciar)}
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{formatoPorcentaje(esquema.saldoAFinanciar / esquema.precio)} del precio
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Controles del diferimiento */}
|
||||||
|
<section className="flex flex-col gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:flex-row sm:items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="mensualidades-esquema" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||||
|
Mensualidades del diferimiento
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="mensualidades-esquema"
|
||||||
|
value={mensualidadEfectiva}
|
||||||
|
onChange={(e) => onNumMensualidades(Math.min(Number(e.target.value), tope))}
|
||||||
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
|
||||||
|
>
|
||||||
|
{Array.from({ length: tope - MENSUALIDADES_MIN + 1 }, (_, i) => MENSUALIDADES_MIN + i).map(
|
||||||
|
(n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n} mensualidades
|
||||||
|
</option>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{fechaEntrega
|
||||||
|
? `Tope: la entrega estimada es el ${new Date(`${fechaEntrega}T00:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })} (máx. ${tope} mensualidades); cada una de ${formatoMoneda(esquema.mensualidad)}`
|
||||||
|
: `De ${MENSUALIDADES_MIN} a ${tope}; cada una de ${formatoMoneda(esquema.mensualidad)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 pb-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]">
|
||||||
|
<input
|
||||||
|
id="escrituracion-diferida"
|
||||||
|
type="checkbox"
|
||||||
|
checked={escrituracionDiferida}
|
||||||
|
onChange={(e) => onEscrituracionDiferida(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-[#D1D5DB] accent-accent"
|
||||||
|
/>
|
||||||
|
Diferir escrituración con el enganche
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TITULO_PROPUESTA: Record<string, string> = {
|
const TITULO_PROPUESTA: Record<string, string> = {
|
||||||
|
// Esquema único FRASA 2026 (cotizaciones nuevas); las viejas muestran su
|
||||||
|
// tipoPropuesta legacy (BANCARIO, INFONAVIT, CONTADO, SEMI_CONTADO)
|
||||||
|
FRASA_2026: 'Esquema FRASA',
|
||||||
BANCARIO: 'Crédito Bancario',
|
BANCARIO: 'Crédito Bancario',
|
||||||
INFONAVIT: 'Crédito Infonavit-Cofinavit',
|
INFONAVIT: 'Crédito Infonavit-Cofinavit',
|
||||||
COFINAVIT: 'Crédito Infonavit-Cofinavit',
|
COFINAVIT: 'Crédito Infonavit-Cofinavit',
|
||||||
|
|||||||
236
apps/cotizador/src/components/OpcionesCredito.tsx
Normal file
236
apps/cotizador/src/components/OpcionesCredito.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import type { AjustesCredito, ResultadoOpcionCredito, TipoCredito } from '../lib/opciones-credito'
|
||||||
|
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
opciones: ResultadoOpcionCredito[]
|
||||||
|
ajustes: Record<TipoCredito, AjustesCredito>
|
||||||
|
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
|
||||||
|
elegida: TipoCredito
|
||||||
|
onElegir: (tipo: TipoCredito) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardBase =
|
||||||
|
'flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#0F172A]'
|
||||||
|
const etiqueta = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||||
|
const inputClase =
|
||||||
|
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]'
|
||||||
|
const fila = 'flex flex-col gap-1 py-1.5 sm:flex-row sm:items-center sm:justify-between'
|
||||||
|
const filaEtiqueta = 'text-sm text-[#64748B] dark:text-[#94A3B8]'
|
||||||
|
const filaMonto = 'text-sm font-medium text-[#111827] dark:text-[#E2E8F0]'
|
||||||
|
|
||||||
|
/** Clamp básico de los ajustes al cambiar un input (evita estados inválidos) */
|
||||||
|
function ajustar(tipo: TipoCredito, previo: AjustesCredito, parcial: Partial<AjustesCredito>): AjustesCredito {
|
||||||
|
const siguiente = { ...previo, ...parcial }
|
||||||
|
if (tipo === 'SEMI_CONTADO') {
|
||||||
|
const pct = Number(siguiente.pctContado ?? 0)
|
||||||
|
siguiente.pctContado = Math.min(1, Math.max(0, Number.isFinite(pct) ? pct : 0))
|
||||||
|
}
|
||||||
|
const tasa = Number(siguiente.tasaAnual)
|
||||||
|
siguiente.tasaAnual = Math.min(0.5, Math.max(0, Number.isFinite(tasa) ? tasa : 0))
|
||||||
|
const plazo = Number(siguiente.plazoAnos)
|
||||||
|
siguiente.plazoAnos = Math.min(35, Math.max(1, Number.isFinite(plazo) ? Math.round(plazo) : 20))
|
||||||
|
return siguiente
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fila({ titulo, monto, negritas = false }: { titulo: string; monto: string; negritas?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={fila}>
|
||||||
|
<dt className={negritas ? `text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]` : filaEtiqueta}>
|
||||||
|
{titulo}
|
||||||
|
</dt>
|
||||||
|
<dd className={negritas ? 'text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]' : filaMonto}>
|
||||||
|
{monto}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TarjetaOpcion({
|
||||||
|
r,
|
||||||
|
ajuste,
|
||||||
|
elegida,
|
||||||
|
onCambiarAjustes,
|
||||||
|
onElegir,
|
||||||
|
}: {
|
||||||
|
r: ResultadoOpcionCredito
|
||||||
|
ajuste: AjustesCredito
|
||||||
|
elegida: boolean
|
||||||
|
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
|
||||||
|
onElegir: (tipo: TipoCredito) => void
|
||||||
|
}) {
|
||||||
|
const conCredito = r.tipo !== 'CONTADO'
|
||||||
|
const esSemi = r.tipo === 'SEMI_CONTADO'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`${cardBase} ${elegida ? 'ring-2 ring-accent' : ''} ${r.esquemaVigente ? '' : 'opacity-90'}`}
|
||||||
|
>
|
||||||
|
<header className="mb-3 flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{r.titulo}</h3>
|
||||||
|
{r.descuento > 0 && (
|
||||||
|
<span className="inline-flex shrink-0 items-center rounded-full bg-accent/10 px-2.5 py-1 text-xs font-semibold text-accent">
|
||||||
|
Descuento {formatoPorcentaje(r.descuento)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!r.esquemaVigente ? (
|
||||||
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||||
|
El apartado supera el 10% del precio final con descuento; revisa el monto de apartado.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
|
||||||
|
<Fila titulo="Precio final c/desc." monto={formatoMoneda(r.precioFinal)} />
|
||||||
|
<Fila
|
||||||
|
titulo="Esquema FRASA 2026"
|
||||||
|
monto={`A la firma ${formatoMoneda(r.esquema.pagoUnicoFirma)} · ${r.esquema.numMensualidades} × ${formatoMoneda(r.esquema.mensualidad)} diferible`}
|
||||||
|
/>
|
||||||
|
{r.tipo === 'CONTADO' ? (
|
||||||
|
<>
|
||||||
|
<div className={fila}>
|
||||||
|
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Se liquida de contado
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-accent">{formatoMoneda(r.saldoAFinanciar)}</dd>
|
||||||
|
</div>
|
||||||
|
<Fila titulo="Ahorro vs precio de lista" monto={formatoMoneda(r.ahorroVsLista)} />
|
||||||
|
<Fila titulo="Total a pagar" monto={formatoMoneda(r.totalEstimado)} negritas />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Fila titulo="Saldo a financiar (84.5%)" monto={formatoMoneda(r.saldoAFinanciar)} />
|
||||||
|
)}
|
||||||
|
{esSemi && (
|
||||||
|
<Fila titulo="Pago de contado a la firma" monto={formatoMoneda(r.pagoContado)} />
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{esSemi && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label htmlFor={`pct-contado-${r.tipo}`} className={etiqueta}>
|
||||||
|
% de contado
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`pct-contado-${r.tipo}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={Math.round((ajuste.pctContado ?? 0) * 100)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value)
|
||||||
|
const pct = Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : 0
|
||||||
|
onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { pctContado: pct }))
|
||||||
|
}}
|
||||||
|
className={inputClase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conCredito && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`tasa-${r.tipo}`} className={etiqueta}>
|
||||||
|
Tasa anual %
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`tasa-${r.tipo}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step={0.1}
|
||||||
|
value={Number((ajuste.tasaAnual * 100).toFixed(2))}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value)
|
||||||
|
onCambiarAjustes(
|
||||||
|
r.tipo,
|
||||||
|
ajustar(r.tipo, ajuste, { tasaAnual: Number.isFinite(v) ? v / 100 : 0 }),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className={inputClase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`plazo-${r.tipo}`} className={etiqueta}>
|
||||||
|
Plazo
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`plazo-${r.tipo}`}
|
||||||
|
value={ajuste.plazoAnos}
|
||||||
|
onChange={(e) => onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { plazoAnos: Number(e.target.value) }))}
|
||||||
|
className={inputClase}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 31 }, (_, i) => i + 5).map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n} años
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-lg bg-accent/10 px-3 py-2.5">
|
||||||
|
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">Mensualidad estimada</p>
|
||||||
|
<p className="text-lg font-semibold text-accent">{formatoMoneda(r.mensualidadEstimada ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-3 divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
|
||||||
|
<Fila titulo="Monto del crédito" monto={formatoMoneda(r.montoCredito)} />
|
||||||
|
<Fila titulo="Intereses estimados" monto={formatoMoneda(r.interesesEstimados)} />
|
||||||
|
<Fila titulo="Total estimado" monto={formatoMoneda(r.totalEstimado)} negritas />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Mensualidad estimada con tasa y plazo ajustables; sujeta a aprobación del banco. No
|
||||||
|
constituye preaprobación.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onElegir(r.tipo)}
|
||||||
|
aria-pressed={elegida}
|
||||||
|
className={`mt-4 w-full rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition-all duration-100 ease-out ${
|
||||||
|
elegida
|
||||||
|
? 'bg-accent text-white hover:bg-accent-hover'
|
||||||
|
: 'border border-[#D1D5DB] bg-white text-[#374151] hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{elegida ? 'Elegida — se incluye en el PDF' : 'Usar en el PDF'}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4 opciones de financiamiento del saldo (84.5% del precio final) sobre el
|
||||||
|
* esquema FRASA 2026: Crédito Bancario, Infonavit-Cofinavit, Semi Contado y
|
||||||
|
* Contado. Cada tarjeta ajusta su tasa/plazo (y % de contado para la semi),
|
||||||
|
* estima la mensualidad del crédito y permite elegir la propuesta que sale
|
||||||
|
* en el PDF.
|
||||||
|
*/
|
||||||
|
export function OpcionesCredito({ opciones, ajustes, onCambiarAjustes, elegida, onElegir }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Opciones de financiamiento del saldo (84.5% del precio final)
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{opciones.map((r) => (
|
||||||
|
<TarjetaOpcion
|
||||||
|
key={r.tipo}
|
||||||
|
r={r}
|
||||||
|
ajuste={ajustes[r.tipo]}
|
||||||
|
elegida={r.tipo === elegida}
|
||||||
|
onCambiarAjustes={onCambiarAjustes}
|
||||||
|
onElegir={onElegir}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { PropuestaResultado } from '../lib/calculations'
|
|
||||||
import { formatoMoneda } from '../lib/formatters'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
numero: number
|
|
||||||
titulo: string
|
|
||||||
propuesta: PropuestaResultado
|
|
||||||
}
|
|
||||||
|
|
||||||
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-baseline justify-between gap-2 py-2">
|
|
||||||
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
{etiqueta}
|
|
||||||
{nota && (
|
|
||||||
<span className="mt-0.5 block text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
{nota}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{valor}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */
|
|
||||||
export function ProposalCard({ numero, titulo, propuesta }: Props) {
|
|
||||||
return (
|
|
||||||
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-all duration-200 hover:shadow-md">
|
|
||||||
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-accent">
|
|
||||||
Propuesta comercial #{numero}
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-1 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{titulo}</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="divide-y divide-[#E5E7EB]">
|
|
||||||
{propuesta.descuento > 0 && (
|
|
||||||
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
|
|
||||||
)}
|
|
||||||
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
|
|
||||||
<Renglon
|
|
||||||
etiqueta="Enganche"
|
|
||||||
nota="incluye deducción del apartado"
|
|
||||||
valor={formatoMoneda(propuesta.enganche)}
|
|
||||||
/>
|
|
||||||
{propuesta.notaMensualidades ? (
|
|
||||||
<Renglon etiqueta="Mensualidades" nota={propuesta.notaMensualidades} valor="—" />
|
|
||||||
) : propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? (
|
|
||||||
<Renglon
|
|
||||||
etiqueta="Mensualidades"
|
|
||||||
nota={`${propuesta.numMensualidades} × ${formatoMoneda(propuesta.mensualidad)}`}
|
|
||||||
valor={formatoMoneda(propuesta.mensualidadesTotal)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Renglon etiqueta="Mensualidades" valor="N/A" />
|
|
||||||
)}
|
|
||||||
<Renglon etiqueta="Pago final" valor={formatoMoneda(propuesta.pagoFinal)} />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,22 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||||
import { useCotizacion } from './useCotizacion'
|
import { APARTADO_ESQUEMA_DEFAULT, mensualidadesParaUnidad, useCotizacion } from './useCotizacion'
|
||||||
import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
|
import { SIN_REGLAS } from '../lib/reglas'
|
||||||
import { DESARROLLOS } from '../data/desarrollos'
|
import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa'
|
||||||
import { INVENTARIO_SEED } from '../data/unidades'
|
import { INVENTARIO_SEED } from '../data/unidades'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reglas comerciales configurables en el cotizador: las propuestas desactivadas
|
* Estado central con el esquema único FRASA 2026: apartado libre (default
|
||||||
* por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se
|
* $15,000), mensualidades del diferimiento (default 14) y escrituración a la
|
||||||
* cae a la primera vigente.
|
* firma o diferida. Ya no hay propuestas: el resultado sale de
|
||||||
|
* calcularEsquemaFrasa (mismo motor que el API).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
|
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
|
||||||
|
|
||||||
function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo {
|
function montar() {
|
||||||
return reglasDesdeApi({
|
const hook = renderHook(() => useCotizacion(SIN_REGLAS))
|
||||||
DIAGONAL: {
|
|
||||||
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => ({ ...p, activa: activa(p.tipo) })),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
|
|
||||||
const hook = renderHook(({ reglas }) => useCotizacion(reglas), {
|
|
||||||
initialProps: { reglas: reglas as ReglasDesarrollo },
|
|
||||||
})
|
|
||||||
act(() => {
|
act(() => {
|
||||||
hook.result.current.setUnidad(unidadDiagonal)
|
hook.result.current.setUnidad(unidadDiagonal)
|
||||||
})
|
})
|
||||||
@@ -34,75 +25,172 @@ function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
|
|||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
describe('useCotizacion y las reglas del admin', () => {
|
describe('useCotizacion — esquema único FRASA 2026', () => {
|
||||||
it('sin reglas ofrece las 4 propuestas de siempre', () => {
|
it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => {
|
||||||
|
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
|
||||||
|
|
||||||
|
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
|
||||||
|
expect(result.current.apartadoEfectivo).toBe(15000)
|
||||||
|
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
|
||||||
|
expect(result.current.escrituracionDiferida).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('elegir unidad con fecha de entrega: las mensualidades = meses que faltan para entregarla', () => {
|
||||||
|
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setUnidad(unidadDiagonal)
|
||||||
|
})
|
||||||
|
expect(result.current.numMensualidades).toBe(mensualidadesParaUnidad(unidadDiagonal))
|
||||||
|
expect(result.current.numMensualidades).not.toBe(MENSUALIDADES_DEFAULT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('elegir unidad SIN fecha de entrega: cae al default del esquema (14)', () => {
|
||||||
|
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setUnidad({ ...unidadDiagonal, fechaEntrega: null })
|
||||||
|
})
|
||||||
|
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mensualidadesParaUnidad: acota a 1..36 y cae al default sin fecha', () => {
|
||||||
|
const hoy = new Date('2026-09-29T12:00:00')
|
||||||
|
const base = { ...unidadDiagonal }
|
||||||
|
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2026-10-15' }, hoy)).toBe(1)
|
||||||
|
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2035-12-31' }, hoy)).toBe(36)
|
||||||
|
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2027-09-29' }, hoy)).toBe(12)
|
||||||
|
expect(mensualidadesParaUnidad(null, hoy)).toBe(MENSUALIDADES_DEFAULT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => {
|
||||||
|
const { result } = montar()
|
||||||
|
const { unidad, acabados } = result.current
|
||||||
|
|
||||||
|
const esperado = calcularEsquemaFrasa({
|
||||||
|
precio: result.current.precioLista,
|
||||||
|
apartado: 15000,
|
||||||
|
numMensualidades: mensualidadesParaUnidad(unidadDiagonal),
|
||||||
|
escrituracionDiferida: false,
|
||||||
|
})
|
||||||
|
expect(result.current.resultado).toEqual(esperado)
|
||||||
|
// El esquema siempre liquida el 15.5% del precio (2.5 + 7.5 + 5.5)
|
||||||
|
expect(result.current.resultado?.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
|
||||||
|
expect(unidad).toBe(unidadDiagonal)
|
||||||
|
expect(acabados).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin unidad no hay resultado (panel vacío)', () => {
|
||||||
|
const { result } = montar()
|
||||||
|
act(() => {
|
||||||
|
result.current.setUnidad(null)
|
||||||
|
})
|
||||||
|
expect(result.current.resultado).toBeNull()
|
||||||
|
expect(result.current.precioLista).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('el apartado libre alimenta el saldo a firma; el excedente acredita al enganche', () => {
|
||||||
|
const { result } = montar()
|
||||||
|
const excedente = Math.round(result.current.precioLista * 0.025) + 5000
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setApartado(excedente)
|
||||||
|
})
|
||||||
|
const r = result.current.resultado!
|
||||||
|
expect(r.excedenteApartado).toBeGreaterThan(0)
|
||||||
|
expect(r.saldoFirma).toBe(0)
|
||||||
|
// El total no cambia: el excedente se mueve de concepto, no se cobra dos veces
|
||||||
|
expect(r.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
|
||||||
|
const { result } = montar()
|
||||||
|
const n = Math.min(6, mensualidadesParaUnidad(unidadDiagonal))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setNumMensualidades(n)
|
||||||
|
})
|
||||||
|
const r = result.current.resultado!
|
||||||
|
expect(r.numMensualidades).toBe(n)
|
||||||
|
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / n, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('el ajuste de mensualidades respeta el tope de la entrega estimada', () => {
|
||||||
|
const { result } = montar()
|
||||||
|
const tope = mensualidadesParaUnidad(unidadDiagonal)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setNumMensualidades(tope + 10)
|
||||||
|
})
|
||||||
|
expect(result.current.numMensualidades).toBe(tope)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escrituración diferida: sube el diferible y baja el pago a la firma', () => {
|
||||||
const { result } = montar()
|
const { result } = montar()
|
||||||
|
|
||||||
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
|
|
||||||
'CREDITO_BANCARIO',
|
|
||||||
'CREDITO_INFONAVIT_COFINAVIT',
|
|
||||||
'CONTADO',
|
|
||||||
'SEMI_CONTADO',
|
|
||||||
])
|
|
||||||
expect(result.current.avisoPropuestaDesactivada).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
|
|
||||||
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
|
|
||||||
|
|
||||||
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
|
|
||||||
'CREDITO_BANCARIO',
|
|
||||||
'CREDITO_INFONAVIT_COFINAVIT',
|
|
||||||
'SEMI_CONTADO',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => {
|
|
||||||
const { result, rerender } = montar()
|
|
||||||
|
|
||||||
// El vendedor elige Contado (índice 2)
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setPropuestaIndex(2)
|
result.current.setEscrituracionDiferida(true)
|
||||||
})
|
})
|
||||||
expect(result.current.propuestas[2]?.tipo).toBe('CONTADO')
|
const r = result.current.resultado!
|
||||||
|
expect(r.escrituracionDiferida).toBe(true)
|
||||||
// El admin la desactiva (llegan reglas nuevas)
|
expect(r.baseDiferible).toBeCloseTo(r.enganche + r.escrituracion, 6)
|
||||||
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') })
|
expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6)
|
||||||
|
|
||||||
expect(result.current.propuestas.map((p) => p.tipo)).not.toContain('CONTADO')
|
|
||||||
expect(result.current.propuestaIndex).toBe(0)
|
|
||||||
expect(result.current.avisoPropuestaDesactivada).toContain('Contado')
|
|
||||||
expect(result.current.avisoPropuestaDesactivada).toContain('Crédito Bancario')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('los descuentos por defecto salen de las reglas configuradas', () => {
|
it('restaurar carga una cotización guardada (N acotado a la entrega y escrituración)', () => {
|
||||||
const { result } = montar(
|
const { result } = montar()
|
||||||
reglasDesdeApi({
|
const tope = mensualidadesParaUnidad(unidadDiagonal)
|
||||||
DIAGONAL: {
|
|
||||||
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
|
|
||||||
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.current.descuentos.contado).toBe(0.06)
|
act(() => {
|
||||||
expect(result.current.descuentos.semiContado).toBe(0.01)
|
result.current.restaurar({
|
||||||
|
desarrollo: 'DIAGONAL',
|
||||||
|
unidad: unidadDiagonal,
|
||||||
|
acabados: true,
|
||||||
|
apartado: 20000,
|
||||||
|
numMensualidades: 24,
|
||||||
|
escrituracionDiferida: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(result.current.acabados).toBe(true)
|
||||||
|
expect(result.current.apartadoEfectivo).toBe(20000)
|
||||||
|
// El diferimiento no puede rebasar la entrega: 24 se acota a las que faltan
|
||||||
|
expect(result.current.numMensualidades).toBe(tope)
|
||||||
|
expect(result.current.escrituracionDiferida).toBe(true)
|
||||||
|
expect(result.current.resultado?.numMensualidades).toBe(tope)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('las reglas cambian los números de las propuestas', () => {
|
it('restaurar una cotización legacy (sin N ni escrituración): default acotado a la entrega', () => {
|
||||||
const { result } = montar(
|
const { result } = montar()
|
||||||
reglasDesdeApi({
|
|
||||||
DIAGONAL: {
|
|
||||||
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
|
|
||||||
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.05 } : p,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const bancario = result.current.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO')
|
act(() => {
|
||||||
const precioLista = result.current.precioLista
|
result.current.restaurar({
|
||||||
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2)
|
desarrollo: 'DIAGONAL',
|
||||||
|
unidad: unidadDiagonal,
|
||||||
|
acabados: false,
|
||||||
|
apartado: 20000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Sin N guardado cae al default (14), pero nunca rebasando la entrega
|
||||||
|
expect(result.current.numMensualidades).toBe(
|
||||||
|
Math.min(MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(unidadDiagonal)),
|
||||||
|
)
|
||||||
|
expect(result.current.escrituracionDiferida).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nueva reinicia apartado, mensualidades y escrituración, y limpia la unidad', () => {
|
||||||
|
const { result } = montar()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setApartado(40000)
|
||||||
|
result.current.setNumMensualidades(6)
|
||||||
|
result.current.setEscrituracionDiferida(true)
|
||||||
|
})
|
||||||
|
act(() => {
|
||||||
|
result.current.nueva()
|
||||||
|
})
|
||||||
|
expect(result.current.unidad).toBeNull()
|
||||||
|
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
|
||||||
|
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
|
||||||
|
expect(result.current.escrituracionDiferida).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
|
import type { Unidad, ParametrosDesarrollo } from '../types'
|
||||||
import {
|
import { obtenerPrecioLista, resolverNumMensualidades } from '../lib/calculations'
|
||||||
APARTADO_DEFAULT,
|
|
||||||
calcularPropuestas,
|
|
||||||
obtenerPrecioLista,
|
|
||||||
resolverNumMensualidades,
|
|
||||||
validarEnganchePct,
|
|
||||||
type PropuestaResultado,
|
|
||||||
} from '../lib/calculations'
|
|
||||||
import { DESARROLLO_DEFAULT } from '../data/desarrollos'
|
import { DESARROLLO_DEFAULT } from '../data/desarrollos'
|
||||||
import {
|
import {
|
||||||
SIN_REGLAS,
|
MENSUALIDADES_DEFAULT,
|
||||||
descuentosDefaultEfectivo,
|
MENSUALIDADES_MAX,
|
||||||
desarrolloEfectivo,
|
MENSUALIDADES_MIN,
|
||||||
propuestasVigentes,
|
PCT_ENGANCHE_MAXIMO,
|
||||||
type ReglasDesarrollo,
|
calcularEsquemaFrasa,
|
||||||
} from '../lib/reglas'
|
type EsquemaFrasaResultado,
|
||||||
|
} from '../lib/esquema-frasa'
|
||||||
|
import { SIN_REGLAS, desarrolloEfectivo, type ReglasDesarrollo } from '../lib/reglas'
|
||||||
|
|
||||||
|
/** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */
|
||||||
|
export const APARTADO_ESQUEMA_DEFAULT = 15000
|
||||||
|
|
||||||
|
const MS_POR_MES = 30.4 * 86400000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tope (y default) de mensualidades del diferimiento para una unidad: los
|
||||||
|
* meses que faltan hasta su fecha de entrega estimada (la que vive en el
|
||||||
|
* módulo de captura: entrega programada/reprogramada de obra). El diferimiento
|
||||||
|
* nunca puede rebasar la entrega de la vivienda. Si la unidad no trae fecha,
|
||||||
|
* cae al default global del esquema (14; tope 36).
|
||||||
|
*/
|
||||||
|
export function mensualidadesParaUnidad(u: Unidad | null, hoy: Date = new Date()): number {
|
||||||
|
const fecha = u?.fechaEntrega
|
||||||
|
if (!fecha) return MENSUALIDADES_DEFAULT
|
||||||
|
const entrega = new Date(`${fecha}T00:00:00`).getTime()
|
||||||
|
if (!Number.isFinite(entrega)) return MENSUALIDADES_DEFAULT
|
||||||
|
const sugeridas = Math.max(MENSUALIDADES_MIN, Math.round((entrega - hoy.getTime()) / MS_POR_MES))
|
||||||
|
return Math.min(MENSUALIDADES_MAX, sugeridas)
|
||||||
|
}
|
||||||
|
|
||||||
interface UseCotizacionResult {
|
interface UseCotizacionResult {
|
||||||
desarrollo: string
|
desarrollo: string
|
||||||
/** Cambia de desarrollo: limpia la unidad elegida y reinicia los descuentos */
|
/** Cambia de desarrollo: limpia la unidad elegida */
|
||||||
setDesarrollo: (id: string) => void
|
setDesarrollo: (id: string) => void
|
||||||
params: ParametrosDesarrollo
|
params: ParametrosDesarrollo
|
||||||
unidad: Unidad | null
|
unidad: Unidad | null
|
||||||
@@ -30,25 +46,25 @@ interface UseCotizacionResult {
|
|||||||
setApartado: (v: number) => void
|
setApartado: (v: number) => void
|
||||||
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
|
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
|
||||||
apartadoEfectivo: number
|
apartadoEfectivo: number
|
||||||
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
|
/** Número de mensualidades del diferimiento (1..36): al elegir unidad se
|
||||||
enganchePct: number | null
|
* prellena con los meses que faltan para su entrega estimada; el vendedor
|
||||||
setEnganchePct: (v: number | null) => void
|
* puede ajustarla */
|
||||||
/** Mensaje de error del enganche capturado; null si es válido */
|
numMensualidades: number
|
||||||
errorEnganche: string | null
|
setNumMensualidades: (n: number) => void
|
||||||
descuentos: DescuentosPropuestas
|
/** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */
|
||||||
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
|
escrituracionDiferida: boolean
|
||||||
/** Índice de la propuesta elegida (para guardar/compartir) */
|
setEscrituracionDiferida: (v: boolean) => void
|
||||||
propuestaIndex: number
|
/** Enganche adicional (anticipo extra, opcional): el total de anticipos no
|
||||||
setPropuestaIndex: (i: number) => void
|
* puede superar el 90% del precio (mínimo 10% queda por financiar) */
|
||||||
|
engancheAdicional: number
|
||||||
|
setEngancheAdicional: (v: number) => void
|
||||||
|
/** Máximo de enganche adicional permitido por el tope del 90% (0 sin unidad) */
|
||||||
|
engancheAdicionalMaximo: number
|
||||||
precioLista: number
|
precioLista: number
|
||||||
/** Número de mensualidades elegido por el vendedor; null = usa el calculado */
|
/** Esquema FRASA 2026 calculado; null mientras no hay unidad elegida */
|
||||||
numMensualidades: number | null
|
resultado: EsquemaFrasaResultado | null
|
||||||
setNumMensualidades: (n: number | null) => void
|
/** Meses restantes para la entrega de la unidad (ficha técnica); null si no se pueden resolver */
|
||||||
/** Número efectivo de mensualidades (la elegida o la calculada) */
|
|
||||||
numMeses: number | null
|
numMeses: number | null
|
||||||
propuestas: PropuestaResultado[]
|
|
||||||
/** Aviso cuando el admin desactivó la propuesta que estaba elegida; null si no aplica */
|
|
||||||
avisoPropuestaDesactivada: string | null
|
|
||||||
/** Restaura el estado completo desde una cotización guardada (historial) */
|
/** Restaura el estado completo desde una cotización guardada (historial) */
|
||||||
restaurar: (estado: EstadoRestaurar) => void
|
restaurar: (estado: EstadoRestaurar) => void
|
||||||
/** Reinicia la cotización (botón "Nueva") */
|
/** Reinicia la cotización (botón "Nueva") */
|
||||||
@@ -61,60 +77,58 @@ export interface EstadoRestaurar {
|
|||||||
unidad: Unidad
|
unidad: Unidad
|
||||||
acabados: boolean
|
acabados: boolean
|
||||||
apartado: number
|
apartado: number
|
||||||
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
|
/** Mensualidades del diferimiento guardadas (default 14) */
|
||||||
descuento?: number
|
numMensualidades?: number
|
||||||
propuestaIndex?: number
|
/** Escrituración diferida guardada (default false) */
|
||||||
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
|
escrituracionDiferida?: boolean
|
||||||
numMensualidades?: number | null
|
/** Enganche adicional guardado (default 0) */
|
||||||
|
engancheAdicional?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estado central de la cotización: desarrollo, unidad, acabados, apartado y
|
* Estado central de la cotización con el esquema único FRASA 2026:
|
||||||
* descuentos. Las reglas comerciales llegan del API (con respaldo en las
|
* desarrollo, unidad, acabados, apartado libre, mensualidades del
|
||||||
* constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era
|
* diferimiento (1..36, default 14) y si la escrituración se difiere o se paga
|
||||||
* la elegida, se avisa y se cae a la primera vigente.
|
* a la firma. El resultado sale de calcularEsquemaFrasa (módulo puro, mismo
|
||||||
|
* motor que el API). Ya no hay propuestas: descuentos, enganche en % e índice
|
||||||
|
* de propuesta desaparecieron con las 4 propuestas.
|
||||||
*/
|
*/
|
||||||
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult {
|
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult {
|
||||||
const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT)
|
const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT)
|
||||||
const [unidad, setUnidad] = useState<Unidad | null>(null)
|
const [unidad, setUnidad] = useState<Unidad | null>(null)
|
||||||
const [acabados, setAcabados] = useState(false)
|
const [acabados, setAcabados] = useState(false)
|
||||||
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
|
const [apartado, setApartado] = useState<number>(APARTADO_ESQUEMA_DEFAULT)
|
||||||
const [enganchePct, setEnganchePct] = useState<number | null>(null)
|
const [numMensualidades, setNumMensualidades] = useState<number>(MENSUALIDADES_DEFAULT)
|
||||||
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(
|
const [escrituracionDiferida, setEscrituracionDiferida] = useState(false)
|
||||||
descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT),
|
const [engancheAdicional, setEngancheAdicionalState] = useState(0)
|
||||||
)
|
|
||||||
const [propuestaIndex, setPropuestaIndex] = useState(0)
|
|
||||||
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
|
|
||||||
const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const params = desarrolloEfectivo(reglas, desarrollo)
|
const params = desarrolloEfectivo(reglas, desarrollo)
|
||||||
|
|
||||||
// Los descuentos por defecto se re-aplican cuando cambian las reglas del
|
|
||||||
// desarrollo (p. ej. al llegar las del API), sin pisar lo que capturó el
|
|
||||||
// vendedor mientras las reglas no cambian.
|
|
||||||
const descuentosAplicados = useRef<string>('')
|
|
||||||
const defaults = useMemo(
|
|
||||||
() => descuentosDefaultEfectivo(reglas, desarrollo),
|
|
||||||
[reglas, desarrollo],
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
|
||||||
const clave = JSON.stringify(defaults)
|
|
||||||
if (descuentosAplicados.current === clave) return
|
|
||||||
descuentosAplicados.current = clave
|
|
||||||
setDescuentos(defaults)
|
|
||||||
}, [defaults])
|
|
||||||
|
|
||||||
const setDesarrollo = (id: string) => {
|
const setDesarrollo = (id: string) => {
|
||||||
setDesarrolloState(id)
|
setDesarrolloState(id)
|
||||||
setUnidad(null)
|
setUnidad(null)
|
||||||
setAcabados(false)
|
setAcabados(false)
|
||||||
setDescuentos(descuentosDefaultEfectivo(reglas, id))
|
|
||||||
setPropuestaIndex(0)
|
|
||||||
setNumMensualidades(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
|
/** Elige unidad y, con ella, las mensualidades del diferimiento: los meses
|
||||||
setDescuentos((prev) => ({ ...prev, [clave]: valor }))
|
* que faltan para la entrega estimada de esa vivienda (dato del módulo de
|
||||||
|
* captura). El vendedor puede ajustarlas después, sin rebasar la entrega. */
|
||||||
|
const elegirUnidad = (u: Unidad | null) => {
|
||||||
|
setUnidad(u)
|
||||||
|
setNumMensualidades(mensualidadesParaUnidad(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tope de mensualidades: las que faltan para la entrega (36 sin fecha) */
|
||||||
|
const topeMensualidades = () => mensualidadesParaUnidad(unidad)
|
||||||
|
|
||||||
|
const ajustarMensualidades = (n: number) => {
|
||||||
|
setNumMensualidades(Math.max(MENSUALIDADES_MIN, Math.min(n, topeMensualidades())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enganche adicional: acotado a >= 0; el esquema valida que el anticipo
|
||||||
|
* total no supere el 90% del precio (mínimo 10% por financiar) */
|
||||||
|
const setEngancheAdicional = (v: number) => {
|
||||||
|
setEngancheAdicionalState(Number.isFinite(v) ? Math.max(0, v) : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restaurar = (estado: EstadoRestaurar) => {
|
const restaurar = (estado: EstadoRestaurar) => {
|
||||||
@@ -122,38 +136,29 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
|
|||||||
setUnidad(estado.unidad)
|
setUnidad(estado.unidad)
|
||||||
setAcabados(estado.acabados)
|
setAcabados(estado.acabados)
|
||||||
setApartado(estado.apartado)
|
setApartado(estado.apartado)
|
||||||
const base = descuentosDefaultEfectivo(reglas, estado.desarrollo)
|
// El diferimiento no puede rebasar la entrega: una cotización guardada con
|
||||||
const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo)
|
// más mensualidades se acota a las que faltan hoy para esa unidad
|
||||||
const vigentes = propuestasVigentes(paramsRestaurados)
|
setNumMensualidades(
|
||||||
if (estado.descuento !== undefined) {
|
Math.min(estado.numMensualidades ?? MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(estado.unidad)),
|
||||||
const idx = estado.propuestaIndex ?? 0
|
)
|
||||||
const clave = vigentes[idx]?.claveDescuento
|
setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
|
||||||
if (clave) base[clave] = estado.descuento
|
setEngancheAdicionalState(Math.max(0, estado.engancheAdicional ?? 0))
|
||||||
}
|
|
||||||
setDescuentos(base)
|
|
||||||
setPropuestaIndex(estado.propuestaIndex ?? 0)
|
|
||||||
setNumMensualidades(estado.numMensualidades ?? null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nueva = () => {
|
const nueva = () => {
|
||||||
setUnidad(null)
|
setUnidad(null)
|
||||||
setAcabados(false)
|
setAcabados(false)
|
||||||
setApartado(APARTADO_DEFAULT)
|
setApartado(APARTADO_ESQUEMA_DEFAULT)
|
||||||
setEnganchePct(null)
|
setNumMensualidades(MENSUALIDADES_DEFAULT)
|
||||||
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo))
|
setEscrituracionDiferida(false)
|
||||||
setPropuestaIndex(0)
|
setEngancheAdicionalState(0)
|
||||||
setNumMensualidades(null)
|
|
||||||
setAvisoPropuestaDesactivada(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const numMesesCalculado = useMemo(
|
const numMeses = useMemo(
|
||||||
() => (unidad ? resolverNumMensualidades(unidad, params) : null),
|
() => (unidad ? resolverNumMensualidades(unidad, params) : null),
|
||||||
[unidad, params],
|
[unidad, params],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */
|
|
||||||
const numMeses = numMensualidades ?? numMesesCalculado
|
|
||||||
|
|
||||||
const precioLista = useMemo(
|
const precioLista = useMemo(
|
||||||
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
|
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
|
||||||
[unidad, acabados],
|
[unidad, acabados],
|
||||||
@@ -163,71 +168,58 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
|
|||||||
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras
|
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras
|
||||||
const apartadoEfectivo = Math.max(0, apartado)
|
const apartadoEfectivo = Math.max(0, apartado)
|
||||||
|
|
||||||
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
|
/**
|
||||||
const errorEnganche = validarEnganchePct(enganchePct)
|
* Esquema FRASA 2026 calculado; null mientras no hay unidad elegida. El
|
||||||
const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null
|
* enganche adicional se acota al máximo que permite el 90% de anticipo
|
||||||
|
* (el esquema lanza error si el total lo rebasa; aquí se previene).
|
||||||
const propuestas = useMemo(() => {
|
*/
|
||||||
if (!unidad) return []
|
const resultado = useMemo<EsquemaFrasaResultado | null>(() => {
|
||||||
return calcularPropuestas({
|
if (!unidad || precioLista <= 0) return null
|
||||||
unidad,
|
const base = calcularEsquemaFrasa({
|
||||||
acabados,
|
precio: precioLista,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
descuentos,
|
numMensualidades,
|
||||||
desarrollo,
|
escrituracionDiferida,
|
||||||
reglas: params,
|
|
||||||
enganchePct: engancheEfectivo,
|
|
||||||
numMeses: numMeses ?? undefined,
|
|
||||||
})
|
})
|
||||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses])
|
const maxAdicional = Math.max(0, precioLista * PCT_ENGANCHE_MAXIMO - base.totalPagado)
|
||||||
|
const adicionalEfectivo = Math.min(Math.max(0, engancheAdicional), maxAdicional)
|
||||||
|
if (adicionalEfectivo === 0) return base
|
||||||
|
return calcularEsquemaFrasa({
|
||||||
|
precio: precioLista,
|
||||||
|
apartado: apartadoEfectivo,
|
||||||
|
numMensualidades,
|
||||||
|
escrituracionDiferida,
|
||||||
|
engancheAdicional: adicionalEfectivo,
|
||||||
|
})
|
||||||
|
}, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, engancheAdicional])
|
||||||
|
|
||||||
// Propuesta elegida: si el índice se sale de la lista vigente (p. ej. el admin
|
/** Máximo de enganche adicional que permite el 90% de anticipo (0 sin unidad) */
|
||||||
// desactivó una propuesta) se cae a la primera y, si la que estaba elegida ya
|
const engancheAdicionalMaximo = useMemo(() => {
|
||||||
// no existe, se avisa con claridad.
|
if (!resultado) return 0
|
||||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
return Math.max(0, precioLista * PCT_ENGANCHE_MAXIMO - (resultado.totalPagado - resultado.engancheAdicional))
|
||||||
const tipoPrevio = useRef<{ tipo: string; titulo: string } | null>(null)
|
}, [resultado, precioLista])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const previo = tipoPrevio.current
|
|
||||||
const vigentes = propuestasVigentes(params)
|
|
||||||
if (previo !== null && !vigentes.some((p) => p.tipo === previo.tipo)) {
|
|
||||||
const sustituto = propuestas[0]?.titulo ?? 'otra propuesta'
|
|
||||||
setAvisoPropuestaDesactivada(
|
|
||||||
`El administrador desactivó la propuesta ${previo.titulo} para ${params.nombre}. Se seleccionó ${sustituto}; elige otra si lo prefieres.`,
|
|
||||||
)
|
|
||||||
setPropuestaIndex(0)
|
|
||||||
} else if (propuestaIndex >= propuestas.length) {
|
|
||||||
setPropuestaIndex(0)
|
|
||||||
}
|
|
||||||
tipoPrevio.current = propuestaActiva
|
|
||||||
? { tipo: propuestaActiva.tipo, titulo: propuestaActiva.titulo }
|
|
||||||
: null
|
|
||||||
}, [params, propuestas, propuestaActiva, propuestaIndex])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
desarrollo,
|
desarrollo,
|
||||||
setDesarrollo,
|
setDesarrollo,
|
||||||
params,
|
params,
|
||||||
unidad,
|
unidad,
|
||||||
setUnidad,
|
setUnidad: elegirUnidad,
|
||||||
acabados,
|
acabados,
|
||||||
setAcabados,
|
setAcabados,
|
||||||
apartado,
|
apartado,
|
||||||
setApartado,
|
setApartado,
|
||||||
apartadoEfectivo,
|
apartadoEfectivo,
|
||||||
enganchePct,
|
|
||||||
setEnganchePct,
|
|
||||||
errorEnganche,
|
|
||||||
descuentos,
|
|
||||||
setDescuento,
|
|
||||||
propuestaIndex,
|
|
||||||
setPropuestaIndex,
|
|
||||||
precioLista,
|
|
||||||
numMensualidades,
|
numMensualidades,
|
||||||
setNumMensualidades,
|
setNumMensualidades: ajustarMensualidades,
|
||||||
|
escrituracionDiferida,
|
||||||
|
setEscrituracionDiferida,
|
||||||
|
engancheAdicional,
|
||||||
|
setEngancheAdicional,
|
||||||
|
engancheAdicionalMaximo,
|
||||||
|
precioLista,
|
||||||
|
resultado,
|
||||||
numMeses,
|
numMeses,
|
||||||
propuestas,
|
|
||||||
avisoPropuestaDesactivada,
|
|
||||||
restaurar,
|
restaurar,
|
||||||
nueva,
|
nueva,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { PropuestaResultado } from './calculations'
|
import type { EsquemaFrasaResultado } from './esquema-frasa'
|
||||||
import { formatoMoneda, formatoFechaLarga } from './formatters'
|
import { formatoMoneda, formatoFechaLarga } from './formatters'
|
||||||
|
|
||||||
export interface DatosCompartir {
|
export interface DatosCompartir {
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
desarrolloNombre: string
|
desarrolloNombre: string
|
||||||
propuesta: PropuestaResultado
|
/** Esquema único FRASA 2026 calculado */
|
||||||
|
esquema: EsquemaFrasaResultado
|
||||||
apartado: number
|
apartado: number
|
||||||
vigencia: Date
|
vigencia: Date
|
||||||
/** Folio y URL pública solo existen si la cotización ya se guardó en el API */
|
/** Folio y URL pública solo existen si la cotización ya se guardó en el API */
|
||||||
@@ -13,8 +14,9 @@ export interface DatosCompartir {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Texto resumen de la propuesta, listo para copiar/pegar en WhatsApp.
|
* Texto resumen del esquema FRASA 2026, listo para copiar/pegar en WhatsApp.
|
||||||
* Incluye unidad, desarrollo, propuesta elegida, montos y vigencia.
|
* Incluye unidad, desarrollo, línea de tiempo (hoy / firma / mensualidades)
|
||||||
|
* y vigencia.
|
||||||
*/
|
*/
|
||||||
export function textoCompartir(d: DatosCompartir): string {
|
export function textoCompartir(d: DatosCompartir): string {
|
||||||
const lineas: string[] = []
|
const lineas: string[] = []
|
||||||
@@ -23,20 +25,14 @@ export function textoCompartir(d: DatosCompartir): string {
|
|||||||
)
|
)
|
||||||
lineas.push(`Desarrollo: ${d.desarrolloNombre}`)
|
lineas.push(`Desarrollo: ${d.desarrolloNombre}`)
|
||||||
lineas.push(`Unidad: ${d.unidadCodigo}`)
|
lineas.push(`Unidad: ${d.unidadCodigo}`)
|
||||||
lineas.push(`Propuesta: ${d.propuesta.titulo}`)
|
lineas.push(`Precio de lista: ${formatoMoneda(d.esquema.precio)}`)
|
||||||
lineas.push(`Precio final c/desc: ${formatoMoneda(d.propuesta.precioFinal)}`)
|
lineas.push(`Apartado (hoy): ${formatoMoneda(d.apartado)}`)
|
||||||
lineas.push(`Apartado: ${formatoMoneda(d.apartado)}`)
|
lineas.push(`Pago a la firma: ${formatoMoneda(d.esquema.pagoUnicoFirma)}`)
|
||||||
lineas.push(`Enganche: ${formatoMoneda(d.propuesta.enganche)}`)
|
lineas.push(
|
||||||
if (d.propuesta.notaMensualidades) {
|
`Mensualidades: ${d.esquema.numMensualidades} × ${formatoMoneda(d.esquema.mensualidad)} (total diferible ${formatoMoneda(d.esquema.baseDiferible)})`,
|
||||||
lineas.push(`Mensualidades: — (${d.propuesta.notaMensualidades})`)
|
)
|
||||||
} else if (d.propuesta.mensualidad !== null) {
|
lineas.push(`Escrituración 5.5%: ${d.esquema.escrituracionDiferida ? 'diferida con el enganche' : `pagada a la firma (${formatoMoneda(d.esquema.escrituracion)})`}`)
|
||||||
lineas.push(
|
lineas.push(`Total del esquema: ${formatoMoneda(d.esquema.totalPagado)}`)
|
||||||
`Mensualidades: ${d.propuesta.numMensualidades} × ${formatoMoneda(d.propuesta.mensualidad)}`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
lineas.push('Mensualidades: N/A')
|
|
||||||
}
|
|
||||||
lineas.push(`Pago final: ${formatoMoneda(d.propuesta.pagoFinal)}`)
|
|
||||||
lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`)
|
lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`)
|
||||||
if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`)
|
if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`)
|
||||||
return lineas.join('\n')
|
return lineas.join('\n')
|
||||||
|
|||||||
115
apps/cotizador/src/lib/esquema-frasa.test.ts
Normal file
115
apps/cotizador/src/lib/esquema-frasa.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
calcularEsquemaFrasa,
|
||||||
|
validarEsquemaInput,
|
||||||
|
PCT_ENGANCHE_FIRMA,
|
||||||
|
PCT_ENGANCHE,
|
||||||
|
PCT_ESCRITURACION,
|
||||||
|
} from './esquema-frasa'
|
||||||
|
|
||||||
|
/** 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) + 0, 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)
|
||||||
|
// y equivale al 15.5% del precio (2.5 + 7.5 + 5.5) salvo excedentes
|
||||||
|
expect(r.totalPagado).toBeCloseTo(155000, 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
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) // no se cobra dos veces: 30k + 70k + 55k
|
||||||
|
})
|
||||||
|
|
||||||
|
it('apartado mínimo (sin mínimo fijo): $1 aparta igual', () => {
|
||||||
|
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 1, numMensualidades: 14, escrituracionDiferida: false })
|
||||||
|
expect(r.saldoFirma).toBe(24999)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularEsquemaFrasa — mensualidades', () => {
|
||||||
|
it('N=1: todo el diferible en una sola mensualidad', () => {
|
||||||
|
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 15000, numMensualidades: 1, escrituracionDiferida: true })
|
||||||
|
expect(r.mensualidad).toBe(130000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validación: rechaza entradas inválidas', () => {
|
||||||
|
expect(validarEsquemaInput({ precio: 0, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/precio/i)
|
||||||
|
expect(validarEsquemaInput({ precio: 1000000, apartado: -5, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/apartado/i)
|
||||||
|
expect(validarEsquemaInput({ precio: 1000000, apartado: 2000000, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/superar/i)
|
||||||
|
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 0, escrituracionDiferida: false })).toMatch(/mensualidad/i)
|
||||||
|
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14.5, escrituracionDiferida: false })).toMatch(/entero/i)
|
||||||
|
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularEsquemaFrasa — enganche adicional (hasta 90% de anticipo)', () => {
|
||||||
|
const base = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
|
||||||
|
|
||||||
|
it('sin adicional: queda en 0 y el saldo a financiar es el 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.engancheAdicional).toBe(400000)
|
||||||
|
expect(r.totalPagado).toBeCloseTo(155000, 2)
|
||||||
|
expect(r.saldoAFinanciar).toBeCloseTo(445000, 2)
|
||||||
|
// El esquema base no cambia: el adicional es aparte
|
||||||
|
expect(r.enganche).toBeCloseTo(75000, 2)
|
||||||
|
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tope exacto: 90% del precio menos el esquema (745,000) es válido', () => {
|
||||||
|
const r = calcularEsquemaFrasa({ ...base, engancheAdicional: 745000 })
|
||||||
|
expect(r.saldoAFinanciar).toBeCloseTo(100000, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza pasar del 90% de enganche total', () => {
|
||||||
|
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 745001 })).toThrow(/90%/)
|
||||||
|
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 900000 })).toThrow(/90%/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza adicional negativo', () => {
|
||||||
|
expect(validarEsquemaInput({ ...base, engancheAdicional: -1 })).toMatch(/adicional/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
123
apps/cotizador/src/lib/esquema-frasa.ts
Normal file
123
apps/cotizador/src/lib/esquema-frasa.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Módulo puro (sin UI, sin API) para probarlo a fondo. 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { folioCotizacion } from './folio'
|
import { folioCotizacion } from './folio'
|
||||||
import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir'
|
import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir'
|
||||||
import { compararUnidades } from './comparadorUnidades'
|
import { compararUnidades } from './comparadorUnidades'
|
||||||
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations'
|
import { calcularEsquemaFrasa } from './esquema-frasa'
|
||||||
|
import { APARTADO_DEFAULT, totalPagado } from './calculations'
|
||||||
import { UNIDADES_SEED } from '../data/unidades'
|
import { UNIDADES_SEED } from '../data/unidades'
|
||||||
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
|
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
|
||||||
import { DESARROLLOS } from '../data/desarrollos'
|
import { DESARROLLOS } from '../data/desarrollos'
|
||||||
@@ -16,30 +17,31 @@ describe('folioCotizacion', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('textoCompartir', () => {
|
describe('textoCompartir', () => {
|
||||||
const propuesta = calcularPropuestas({
|
// Caso dorado del cliente: $1,000,000, apartado $15,000, N=14
|
||||||
unidad: UNIDADES_SEED[0],
|
const esquema = calcularEsquemaFrasa({
|
||||||
acabados: true,
|
precio: 1000000,
|
||||||
apartado: APARTADO_DEFAULT,
|
apartado: 15000,
|
||||||
descuentos: { ...DESCUENTOS_DEFAULT },
|
numMensualidades: 14,
|
||||||
numMeses: 14,
|
escrituracionDiferida: false,
|
||||||
})[0]
|
})
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
unidadCodigo: 'U-101',
|
unidadCodigo: 'U-101',
|
||||||
desarrolloNombre: 'Diagonal',
|
desarrolloNombre: 'Diagonal',
|
||||||
propuesta,
|
esquema,
|
||||||
apartado: APARTADO_DEFAULT,
|
apartado: 15000,
|
||||||
vigencia: new Date('2026-08-31T00:00:00'),
|
vigencia: new Date('2026-08-31T00:00:00'),
|
||||||
}
|
}
|
||||||
|
|
||||||
it('incluye unidad, desarrollo, propuesta, montos y vigencia', () => {
|
it('incluye unidad, desarrollo, línea de tiempo del esquema y vigencia', () => {
|
||||||
const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' })
|
const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' })
|
||||||
expect(texto).toContain('Cotización COT-3F8A1B')
|
expect(texto).toContain('Cotización COT-3F8A1B')
|
||||||
expect(texto).toContain('Desarrollo: Diagonal')
|
expect(texto).toContain('Desarrollo: Diagonal')
|
||||||
expect(texto).toContain('Unidad: U-101')
|
expect(texto).toContain('Unidad: U-101')
|
||||||
expect(texto).toContain(`Propuesta: ${propuesta.titulo}`)
|
expect(texto).toContain(`Apartado (hoy): ${formatoMoneda(15000)}`)
|
||||||
expect(texto).toContain(`Enganche: ${formatoMoneda(propuesta.enganche)}`)
|
expect(texto).toContain(`Pago a la firma: ${formatoMoneda(65000)}`)
|
||||||
expect(texto).toContain(`Pago final: ${formatoMoneda(propuesta.pagoFinal)}`)
|
expect(texto).toContain(`Mensualidades: 14 × ${formatoMoneda(75000 / 14)}`)
|
||||||
|
expect(texto).toContain(`Total del esquema: ${formatoMoneda(155000)}`)
|
||||||
expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026')
|
expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026')
|
||||||
expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico')
|
expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico')
|
||||||
})
|
})
|
||||||
@@ -50,15 +52,16 @@ describe('textoCompartir', () => {
|
|||||||
expect(texto).not.toContain('Consulta:')
|
expect(texto).not.toContain('Consulta:')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('contado (mensualidad null) muestra Mensualidades: N/A', () => {
|
it('escrituración diferida: lo dice en el texto', () => {
|
||||||
const contado = calcularPropuestas({
|
const diferido = calcularEsquemaFrasa({
|
||||||
unidad: UNIDADES_SEED[0],
|
precio: 1000000,
|
||||||
acabados: true,
|
apartado: 15000,
|
||||||
apartado: APARTADO_DEFAULT,
|
numMensualidades: 14,
|
||||||
descuentos: { ...DESCUENTOS_DEFAULT },
|
escrituracionDiferida: true,
|
||||||
numMeses: 14,
|
})
|
||||||
}).find((p) => p.tipo === 'CONTADO')!
|
const texto = textoCompartir({ ...base, esquema: diferido })
|
||||||
expect(textoCompartir({ ...base, propuesta: contado })).toContain('Mensualidades: N/A')
|
expect(texto).toContain('Escrituración 5.5%: diferida con el enganche')
|
||||||
|
expect(texto).toContain(`Pago a la firma: ${formatoMoneda(10000)}`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
202
apps/cotizador/src/lib/opciones-credito.test.ts
Normal file
202
apps/cotizador/src/lib/opciones-credito.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
AJUSTES_DEFAULT,
|
||||||
|
DESCUENTO_POR_TIPO,
|
||||||
|
TIPOS_ORDEN,
|
||||||
|
calcularOpcionCredito,
|
||||||
|
validarAjustes,
|
||||||
|
type EntradaOpcion,
|
||||||
|
type TipoCredito,
|
||||||
|
} from './opciones-credito'
|
||||||
|
|
||||||
|
/** Caso dorado del cliente: casa de $1,000,000, apartado $15,000, N=14 */
|
||||||
|
const CASO = { precioLista: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
|
||||||
|
|
||||||
|
function opcion(tipo: TipoCredito, extra: Partial<EntradaOpcion> = {}) {
|
||||||
|
return calcularOpcionCredito({ ...CASO, tipo, ajustes: AJUSTES_DEFAULT[tipo], ...extra })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anualidad con la misma fórmula del módulo (el test calcula su esperado) */
|
||||||
|
function anualidadEsperada(monto: number, tasaAnual: number, plazoAnos: number): number {
|
||||||
|
const n = plazoAnos * 12
|
||||||
|
const i = tasaAnual / 12
|
||||||
|
return Number(((monto * i) / (1 - Math.pow(1 + i, -n))).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — Crédito Bancario (caso dorado)', () => {
|
||||||
|
const r = opcion('CREDITO_BANCARIO')
|
||||||
|
|
||||||
|
it('sin descuento: precio final = lista y saldo a financiar $845,000', () => {
|
||||||
|
expect(DESCUENTO_POR_TIPO.CREDITO_BANCARIO).toBe(0)
|
||||||
|
expect(r.precioFinal).toBe(1000000)
|
||||||
|
expect(r.montoDescuento).toBe(0)
|
||||||
|
expect(r.saldoAFinanciar).toBe(845000) // 1,000,000 − 155,000 del esquema
|
||||||
|
expect(r.pagoContado).toBe(0)
|
||||||
|
expect(r.montoCredito).toBe(845000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('esquema FRASA 2026 anidado: firma única $65,000 y diferible $75,000', () => {
|
||||||
|
expect(r.esquema.pagoUnicoFirma).toBe(65000) // saldo firma 10,000 + escrituración 55,000
|
||||||
|
expect(r.esquema.baseDiferible).toBe(75000)
|
||||||
|
expect(r.esquema.totalPagado).toBeCloseTo(155000, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mensualidad estimada con la fórmula de anualidad (10.5% a 20 años)', () => {
|
||||||
|
const esperada = anualidadEsperada(845000, 0.105, 20)
|
||||||
|
expect(esperada).toBeGreaterThan(8000)
|
||||||
|
expect(esperada).toBeLessThan(8500) // ~8,436.xx
|
||||||
|
expect(r.mensualidadEstimada).toBe(esperada)
|
||||||
|
expect(r.interesesEstimados).toBeGreaterThan(0)
|
||||||
|
expect(r.interesesEstimados).toBe(
|
||||||
|
Number((r.mensualidadEstimada! * 12 * 20 - r.montoCredito).toFixed(2)),
|
||||||
|
)
|
||||||
|
expect(r.totalEstimado).toBe(Number((r.precioFinal + r.interesesEstimados).toFixed(2)))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ahorro vs lista 0 y esquema vigente', () => {
|
||||||
|
expect(r.ahorroVsLista).toBe(0)
|
||||||
|
expect(r.esquemaVigente).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — Contado (2% de descuento)', () => {
|
||||||
|
const r = opcion('CONTADO')
|
||||||
|
|
||||||
|
it('precio final $980,000 y ahorro de $20,000', () => {
|
||||||
|
expect(r.descuento).toBe(0.02)
|
||||||
|
expect(r.montoDescuento).toBe(20000)
|
||||||
|
expect(r.precioFinal).toBe(980000)
|
||||||
|
expect(r.ahorroVsLista).toBe(20000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saldo a financiar = round2(980,000 × 0.845) = $828,100', () => {
|
||||||
|
expect(r.saldoAFinanciar).toBe(828100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('esquema sobre el precio final: firma única $63,400', () => {
|
||||||
|
expect(r.esquema.saldoFirma).toBe(9500) // 24,500 − 15,000 apartado
|
||||||
|
expect(r.esquema.escrituracion).toBe(53900) // 5.5% de 980,000
|
||||||
|
expect(r.esquema.pagoUnicoFirma).toBe(63400) // 9,500 + 53,900
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin credito: mensualidad null, intereses 0 y total estimado = precio final', () => {
|
||||||
|
expect(r.montoCredito).toBe(0)
|
||||||
|
expect(r.pagoContado).toBe(0)
|
||||||
|
expect(r.mensualidadEstimada).toBeNull()
|
||||||
|
expect(r.interesesEstimados).toBe(0)
|
||||||
|
expect(r.totalEstimado).toBe(980000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — Semi Contado (1% + 50% de contado)', () => {
|
||||||
|
const r = opcion('SEMI_CONTADO')
|
||||||
|
|
||||||
|
it('precio final $990,000 y saldo a financiar $836,550', () => {
|
||||||
|
expect(r.montoDescuento).toBe(10000)
|
||||||
|
expect(r.precioFinal).toBe(990000)
|
||||||
|
expect(r.saldoAFinanciar).toBe(836550) // round2(990,000 × 0.845)
|
||||||
|
expect(r.esquemaVigente).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pago de contado y crédito mitad y mitad', () => {
|
||||||
|
expect(r.pctContado).toBe(0.5)
|
||||||
|
expect(r.pagoContado).toBe(418275)
|
||||||
|
expect(r.montoCredito).toBe(418275)
|
||||||
|
const esperada = anualidadEsperada(418275, 0.105, 20)
|
||||||
|
expect(r.mensualidadEstimada).toBe(esperada)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — Crédito Infonavit-Cofinavit', () => {
|
||||||
|
it('sin descuento, plazo default 25 años y esquema vigente', () => {
|
||||||
|
const r = opcion('CREDITO_INFONAVIT_COFINAVIT')
|
||||||
|
expect(r.precioFinal).toBe(1000000)
|
||||||
|
expect(r.saldoAFinanciar).toBe(845000)
|
||||||
|
expect(r.plazoAnos).toBe(25)
|
||||||
|
expect(r.esquemaVigente).toBe(true)
|
||||||
|
const esperada = anualidadEsperada(845000, 0.105, 25)
|
||||||
|
expect(r.mensualidadEstimada).toBe(esperada)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — saldo con los 4 tipos', () => {
|
||||||
|
it('con apartado <= 2.5% del precio final, saldo = round2(precioFinal × 0.845)', () => {
|
||||||
|
for (const tipo of TIPOS_ORDEN) {
|
||||||
|
const r = opcion(tipo)
|
||||||
|
expect(r.saldoAFinanciar).toBe(Number((r.precioFinal * 0.845).toFixed(2)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validarAjustes', () => {
|
||||||
|
it('rechaza tasas fuera de rango (0%..50%)', () => {
|
||||||
|
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: -0.1, plazoAnos: 20 })).toMatch(/tasa/i)
|
||||||
|
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.6, plazoAnos: 20 })).toMatch(/tasa/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza plazos inválidos (entero 1..35)', () => {
|
||||||
|
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 0 })).toMatch(/plazo/i)
|
||||||
|
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 3.5 })).toMatch(/plazo/i)
|
||||||
|
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 36 })).toMatch(/plazo/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza pctContado fuera de 0..1 solo para Semi Contado', () => {
|
||||||
|
expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.2 })).toMatch(/contado/i)
|
||||||
|
expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('acepta ajustes válidos', () => {
|
||||||
|
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 20 })).toBeNull()
|
||||||
|
expect(validarAjustes('CONTADO', { tasaAnual: 0, plazoAnos: 1 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calcularOpcionCredito lanza Error cuando la validación falla', () => {
|
||||||
|
expect(() =>
|
||||||
|
calcularOpcionCredito({
|
||||||
|
...CASO,
|
||||||
|
tipo: 'SEMI_CONTADO',
|
||||||
|
ajustes: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.5 },
|
||||||
|
}),
|
||||||
|
).toThrow(/contado/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — esquema no vigente', () => {
|
||||||
|
it('apartado que excede el 10% del precio final con descuento: enganche negativo', () => {
|
||||||
|
// Contado: precio final 980,000 → firma 2.5% = 24,500 y enganche 7.5% = 73,500
|
||||||
|
// Apartado 200,000: excedente 175,500 > 73,500 → enganche = 73,500 − 175,500 < 0
|
||||||
|
const r = opcion('CONTADO', { apartado: 200000 })
|
||||||
|
expect(r.precioFinal).toBe(980000)
|
||||||
|
expect(r.esquema.excedenteApartado).toBe(175500)
|
||||||
|
expect(r.esquema.enganche).toBeLessThan(0)
|
||||||
|
expect(r.esquemaVigente).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — tasa cero', () => {
|
||||||
|
it('mensualidad = monto / n cuando la tasa es 0', () => {
|
||||||
|
const r = opcion('CREDITO_BANCARIO', { ajustes: { tasaAnual: 0, plazoAnos: 20 } })
|
||||||
|
const mensualidad = Number((845000 / 240).toFixed(2))
|
||||||
|
expect(r.mensualidadEstimada).toBe(mensualidad)
|
||||||
|
// Con tasa 0 los intereses son el redondeo de la mensualidad (±centavos)
|
||||||
|
expect(r.interesesEstimados).toBe(Number((mensualidad * 240 - 845000).toFixed(2)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcularOpcionCredito — enganche adicional', () => {
|
||||||
|
it('el saldo a financiar de cada opción toma el adicional (y se acota al 90%)', () => {
|
||||||
|
const base = {
|
||||||
|
precioLista: 1000000,
|
||||||
|
apartado: 15000,
|
||||||
|
numMensualidades: 14,
|
||||||
|
escrituracionDiferida: false,
|
||||||
|
ajustes: AJUSTES_DEFAULT.CREDITO_BANCARIO,
|
||||||
|
}
|
||||||
|
const r = calcularOpcionCredito({ ...base, tipo: 'CREDITO_BANCARIO', engancheAdicional: 400000 })
|
||||||
|
expect(r.saldoAFinanciar).toBe(445000)
|
||||||
|
expect(r.montoCredito).toBe(445000)
|
||||||
|
// Contado: precio final 980,000 → saldo 828,100 − 400,000 = 428,100 de contado
|
||||||
|
const contado = calcularOpcionCredito({ ...base, tipo: 'CONTADO', ajustes: AJUSTES_DEFAULT.CONTADO, engancheAdicional: 400000 })
|
||||||
|
expect(contado.saldoAFinanciar).toBe(428100)
|
||||||
|
})
|
||||||
|
})
|
||||||
194
apps/cotizador/src/lib/opciones-credito.ts
Normal file
194
apps/cotizador/src/lib/opciones-credito.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Opciones de credito sobre el esquema FRASA 2026 (Excel del cliente, 4
|
||||||
|
* propuestas): Crédito Bancario, Crédito Infonavit-Cofinavit, Semi Contado
|
||||||
|
* y Contado. Cada opcion aplica su descuento (0/0/1%/2%) al precio de lista,
|
||||||
|
* recalcula el esquema FRASA 2026 sobre el precio final y estima la
|
||||||
|
* mensualidad del credito hipotecario restante (saldo a financiar = 84.5%
|
||||||
|
* del precio final, una vez saldado el 15.5% del esquema).
|
||||||
|
*
|
||||||
|
* Módulo puro (sin UI, sin API) para probarlo a fondo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PCT_ENGANCHE_MAXIMO, calcularEsquemaFrasa, type EsquemaFrasaResultado } from './esquema-frasa'
|
||||||
|
|
||||||
|
export type TipoCredito =
|
||||||
|
| 'CREDITO_BANCARIO'
|
||||||
|
| 'CREDITO_INFONAVIT_COFINAVIT'
|
||||||
|
| 'SEMI_CONTADO'
|
||||||
|
| 'CONTADO'
|
||||||
|
|
||||||
|
export interface AjustesCredito {
|
||||||
|
/** Tasa anual nominal (0.105 = 10.5%) */
|
||||||
|
tasaAnual: number
|
||||||
|
/** Plazo en años (entero, 1..35) */
|
||||||
|
plazoAnos: number
|
||||||
|
/**
|
||||||
|
* Solo SEMI_CONTADO: fraccion (0..1) del saldo a financiar que el cliente
|
||||||
|
* liquida de contado a la firma; el resto va al credito hipotecario.
|
||||||
|
*/
|
||||||
|
pctContado?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Descuentos sobre el precio de lista definidos en el Excel del cliente */
|
||||||
|
export const DESCUENTO_POR_TIPO: Record<TipoCredito, number> = {
|
||||||
|
CREDITO_BANCARIO: 0,
|
||||||
|
CREDITO_INFONAVIT_COFINAVIT: 0,
|
||||||
|
SEMI_CONTADO: 0.01,
|
||||||
|
CONTADO: 0.02,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AJUSTES_DEFAULT: Record<TipoCredito, AjustesCredito> = {
|
||||||
|
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 },
|
||||||
|
// Valores dummy: el contado no usa credito
|
||||||
|
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orden de presentacion de las 4 opciones (el del Excel del cliente) */
|
||||||
|
export const TIPOS_ORDEN: readonly TipoCredito[] = [
|
||||||
|
'CREDITO_BANCARIO',
|
||||||
|
'CREDITO_INFONAVIT_COFINAVIT',
|
||||||
|
'SEMI_CONTADO',
|
||||||
|
'CONTADO',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TITULO_POR_TIPO: Record<TipoCredito, string> = {
|
||||||
|
CREDITO_BANCARIO: 'Crédito Bancario',
|
||||||
|
CREDITO_INFONAVIT_COFINAVIT: 'Crédito Infonavit-Cofinavit',
|
||||||
|
SEMI_CONTADO: 'Semi Contado',
|
||||||
|
CONTADO: 'Contado',
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (x: number): number => Number(x.toFixed(2))
|
||||||
|
|
||||||
|
export interface EntradaOpcion {
|
||||||
|
/** Precio de lista de la unidad (sin descuento) */
|
||||||
|
precioLista: number
|
||||||
|
apartado: number
|
||||||
|
numMensualidades: number
|
||||||
|
escrituracionDiferida: boolean
|
||||||
|
/** Enganche adicional (monto absoluto, >= 0): se acota al 90% del anticipo */
|
||||||
|
engancheAdicional?: number
|
||||||
|
tipo: TipoCredito
|
||||||
|
ajustes: AjustesCredito
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoOpcionCredito {
|
||||||
|
tipo: TipoCredito
|
||||||
|
titulo: string
|
||||||
|
/** Fraccion de descuento sobre el precio de lista (0/0.01/0.02) */
|
||||||
|
descuento: number
|
||||||
|
montoDescuento: number
|
||||||
|
/** precioLista − montoDescuento (base del esquema FRASA 2026) */
|
||||||
|
precioFinal: number
|
||||||
|
/** Esquema FRASA 2026 calculado sobre el precio final (sin redondear) */
|
||||||
|
esquema: EsquemaFrasaResultado
|
||||||
|
/** precioFinal − esquema.totalPagado (lo que queda para financiar) */
|
||||||
|
saldoAFinanciar: number
|
||||||
|
/** SEMI: saldoAFinanciar * pctContado; resto 0 */
|
||||||
|
pagoContado: number
|
||||||
|
/** saldoAFinanciar − pagoContado (0 en CONTADO) */
|
||||||
|
montoCredito: number
|
||||||
|
tasaAnual: number
|
||||||
|
plazoAnos: number
|
||||||
|
pctContado?: number
|
||||||
|
/** Anualidad del credito; null en CONTADO */
|
||||||
|
mensualidadEstimada: number | null
|
||||||
|
/** mensualidad * 12 * plazoAnos − montoCredito (0 en CONTADO) */
|
||||||
|
interesesEstimados: number
|
||||||
|
/** precioFinal + interesesEstimados */
|
||||||
|
totalEstimado: number
|
||||||
|
/** === montoDescuento: lo que se ahorra vs el precio de lista */
|
||||||
|
ahorroVsLista: number
|
||||||
|
/** false si el apartado excede el 10% del precio final (enganche negativo) */
|
||||||
|
esquemaVigente: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error de validación de ajustes; null si son válidos */
|
||||||
|
export function validarAjustes(tipo: TipoCredito, a: AjustesCredito): string | null {
|
||||||
|
if (!Number.isFinite(a.tasaAnual) || a.tasaAnual < 0 || a.tasaAnual > 0.5) {
|
||||||
|
return 'La tasa anual debe estar entre 0% y 50%'
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(a.plazoAnos) || a.plazoAnos < 1 || a.plazoAnos > 35) {
|
||||||
|
return 'El plazo debe ser un número entero de 1 a 35 años'
|
||||||
|
}
|
||||||
|
if (tipo === 'SEMI_CONTADO' && a.pctContado !== undefined) {
|
||||||
|
if (!Number.isFinite(a.pctContado) || a.pctContado < 0 || a.pctContado > 1) {
|
||||||
|
return 'El porcentaje de contado debe estar entre 0% y 100%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anualidad (mensualidad) de un crédito: monto * i / (1 − (1+i)^−n) */
|
||||||
|
function mensualidadCredito(monto: number, tasaAnual: number, plazoAnos: number): number {
|
||||||
|
const n = plazoAnos * 12
|
||||||
|
if (tasaAnual === 0) return monto / n
|
||||||
|
const i = tasaAnual / 12
|
||||||
|
return (monto * i) / (1 - Math.pow(1 + i, -n))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcularOpcionCredito(e: EntradaOpcion): ResultadoOpcionCredito {
|
||||||
|
const errorAjustes = validarAjustes(e.tipo, e.ajustes)
|
||||||
|
if (errorAjustes) throw new Error(errorAjustes)
|
||||||
|
|
||||||
|
const descuento = DESCUENTO_POR_TIPO[e.tipo]
|
||||||
|
const montoDescuento = round2(e.precioLista * descuento)
|
||||||
|
const precioFinal = e.precioLista - montoDescuento
|
||||||
|
|
||||||
|
const esquemaBase = calcularEsquemaFrasa({
|
||||||
|
precio: precioFinal,
|
||||||
|
apartado: e.apartado,
|
||||||
|
numMensualidades: e.numMensualidades,
|
||||||
|
escrituracionDiferida: e.escrituracionDiferida,
|
||||||
|
})
|
||||||
|
// El adicional se acota igual que en el hook: el anticipo total no puede
|
||||||
|
// superar el 90% del precio final de la opción
|
||||||
|
const adicionalSolicitado = Math.max(0, e.engancheAdicional ?? 0)
|
||||||
|
const adicionalEfectivo = Math.min(adicionalSolicitado, round2(precioFinal * PCT_ENGANCHE_MAXIMO - esquemaBase.totalPagado))
|
||||||
|
const esquema =
|
||||||
|
adicionalEfectivo > 0
|
||||||
|
? calcularEsquemaFrasa({
|
||||||
|
precio: precioFinal,
|
||||||
|
apartado: e.apartado,
|
||||||
|
numMensualidades: e.numMensualidades,
|
||||||
|
escrituracionDiferida: e.escrituracionDiferida,
|
||||||
|
engancheAdicional: adicionalEfectivo,
|
||||||
|
})
|
||||||
|
: esquemaBase
|
||||||
|
|
||||||
|
const saldoAFinanciar = round2(esquema.saldoAFinanciar)
|
||||||
|
const esContado = e.tipo === 'CONTADO'
|
||||||
|
const pctContado = e.tipo === 'SEMI_CONTADO' ? (e.ajustes.pctContado ?? 0) : 0
|
||||||
|
const pagoContado = esContado ? 0 : round2(saldoAFinanciar * pctContado)
|
||||||
|
const montoCredito = esContado ? 0 : round2(saldoAFinanciar - pagoContado)
|
||||||
|
|
||||||
|
const mensualidadEstimada = esContado
|
||||||
|
? null
|
||||||
|
: round2(mensualidadCredito(montoCredito, e.ajustes.tasaAnual, e.ajustes.plazoAnos))
|
||||||
|
const interesesEstimados = esContado
|
||||||
|
? 0
|
||||||
|
: round2(mensualidadEstimada! * 12 * e.ajustes.plazoAnos - montoCredito)
|
||||||
|
const totalEstimado = round2(precioFinal + interesesEstimados)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tipo: e.tipo,
|
||||||
|
titulo: TITULO_POR_TIPO[e.tipo],
|
||||||
|
descuento,
|
||||||
|
montoDescuento,
|
||||||
|
precioFinal,
|
||||||
|
esquema,
|
||||||
|
saldoAFinanciar,
|
||||||
|
pagoContado,
|
||||||
|
montoCredito,
|
||||||
|
tasaAnual: e.ajustes.tasaAnual,
|
||||||
|
plazoAnos: e.ajustes.plazoAnos,
|
||||||
|
...(e.tipo === 'SEMI_CONTADO' ? { pctContado: e.ajustes.pctContado } : {}),
|
||||||
|
mensualidadEstimada,
|
||||||
|
interesesEstimados,
|
||||||
|
totalEstimado,
|
||||||
|
ahorroVsLista: montoDescuento,
|
||||||
|
esquemaVigente: esquema.enganche >= 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
|
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
|
||||||
import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations'
|
import { calcularEsquemaFrasa } from './esquema-frasa'
|
||||||
|
import { AJUSTES_DEFAULT, calcularOpcionCredito } from './opciones-credito'
|
||||||
import { UNIDADES_SEED } from '../data/unidades'
|
import { UNIDADES_SEED } from '../data/unidades'
|
||||||
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
|
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
|
||||||
import { formatoMoneda } from './formatters'
|
import { formatoMoneda } from './formatters'
|
||||||
|
|
||||||
|
/** Esquema único FRASA 2026: caso dorado $1,000,000 / apartado $15,000 / N=14 */
|
||||||
|
const esquemaDorado = calcularEsquemaFrasa({
|
||||||
|
precio: 1000000,
|
||||||
|
apartado: 15000,
|
||||||
|
numMensualidades: 14,
|
||||||
|
escrituracionDiferida: false,
|
||||||
|
})
|
||||||
|
|
||||||
describe('crearDocumentoPdf', () => {
|
describe('crearDocumentoPdf', () => {
|
||||||
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
|
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
|
||||||
const unidad = UNIDADES_SEED[0]
|
const unidad = UNIDADES_SEED[0]
|
||||||
const propuestas = calcularPropuestas({
|
|
||||||
unidad,
|
|
||||||
acabados: true,
|
|
||||||
apartado: APARTADO_DEFAULT,
|
|
||||||
descuentos: { ...DESCUENTOS_DEFAULT },
|
|
||||||
numMeses: 14,
|
|
||||||
})
|
|
||||||
const { doc, nombreArchivo } = crearDocumentoPdf({
|
const { doc, nombreArchivo } = crearDocumentoPdf({
|
||||||
unidad,
|
unidad,
|
||||||
acabados: true,
|
acabados: true,
|
||||||
apartado: APARTADO_DEFAULT,
|
apartado: 15000,
|
||||||
precioLista: unidad.precioConAcabados,
|
precioLista: unidad.precioConAcabados,
|
||||||
propuestas,
|
esquema: { ...esquemaDorado, precio: unidad.precioConAcabados },
|
||||||
cliente: { nombre: 'Juan Pérez' },
|
cliente: { nombre: 'Juan Pérez' },
|
||||||
})
|
})
|
||||||
expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/)
|
expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/)
|
||||||
@@ -30,24 +32,38 @@ describe('crearDocumentoPdf', () => {
|
|||||||
expect(texto.length).toBeGreaterThan(0)
|
expect(texto.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => {
|
it('imprime la línea de tiempo del esquema (hoy / firma / mensualidades)', () => {
|
||||||
const unidad = UNIDADES_SEED[0]
|
const unidad = UNIDADES_SEED[0]
|
||||||
const propuestas = calcularPropuestas({
|
const { doc } = crearDocumentoPdf({
|
||||||
unidad,
|
unidad,
|
||||||
acabados: true,
|
acabados: true,
|
||||||
apartado: APARTADO_DEFAULT,
|
apartado: 15000,
|
||||||
descuentos: { ...DESCUENTOS_DEFAULT },
|
precioLista: unidad.precioConAcabados,
|
||||||
numMeses: 14,
|
esquema: esquemaDorado,
|
||||||
|
cliente: { nombre: 'Juan Pérez' },
|
||||||
})
|
})
|
||||||
|
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
|
||||||
|
expect(pdf).toContain('ESQUEMA FRASA 2026')
|
||||||
|
// jspdf escapa los paréntesis en el stream: se valida el texto sin ellos
|
||||||
|
expect(pdf).toContain('APARTADO')
|
||||||
|
expect(pdf).toContain('ENGANCHE DE FIRMA')
|
||||||
|
expect(pdf).toContain('GASTOS DE ESCRITURACIÓN')
|
||||||
|
expect(pdf).toContain(formatoMoneda(esquemaDorado.pagoUnicoFirma))
|
||||||
|
expect(pdf).toContain('14 × ')
|
||||||
|
expect(pdf).toContain(formatoMoneda(esquemaDorado.totalPagado))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => {
|
||||||
|
const unidad = UNIDADES_SEED[0]
|
||||||
// PNG 1x1 válido; en la app lo genera la librería qrcode
|
// PNG 1x1 válido; en la app lo genera la librería qrcode
|
||||||
const png1x1 =
|
const png1x1 =
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||||
const { doc } = crearDocumentoPdf({
|
const { doc } = crearDocumentoPdf({
|
||||||
unidad,
|
unidad,
|
||||||
acabados: true,
|
acabados: true,
|
||||||
apartado: APARTADO_DEFAULT,
|
apartado: 15000,
|
||||||
precioLista: unidad.precioConAcabados,
|
precioLista: unidad.precioConAcabados,
|
||||||
propuestas,
|
esquema: esquemaDorado,
|
||||||
cliente: { nombre: 'Juan Pérez' },
|
cliente: { nombre: 'Juan Pérez' },
|
||||||
folio: 'COT-3F8A1B',
|
folio: 'COT-3F8A1B',
|
||||||
qrDataUrl: png1x1,
|
qrDataUrl: png1x1,
|
||||||
@@ -56,21 +72,70 @@ describe('crearDocumentoPdf', () => {
|
|||||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
|
it('imprime la sección de financiamiento elegido cuando viene la opción', () => {
|
||||||
const unidad = UNIDADES_SEED[0]
|
const unidad = UNIDADES_SEED[0]
|
||||||
const propuestas = calcularPropuestas({
|
const opcion = calcularOpcionCredito({
|
||||||
unidad,
|
precioLista: 1000000,
|
||||||
acabados: true,
|
apartado: 15000,
|
||||||
apartado: APARTADO_DEFAULT,
|
numMensualidades: 14,
|
||||||
descuentos: { ...DESCUENTOS_DEFAULT },
|
escrituracionDiferida: false,
|
||||||
numMeses: 14,
|
tipo: 'CONTADO',
|
||||||
|
ajustes: AJUSTES_DEFAULT.CONTADO,
|
||||||
})
|
})
|
||||||
const { doc } = crearDocumentoPdf({
|
const { doc } = crearDocumentoPdf({
|
||||||
unidad,
|
unidad,
|
||||||
acabados: true,
|
acabados: true,
|
||||||
apartado: APARTADO_DEFAULT,
|
apartado: 15000,
|
||||||
|
precioLista: 1000000,
|
||||||
|
esquema: esquemaDorado,
|
||||||
|
opcion,
|
||||||
|
cliente: { nombre: 'Juan Pérez' },
|
||||||
|
})
|
||||||
|
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
|
||||||
|
expect(pdf).toContain('PROPUESTA DE FINANCIAMIENTO ELEGIDA')
|
||||||
|
expect(pdf).toContain('CONTADO')
|
||||||
|
// Precio final con descuento, pago de contado y ahorro vs lista
|
||||||
|
expect(pdf).toContain(formatoMoneda(opcion.precioFinal))
|
||||||
|
expect(pdf).toContain(formatoMoneda(opcion.saldoAFinanciar))
|
||||||
|
expect(pdf).toContain(formatoMoneda(opcion.ahorroVsLista))
|
||||||
|
expect(pdf).toContain('NO CONSTITUYE PREAPROBACIÓN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imprime tasa y plazo cuando la opción elegida usa crédito', () => {
|
||||||
|
const unidad = UNIDADES_SEED[0]
|
||||||
|
const opcion = calcularOpcionCredito({
|
||||||
|
precioLista: 1000000,
|
||||||
|
apartado: 15000,
|
||||||
|
numMensualidades: 14,
|
||||||
|
escrituracionDiferida: false,
|
||||||
|
tipo: 'CREDITO_BANCARIO',
|
||||||
|
ajustes: AJUSTES_DEFAULT.CREDITO_BANCARIO,
|
||||||
|
})
|
||||||
|
const { doc } = crearDocumentoPdf({
|
||||||
|
unidad,
|
||||||
|
acabados: true,
|
||||||
|
apartado: 15000,
|
||||||
|
precioLista: 1000000,
|
||||||
|
esquema: esquemaDorado,
|
||||||
|
opcion,
|
||||||
|
cliente: { nombre: 'Juan Pérez' },
|
||||||
|
})
|
||||||
|
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
|
||||||
|
expect(pdf).toContain('CRÉDITO BANCARIO')
|
||||||
|
expect(pdf).toContain('MENSUALIDAD ESTIMADA')
|
||||||
|
expect(pdf).toContain(formatoMoneda(opcion.mensualidadEstimada!))
|
||||||
|
expect(pdf).toContain('INTERESES ESTIMADOS')
|
||||||
|
expect(pdf).toContain('10.5% A 20 AÑOS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
|
||||||
|
const unidad = UNIDADES_SEED[0]
|
||||||
|
const { doc } = crearDocumentoPdf({
|
||||||
|
unidad,
|
||||||
|
acabados: true,
|
||||||
|
apartado: 15000,
|
||||||
precioLista: unidad.precioConAcabados,
|
precioLista: unidad.precioConAcabados,
|
||||||
propuestas,
|
esquema: esquemaDorado,
|
||||||
cliente: { nombre: 'Juan Pérez' },
|
cliente: { nombre: 'Juan Pérez' },
|
||||||
folio: 'CTE-LOCAL-1757568000000',
|
folio: 'CTE-LOCAL-1757568000000',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { jsPDF } from 'jspdf'
|
import { jsPDF } from 'jspdf'
|
||||||
import autoTable from 'jspdf-autotable'
|
import autoTable from 'jspdf-autotable'
|
||||||
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
|
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
|
||||||
import type { PropuestaResultado } from './calculations'
|
import type { EsquemaFrasaResultado } from './esquema-frasa'
|
||||||
|
import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } from './esquema-frasa'
|
||||||
|
import type { ResultadoOpcionCredito } from './opciones-credito'
|
||||||
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
|
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
|
||||||
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
|
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
|
||||||
|
|
||||||
@@ -10,8 +12,10 @@ export interface PdfInput {
|
|||||||
acabados: boolean
|
acabados: boolean
|
||||||
apartado: number
|
apartado: number
|
||||||
precioLista: number
|
precioLista: number
|
||||||
/** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */
|
/** Esquema único FRASA 2026 a imprimir */
|
||||||
propuestas: PropuestaResultado[]
|
esquema: EsquemaFrasaResultado
|
||||||
|
/** Opción de financiamiento elegida por el vendedor (sección de financiamiento) */
|
||||||
|
opcion?: ResultadoOpcionCredito
|
||||||
cliente: Cliente
|
cliente: Cliente
|
||||||
/** Parámetros del desarrollo; por defecto DIAGONAL */
|
/** Parámetros del desarrollo; por defecto DIAGONAL */
|
||||||
desarrollo?: ParametrosDesarrollo
|
desarrollo?: ParametrosDesarrollo
|
||||||
@@ -30,7 +34,7 @@ export const textoCuotaMantenimiento = (cuota: number | null): string =>
|
|||||||
cuota === null ? 'PENDIENTE' : formatoMoneda(cuota)
|
cuota === null ? 'PENDIENTE' : formatoMoneda(cuota)
|
||||||
|
|
||||||
const DISCLAIMER =
|
const DISCLAIMER =
|
||||||
'ESTA COTIZACION NO INCLUYE GASTOS DE ESCRITURACION. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL MONTO TOTAL DE ENGANCHE INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
|
'ESTA COTIZACION INCLUYE LOS GASTOS DE ESCRITURACION (5.5% DEL PRECIO), PAGADEROS A LA FIRMA O DIFERIDOS CON EL ENGANCHE SEGUN LO INDICADO. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL ENGANCHE DE FIRMA (2.5%) INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO; SI EL APARTADO LO EXCEDE, EL EXCEDENTE SE ACREDITA AL ENGANCHE.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
|
||||||
|
|
||||||
const CUENTA_DEPOSITO =
|
const CUENTA_DEPOSITO =
|
||||||
'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES'
|
'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES'
|
||||||
@@ -50,7 +54,7 @@ export interface DocumentoPdf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
|
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
|
||||||
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
|
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, opcion, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
|
||||||
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
|
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
|
||||||
const doc = new jsPDF()
|
const doc = new jsPDF()
|
||||||
const hoy = new Date()
|
const hoy = new Date()
|
||||||
@@ -128,52 +132,107 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
|
|||||||
})
|
})
|
||||||
y = finalY(doc) + 8
|
y = finalY(doc) + 8
|
||||||
|
|
||||||
// 3. Propuesta general — una tabla por propuesta
|
// 3. Esquema único FRASA 2026 — una sola tabla con la línea de tiempo
|
||||||
doc.setFontSize(12)
|
doc.setFontSize(12)
|
||||||
doc.setFont('helvetica', 'bold')
|
doc.setFont('helvetica', 'bold')
|
||||||
doc.text('PROPUESTA GENERAL', MARGEN, y)
|
doc.text('PROPUESTA GENERAL — ESQUEMA FRASA 2026', MARGEN, y)
|
||||||
y += 4
|
y += 4
|
||||||
|
|
||||||
propuestas.forEach((p, i) => {
|
autoTable(doc, {
|
||||||
const mensualidades = p.notaMensualidades
|
startY: y,
|
||||||
? { pagos: `— (${p.notaMensualidades})`, total: '—' }
|
margin: { left: MARGEN, right: MARGEN },
|
||||||
: p.mensualidad !== null && p.mensualidadesTotal !== null
|
styles: { fontSize: 8 },
|
||||||
? {
|
headStyles: { fillColor: [37, 99, 235] },
|
||||||
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
|
head: [['CONCEPTO', '%', 'PAGOS MENSUALES', 'TOTAL']],
|
||||||
total: formatoMoneda(p.mensualidadesTotal),
|
body: [
|
||||||
}
|
['APARTADO (HOY)', '', '', formatoMoneda(apartado)],
|
||||||
: { pagos: 'N/A', total: 'N/A' }
|
[
|
||||||
|
`ENGANCHE DE FIRMA (${formatoPorcentaje(PCT_ENGANCHE_FIRMA)})`,
|
||||||
|
formatoPorcentaje(PCT_ENGANCHE_FIRMA),
|
||||||
|
'',
|
||||||
|
formatoMoneda(esquema.engancheFirmaTotal),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`SALDO A LA FIRMA (firma − apartado${esquema.excedenteApartado > 0 ? ' + excedente al enganche' : ''})`,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
formatoMoneda(esquema.saldoFirma),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`GASTOS DE ESCRITURACIÓN (${formatoPorcentaje(PCT_ESCRITURACION)}) — ${esquema.escrituracionDiferida ? 'DIFERIDOS CON EL ENGANCHE' : 'PAGADOS A LA FIRMA'}`,
|
||||||
|
formatoPorcentaje(PCT_ESCRITURACION),
|
||||||
|
'',
|
||||||
|
formatoMoneda(esquema.escrituracion),
|
||||||
|
],
|
||||||
|
['PAGO A LA FIRMA', '', '', formatoMoneda(esquema.pagoUnicoFirma)],
|
||||||
|
[
|
||||||
|
`ENGANCHE (${formatoPorcentaje(PCT_ENGANCHE)}) DIFERIBLE`,
|
||||||
|
formatoPorcentaje(PCT_ENGANCHE),
|
||||||
|
'',
|
||||||
|
formatoMoneda(esquema.enganche),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'MENSUALIDADES',
|
||||||
|
'',
|
||||||
|
`${esquema.numMensualidades} × ${formatoMoneda(esquema.mensualidad)}`,
|
||||||
|
formatoMoneda(esquema.baseDiferible),
|
||||||
|
],
|
||||||
|
['TOTAL DEL ESQUEMA', '', '', formatoMoneda(esquema.totalPagado)],
|
||||||
|
...(esquema.engancheAdicional > 0
|
||||||
|
? [['ENGANCHE ADICIONAL (ANTICIPO EXTRA)', '', '', formatoMoneda(esquema.engancheAdicional)]]
|
||||||
|
: []),
|
||||||
|
['SALDO A FINANCIAR', '', '', formatoMoneda(esquema.saldoAFinanciar)],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
y = finalY(doc) + 6
|
||||||
|
y += 2
|
||||||
|
|
||||||
// Con una sola propuesta (la elegida por el vendedor) no se numera
|
// 3.1 Propuesta de financiamiento elegida (una de las 4 opciones de crédito)
|
||||||
const encabezado =
|
if (opcion) {
|
||||||
propuestas.length === 1
|
doc.setFontSize(12)
|
||||||
? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}`
|
doc.setFont('helvetica', 'bold')
|
||||||
: `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`
|
doc.text(`PROPUESTA DE FINANCIAMIENTO ELEGIDA — ${opcion.titulo.toUpperCase()}`, MARGEN, y)
|
||||||
|
y += 4
|
||||||
|
|
||||||
|
const renglones: string[][] = [
|
||||||
|
['PRECIO FINAL C/DESC.', formatoMoneda(opcion.precioFinal)],
|
||||||
|
...(opcion.montoDescuento > 0
|
||||||
|
? [[`DESCUENTO (${formatoPorcentaje(opcion.descuento)})`, formatoMoneda(opcion.montoDescuento)]]
|
||||||
|
: []),
|
||||||
|
...(opcion.tipo === 'CONTADO'
|
||||||
|
? [
|
||||||
|
['PAGO DE CONTADO', formatoMoneda(opcion.saldoAFinanciar)],
|
||||||
|
['AHORRO VS PRECIO DE LISTA', formatoMoneda(opcion.ahorroVsLista)],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
...(opcion.tipo === 'SEMI_CONTADO'
|
||||||
|
? [
|
||||||
|
['PAGO DE CONTADO A LA FIRMA', formatoMoneda(opcion.pagoContado)],
|
||||||
|
['SALDO RESTANTE A FINANCIAR', formatoMoneda(opcion.montoCredito)],
|
||||||
|
]
|
||||||
|
: [['SALDO A FINANCIAR', formatoMoneda(opcion.saldoAFinanciar)]]),
|
||||||
|
['MONTO DEL CRÉDITO', formatoMoneda(opcion.montoCredito)],
|
||||||
|
['TASA Y PLAZO', `${(opcion.tasaAnual * 100).toLocaleString('es-MX')}% A ${opcion.plazoAnos} AÑOS`],
|
||||||
|
['MENSUALIDAD ESTIMADA', formatoMoneda(opcion.mensualidadEstimada ?? 0)],
|
||||||
|
['INTERESES ESTIMADOS', formatoMoneda(opcion.interesesEstimados)],
|
||||||
|
]),
|
||||||
|
['TOTAL ESTIMADO', formatoMoneda(opcion.totalEstimado)],
|
||||||
|
]
|
||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: y,
|
startY: y,
|
||||||
margin: { left: MARGEN, right: MARGEN },
|
margin: { left: MARGEN, right: MARGEN },
|
||||||
styles: { fontSize: 8 },
|
styles: { fontSize: 8 },
|
||||||
headStyles: { fillColor: [37, 99, 235] },
|
headStyles: { fillColor: [37, 99, 235] },
|
||||||
head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
head: [['CONCEPTO', 'TOTAL']],
|
||||||
body: [
|
body: renglones,
|
||||||
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
|
|
||||||
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
|
|
||||||
['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)],
|
|
||||||
['ENGANCHE', '', '', formatoMoneda(p.enganche)],
|
|
||||||
['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total],
|
|
||||||
['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)],
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
y = finalY(doc) + 6
|
y = finalY(doc) + 4
|
||||||
|
parrafo(
|
||||||
// Salto de página antes de la siguiente tabla si no cabe
|
'Mensualidad estimada sujeta a aprobación del banco; no constituye preaprobación.',
|
||||||
if (y > 240 && i < propuestas.length - 1) {
|
)
|
||||||
doc.addPage()
|
y += 2
|
||||||
y = MARGEN
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
y += 2
|
|
||||||
|
|
||||||
// 4. ¿Qué sigue?
|
// 4. ¿Qué sigue?
|
||||||
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })
|
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { INVENTARIO_SEED } from '../data/unidades'
|
|||||||
/**
|
/**
|
||||||
* Reglas configurables por el admin en la página del cotizador:
|
* Reglas configurables por el admin en la página del cotizador:
|
||||||
* - un desarrollo con activoEnCotizador: false no se ofrece
|
* - un desarrollo con activoEnCotizador: false no se ofrece
|
||||||
* - una propuesta desactivada no se muestra ni se puede elegir
|
* - una propuesta desactivada ya NO afecta: el esquema único FRASA 2026 no
|
||||||
|
* usa las 4 propuestas (sigue cotizando igual)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
|
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
|
||||||
@@ -60,7 +61,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('CotizadorPage con reglas del admin', () => {
|
describe('CotizadorPage con reglas del admin', () => {
|
||||||
it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => {
|
it('no ofrece el desarrollo desactivado; una propuesta desactivada ya no bloquea el esquema', async () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<CotizadorPage />
|
<CotizadorPage />
|
||||||
@@ -74,16 +75,18 @@ describe('CotizadorPage con reglas del admin', () => {
|
|||||||
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
|
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
|
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
|
||||||
|
|
||||||
// Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes
|
// Cargar la unidad de DIAGONAL (con la propuesta Contado desactivada por
|
||||||
|
// el admin): el esquema único se calcula igual, sin propuestas
|
||||||
fireEvent.focus(inputUnidad)
|
fireEvent.focus(inputUnidad)
|
||||||
fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } })
|
fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } })
|
||||||
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) }))
|
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Crédito Bancario')).toBeTruthy()
|
expect(screen.getByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Crédito Infonavit-Cofinavit')).toBeTruthy()
|
// La línea de tiempo del esquema (Hoy / A la firma / Después)
|
||||||
expect(screen.getByText('Semi Contado')).toBeTruthy()
|
expect(screen.getByText('Hoy')).toBeTruthy()
|
||||||
expect(screen.queryByText('Contado')).toBeNull()
|
expect(screen.getByText('A la firma')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Después')).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
|||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import CotizadorPage from './CotizadorPage'
|
import CotizadorPage from './CotizadorPage'
|
||||||
import { INVENTARIO_SEED } from '../data/unidades'
|
import { INVENTARIO_SEED } from '../data/unidades'
|
||||||
|
import { mensualidadesParaUnidad } from '../hooks/useCotizacion'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smoke del cierre de la cotización:
|
* Smoke del cierre de la cotización con el esquema único FRASA 2026:
|
||||||
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF
|
* - barra sticky con resumen del esquema + Compartir/Guardar/Generar PDF
|
||||||
* - preselección por defecto de la propuesta de menor total
|
* - panel único (línea de tiempo Hoy → Firma → Mensualidades)
|
||||||
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
|
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
|
||||||
*/
|
*/
|
||||||
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
|
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
|
||||||
@@ -35,7 +36,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('CotizadorPage — barra sticky y guardado local', () => {
|
describe('CotizadorPage — barra sticky y guardado local', () => {
|
||||||
it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
|
it('muestra el esquema único y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
|
||||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input)
|
const url = String(input)
|
||||||
const metodo = init?.method ?? 'GET'
|
const metodo = init?.method ?? 'GET'
|
||||||
@@ -58,7 +59,7 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
|||||||
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
|
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
|
||||||
expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy()
|
expect(screen.getByText(/Selecciona una unidad para ver el esquema de cotización\./)).toBeTruthy()
|
||||||
|
|
||||||
// Elegir una unidad del inventario
|
// Elegir una unidad del inventario
|
||||||
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
|
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
|
||||||
@@ -66,12 +67,12 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
|||||||
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
|
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
|
||||||
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
|
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
|
||||||
|
|
||||||
// Preselección por defecto: la propuesta de menor total pagado
|
// Panel único del esquema: línea de tiempo y conceptos
|
||||||
const selectPropuesta = (await screen.findByLabelText(
|
expect(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
|
||||||
'Cambiar propuesta elegida',
|
expect(screen.getByText('Hoy')).toBeTruthy()
|
||||||
)) as HTMLSelectElement
|
expect(screen.getByText('A la firma')).toBeTruthy()
|
||||||
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata
|
// El tope de mensualidades son los meses que faltan para la entrega
|
||||||
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy()
|
expect(screen.getByRole('option', { name: `${mensualidadesParaUnidad(unidad)} mensualidades` })).toBeTruthy()
|
||||||
|
|
||||||
// Guardar con el servidor caído → folio local persistido en el dispositivo
|
// Guardar con el servidor caído → folio local persistido en el dispositivo
|
||||||
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
|
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
|
||||||
@@ -82,10 +83,20 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const locales = JSON.parse(
|
const locales = JSON.parse(
|
||||||
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
|
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
|
||||||
) as { folio?: string; clienteNombre?: string }[]
|
) as {
|
||||||
|
folio?: string
|
||||||
|
clienteNombre?: string
|
||||||
|
esquema?: string
|
||||||
|
tipoPropuesta?: string
|
||||||
|
saldoFirma?: number | null
|
||||||
|
}[]
|
||||||
expect(locales).toHaveLength(1)
|
expect(locales).toHaveLength(1)
|
||||||
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
|
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
|
||||||
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
|
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
|
||||||
|
// La instantánea local ya vive en el esquema único
|
||||||
|
expect(locales[0]?.esquema).toBe('FRASA_2026')
|
||||||
|
expect(locales[0]?.tipoPropuesta).toBe('FRASA_2026')
|
||||||
|
expect(locales[0]?.saldoFirma).not.toBeNull()
|
||||||
})
|
})
|
||||||
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
|
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
|
||||||
})
|
})
|
||||||
@@ -100,12 +111,17 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
|||||||
clienteNombre: 'Cliente Antiguo',
|
clienteNombre: 'Cliente Antiguo',
|
||||||
clienteTelefono: null,
|
clienteTelefono: null,
|
||||||
clienteEmail: null,
|
clienteEmail: null,
|
||||||
|
esquema: 'FRASA_2026',
|
||||||
tipoPropuesta: 'BANCARIO',
|
tipoPropuesta: 'BANCARIO',
|
||||||
acabados: false,
|
acabados: false,
|
||||||
apartado: 20000,
|
apartado: 20000,
|
||||||
descuento: 0,
|
descuento: 0,
|
||||||
precioLista: vendida.precioConAcabados,
|
precioLista: vendida.precioConAcabados,
|
||||||
precioFinal: 0,
|
precioFinal: 0,
|
||||||
|
saldoFirma: null,
|
||||||
|
engancheDiferible: null,
|
||||||
|
escrituracion: null,
|
||||||
|
escrituracionDiferida: false,
|
||||||
enganche: 0,
|
enganche: 0,
|
||||||
numMensualidades: 12,
|
numMensualidades: 12,
|
||||||
mensualidad: 1000,
|
mensualidad: 1000,
|
||||||
@@ -139,6 +155,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
|||||||
const alerta = await screen.findByRole('alert')
|
const alerta = await screen.findByRole('alert')
|
||||||
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
|
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
|
||||||
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
|
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
|
||||||
|
// Es una cotización del esquema anterior (legacy): lo dice al retomarla
|
||||||
|
expect(screen.getByText(/Usa el esquema anterior \(BANCARIO\)/)).toBeTruthy()
|
||||||
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
|
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
|
||||||
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
|
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { useUnidades } from '../hooks/useUnidades'
|
import { useUnidades } from '../hooks/useUnidades'
|
||||||
import { useCotizacion } from '../hooks/useCotizacion'
|
import { useCotizacion, mensualidadesParaUnidad } from '../hooks/useCotizacion'
|
||||||
|
import { MENSUALIDADES_MAX } from '../lib/esquema-frasa'
|
||||||
import DarkModeToggle from '../components/DarkModeToggle'
|
import DarkModeToggle from '../components/DarkModeToggle'
|
||||||
import { SkeletonList } from "../components/Skeleton"
|
import { SkeletonList } from "../components/Skeleton"
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
import { UnitSelector } from '../components/UnitSelector'
|
import { UnitSelector } from '../components/UnitSelector'
|
||||||
import { UnitInfoCard } from '../components/UnitInfoCard'
|
import { UnitInfoCard } from '../components/UnitInfoCard'
|
||||||
import { ProposalCard } from '../components/ProposalCard'
|
import { EsquemaPanel } from '../components/EsquemaPanel'
|
||||||
import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
|
import { OpcionesCredito } from '../components/OpcionesCredito'
|
||||||
import { ComparadorUnidades } from '../components/ComparadorUnidades'
|
|
||||||
import { MisCotizaciones } from '../components/MisCotizaciones'
|
import { MisCotizaciones } from '../components/MisCotizaciones'
|
||||||
import { ModalCliente } from '../components/ModalCliente'
|
import { ModalCliente } from '../components/ModalCliente'
|
||||||
import { BuscadorCliente } from '../components/BuscadorCliente'
|
import { BuscadorCliente } from '../components/BuscadorCliente'
|
||||||
@@ -21,15 +21,20 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
|||||||
import { calcularVigencia } from '../lib/vigencia'
|
import { calcularVigencia } from '../lib/vigencia'
|
||||||
import { folioCotizacion } from '../lib/folio'
|
import { folioCotizacion } from '../lib/folio'
|
||||||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||||||
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
|
import {
|
||||||
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
|
AJUSTES_DEFAULT,
|
||||||
|
TIPOS_ORDEN,
|
||||||
|
calcularOpcionCredito,
|
||||||
|
type AjustesCredito,
|
||||||
|
type TipoCredito,
|
||||||
|
} from '../lib/opciones-credito'
|
||||||
|
import { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
|
||||||
import {
|
import {
|
||||||
getMisCotizaciones,
|
getMisCotizaciones,
|
||||||
|
getCotizacion,
|
||||||
guardarCotizacion,
|
guardarCotizacion,
|
||||||
urlPublicaCotizacion,
|
urlPublicaCotizacion,
|
||||||
ErrorApiCotizacion,
|
ErrorApiCotizacion,
|
||||||
TIPO_A_API,
|
|
||||||
TIPO_DE_API,
|
|
||||||
type CotizacionGuardada,
|
type CotizacionGuardada,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
|
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
|
||||||
@@ -38,6 +43,10 @@ import type { Cliente, Unidad } from '../types'
|
|||||||
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
|
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
|
||||||
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
|
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
|
||||||
|
|
||||||
|
/** Una cotización guardada ya vive en el esquema único (filas nuevas) */
|
||||||
|
const esEsquemaNuevo = (c: CotizacionGuardada): boolean =>
|
||||||
|
c.esquema === 'FRASA_2026' && c.saldoFirma != null
|
||||||
|
|
||||||
const esquemaCliente = z.object({
|
const esquemaCliente = z.object({
|
||||||
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
|
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
|
||||||
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
|
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
|
||||||
@@ -69,17 +78,16 @@ export default function CotizadorPage() {
|
|||||||
apartado,
|
apartado,
|
||||||
setApartado,
|
setApartado,
|
||||||
apartadoEfectivo,
|
apartadoEfectivo,
|
||||||
enganchePct,
|
|
||||||
setEnganchePct,
|
|
||||||
errorEnganche,
|
|
||||||
propuestaIndex,
|
|
||||||
setPropuestaIndex,
|
|
||||||
precioLista,
|
|
||||||
numMensualidades,
|
numMensualidades,
|
||||||
setNumMensualidades,
|
setNumMensualidades,
|
||||||
|
escrituracionDiferida,
|
||||||
|
setEscrituracionDiferida,
|
||||||
|
engancheAdicional,
|
||||||
|
setEngancheAdicional,
|
||||||
|
engancheAdicionalMaximo,
|
||||||
|
precioLista,
|
||||||
|
resultado,
|
||||||
numMeses,
|
numMeses,
|
||||||
propuestas,
|
|
||||||
avisoPropuestaDesactivada,
|
|
||||||
restaurar,
|
restaurar,
|
||||||
nueva,
|
nueva,
|
||||||
} = cotizacion
|
} = cotizacion
|
||||||
@@ -103,6 +111,9 @@ export default function CotizadorPage() {
|
|||||||
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
|
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
|
||||||
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
|
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
|
||||||
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
|
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
|
||||||
|
// Retomar una cotización llegada por URL (/quote/?cotizacion=<id>, desde la
|
||||||
|
// búsqueda global): se aplica cuando termina de cargar inventario+reglas
|
||||||
|
const [cotizacionPendiente, setCotizacionPendiente] = useState<string | null>(null)
|
||||||
|
|
||||||
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
|
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
|
||||||
// primero disponible para no dejar el cotizador en un desarrollo muerto.
|
// primero disponible para no dejar el cotizador en un desarrollo muerto.
|
||||||
@@ -117,11 +128,6 @@ export default function CotizadorPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [desarrollos, desarrollo])
|
}, [desarrollos, desarrollo])
|
||||||
|
|
||||||
// La propuesta elegida dejó de estar disponible (la desactivó el admin)
|
|
||||||
useEffect(() => {
|
|
||||||
if (avisoPropuestaDesactivada !== null) setAviso(avisoPropuestaDesactivada)
|
|
||||||
}, [avisoPropuestaDesactivada])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let vivo = true
|
let vivo = true
|
||||||
/** Combina servidor + locales, sin duplicados, más recientes primero */
|
/** Combina servidor + locales, sin duplicados, más recientes primero */
|
||||||
@@ -167,6 +173,40 @@ export default function CotizadorPage() {
|
|||||||
|
|
||||||
const nombreCliente = watch('nombre') ?? ''
|
const nombreCliente = watch('nombre') ?? ''
|
||||||
|
|
||||||
|
// 4 opciones de crédito sobre el esquema FRASA 2026 (saldo 84.5% a financiar)
|
||||||
|
const [ajustesCredito, setAjustesCredito] = useState<Record<TipoCredito, AjustesCredito>>(() =>
|
||||||
|
structuredClone(AJUSTES_DEFAULT),
|
||||||
|
)
|
||||||
|
const [opcionElegida, setOpcionElegida] = useState<TipoCredito>('CREDITO_BANCARIO')
|
||||||
|
|
||||||
|
const opcionesCredito = useMemo(() => {
|
||||||
|
// Sin unidad no hay opciones que calcular (la sección no se renderiza)
|
||||||
|
if (!Number.isFinite(precioLista) || precioLista <= 0) return []
|
||||||
|
const calcular = (ajustes: Record<TipoCredito, AjustesCredito>) =>
|
||||||
|
TIPOS_ORDEN.map((tipo) =>
|
||||||
|
calcularOpcionCredito({
|
||||||
|
precioLista,
|
||||||
|
apartado: apartadoEfectivo,
|
||||||
|
numMensualidades,
|
||||||
|
escrituracionDiferida,
|
||||||
|
engancheAdicional: resultado?.engancheAdicional ?? 0,
|
||||||
|
tipo,
|
||||||
|
ajustes: ajustes[tipo],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
return calcular(ajustesCredito)
|
||||||
|
} catch {
|
||||||
|
// Ajustes inválidos (estado restaurado corrupto, carrera de inputs):
|
||||||
|
// se recalcula con los defaults para no romper la página
|
||||||
|
try {
|
||||||
|
return calcular(AJUSTES_DEFAULT)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, ajustesCredito, resultado])
|
||||||
|
|
||||||
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
|
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
|
||||||
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
|
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,6 +229,8 @@ export default function CotizadorPage() {
|
|||||||
}
|
}
|
||||||
if (unidadParam) setUnidadPendiente(unidadParam)
|
if (unidadParam) setUnidadPendiente(unidadParam)
|
||||||
if (opId) setOportunidadId(opId)
|
if (opId) setOportunidadId(opId)
|
||||||
|
const cotizacionParam = qs.get('cotizacion')
|
||||||
|
if (cotizacionParam) setCotizacionPendiente(cotizacionParam)
|
||||||
// Solo al montar: es el punto de entrada desde Mi día
|
// Solo al montar: es el punto de entrada desde Mi día
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
@@ -221,33 +263,23 @@ export default function CotizadorPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [unidades, cargando, unidadPendiente])
|
}, [unidades, cargando, unidadPendiente])
|
||||||
|
|
||||||
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
// Retoma la cotización pedida por URL cuando el inventario ya cargó
|
||||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
useEffect(() => {
|
||||||
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
if (cotizacionPendiente === null || cargando) return
|
||||||
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
|
const id = cotizacionPendiente
|
||||||
|
setCotizacionPendiente(null)
|
||||||
|
void getCotizacion(id)
|
||||||
|
.then((c) => {
|
||||||
|
if (c) handleRetomar(c)
|
||||||
|
else setAviso(`No se encontró la cotización entre las tuyas (${id}).`)
|
||||||
|
})
|
||||||
|
.catch(() => setAviso('No se pudo cargar la cotización de la liga.'))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [cotizacionPendiente, cargando, unidades])
|
||||||
|
|
||||||
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
|
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
|
||||||
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
|
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
|
||||||
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando
|
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !guardando
|
||||||
|
|
||||||
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
|
|
||||||
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
|
|
||||||
// más barata; la elección manual del vendedor se respeta hasta el siguiente
|
|
||||||
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
|
|
||||||
const unidadPrevia = useRef<string | null | undefined>(undefined)
|
|
||||||
const saltarAuto = useRef(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const codigo = unidad?.codigo ?? null
|
|
||||||
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
|
|
||||||
unidadPrevia.current = codigo
|
|
||||||
if (!cambioUnidad || saltarAuto.current) {
|
|
||||||
saltarAuto.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
|
|
||||||
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
|
|
||||||
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
|
|
||||||
}, [unidad?.codigo, propuestas, apartadoEfectivo])
|
|
||||||
|
|
||||||
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
|
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
|
||||||
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
|
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
|
||||||
@@ -255,24 +287,32 @@ export default function CotizadorPage() {
|
|||||||
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
|
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
|
||||||
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
|
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
|
if (!unidad || !resultado) throw new Error('No hay esquema para guardar localmente')
|
||||||
return {
|
return {
|
||||||
id: `local-${timestamp}`,
|
id: `local-${timestamp}`,
|
||||||
folio: `CTE-LOCAL-${timestamp}`,
|
folio: `CTE-LOCAL-${timestamp}`,
|
||||||
unidadCodigo: unidad!.codigo,
|
unidadCodigo: unidad.codigo,
|
||||||
desarrollo,
|
desarrollo,
|
||||||
clienteNombre: cliente.nombre,
|
clienteNombre: cliente.nombre,
|
||||||
clienteTelefono: cliente.telefono ?? null,
|
clienteTelefono: cliente.telefono ?? null,
|
||||||
clienteEmail: cliente.email ?? null,
|
clienteEmail: cliente.email ?? null,
|
||||||
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
|
esquema: 'FRASA_2026',
|
||||||
|
tipoPropuesta: 'FRASA_2026',
|
||||||
acabados,
|
acabados,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
descuento: propuestaActiva.descuento,
|
descuento: 0,
|
||||||
precioLista,
|
precioLista,
|
||||||
precioFinal: propuestaActiva.precioFinal,
|
precioFinal: precioLista,
|
||||||
enganche: propuestaActiva.enganche,
|
saldoFirma: resultado.saldoFirma,
|
||||||
numMensualidades: propuestaActiva.numMensualidades ?? 0,
|
engancheDiferible: resultado.enganche,
|
||||||
mensualidad: propuestaActiva.mensualidad,
|
escrituracion: resultado.escrituracion,
|
||||||
pagoFinal: propuestaActiva.pagoFinal,
|
escrituracionDiferida,
|
||||||
|
engancheAdicional: resultado.engancheAdicional,
|
||||||
|
enganche: resultado.enganche,
|
||||||
|
numMensualidades,
|
||||||
|
mensualidad: resultado.mensualidad,
|
||||||
|
pagoFinal: resultado.pagoUnicoFirma,
|
||||||
|
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
|
||||||
createdAt: new Date(timestamp).toISOString(),
|
createdAt: new Date(timestamp).toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,19 +325,19 @@ export default function CotizadorPage() {
|
|||||||
* devuelve null: se avisa y no se guarda nada.
|
* devuelve null: se avisa y no se guarda nada.
|
||||||
*/
|
*/
|
||||||
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
||||||
if (!unidad || !propuestaActiva) return null
|
if (!unidad || !resultado) return null
|
||||||
try {
|
try {
|
||||||
const guardada = await guardarCotizacion({
|
const guardada = await guardarCotizacion({
|
||||||
unidadCodigo: unidad.codigo,
|
unidadCodigo: unidad.codigo,
|
||||||
clienteNombre: cliente.nombre,
|
clienteNombre: cliente.nombre,
|
||||||
...(cliente.email ? { clienteEmail: cliente.email } : {}),
|
...(cliente.email ? { clienteEmail: cliente.email } : {}),
|
||||||
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
|
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
|
||||||
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
|
|
||||||
acabados,
|
acabados,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
descuento: propuestaActiva.descuento,
|
numMensualidades,
|
||||||
...(numMeses !== null ? { numMensualidades: numMeses } : {}),
|
escrituracionDiferida,
|
||||||
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
|
engancheAdicional: resultado.engancheAdicional,
|
||||||
|
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
|
||||||
...(oportunidadId !== null ? { oportunidadId } : {}),
|
...(oportunidadId !== null ? { oportunidadId } : {}),
|
||||||
})
|
})
|
||||||
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
||||||
@@ -324,7 +364,7 @@ export default function CotizadorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generarPdfConCliente = async (cliente: Cliente) => {
|
const generarPdfConCliente = async (cliente: Cliente) => {
|
||||||
if (!unidad) return
|
if (!unidad || !resultado) return
|
||||||
setGuardando(true)
|
setGuardando(true)
|
||||||
try {
|
try {
|
||||||
const guardada = await guardarEnApi(cliente)
|
const guardada = await guardarEnApi(cliente)
|
||||||
@@ -345,8 +385,10 @@ export default function CotizadorPage() {
|
|||||||
acabados,
|
acabados,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
precioLista,
|
precioLista,
|
||||||
// El PDF imprime SOLO la propuesta elegida por el vendedor
|
// El PDF imprime SOLO el esquema único FRASA 2026
|
||||||
propuestas: propuestaActiva ? [propuestaActiva] : [],
|
esquema: resultado,
|
||||||
|
// + la opción de financiamiento elegida (Crédito Bancario por default)
|
||||||
|
opcion: opcionesCredito.find((o) => o.tipo === opcionElegida),
|
||||||
cliente,
|
cliente,
|
||||||
desarrollo: params,
|
desarrollo: params,
|
||||||
folio,
|
folio,
|
||||||
@@ -412,11 +454,11 @@ export default function CotizadorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCompartir = async () => {
|
const handleCompartir = async () => {
|
||||||
if (!unidad || !propuestaActiva) return
|
if (!unidad || !resultado) return
|
||||||
const texto = textoCompartir({
|
const texto = textoCompartir({
|
||||||
unidadCodigo: unidad.codigo,
|
unidadCodigo: unidad.codigo,
|
||||||
desarrolloNombre: params.nombre,
|
desarrolloNombre: params.nombre,
|
||||||
propuesta: propuestaActiva,
|
esquema: resultado,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
vigencia: calcularVigencia(),
|
vigencia: calcularVigencia(),
|
||||||
...(ultimaGuardada
|
...(ultimaGuardada
|
||||||
@@ -448,32 +490,66 @@ export default function CotizadorPage() {
|
|||||||
setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`)
|
setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const tipo = TIPO_DE_API[c.tipoPropuesta]
|
const nueva = esEsquemaNuevo(c)
|
||||||
const paramsGuardados = desarrolloEfectivo(reglas, c.desarrollo)
|
|
||||||
const idx = paramsGuardados.propuestas.filter(propuestaVigente).findIndex((p) => p.tipo === tipo)
|
|
||||||
if (idx < 0) {
|
|
||||||
setAviso(
|
|
||||||
`La cotización ${c.folio ?? ''} usa la propuesta ${c.tipoPropuesta}, que ya no está disponible para ${c.desarrollo}. Elige otra propuesta.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
|
|
||||||
restaurar({
|
restaurar({
|
||||||
desarrollo: c.desarrollo,
|
desarrollo: c.desarrollo,
|
||||||
unidad: u,
|
unidad: u,
|
||||||
acabados: c.acabados,
|
acabados: c.acabados,
|
||||||
apartado: c.apartado,
|
apartado: c.apartado,
|
||||||
descuento: c.descuento,
|
// Las cotizaciones del esquema anterior retoman con los defaults del
|
||||||
propuestaIndex: idx >= 0 ? idx : 0,
|
// nuevo esquema (14 mensualidades, escrituración a la firma)
|
||||||
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null,
|
...(nueva
|
||||||
|
? {
|
||||||
|
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined,
|
||||||
|
escrituracionDiferida: c.escrituracionDiferida,
|
||||||
|
engancheAdicional: c.engancheAdicional ?? 0,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
setValue('nombre', c.clienteNombre, { shouldValidate: true })
|
setValue('nombre', c.clienteNombre, { shouldValidate: true })
|
||||||
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
|
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
|
||||||
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
|
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
|
||||||
setUltimaGuardada(c)
|
setUltimaGuardada(c)
|
||||||
|
// Restaura la opción de crédito elegida y sus ajustes (guardados como JSON)
|
||||||
|
if (typeof c.opcionesCredito === 'string' && c.opcionesCredito.length > 0) {
|
||||||
|
try {
|
||||||
|
const guardado = JSON.parse(c.opcionesCredito) as {
|
||||||
|
elegida?: TipoCredito
|
||||||
|
ajustes?: Record<TipoCredito, AjustesCredito>
|
||||||
|
}
|
||||||
|
if (guardado.elegida && TIPOS_ORDEN.includes(guardado.elegida)) {
|
||||||
|
setOpcionElegida(guardado.elegida)
|
||||||
|
}
|
||||||
|
if (guardado.ajustes && typeof guardado.ajustes === 'object') {
|
||||||
|
setAjustesCredito((prev) => {
|
||||||
|
const siguiente = { ...prev }
|
||||||
|
for (const tipo of TIPOS_ORDEN) {
|
||||||
|
const a = guardado.ajustes?.[tipo]
|
||||||
|
// Solo se restauran ajustes con la forma esperada; si no, el default
|
||||||
|
if (a && typeof a.tasaAnual === 'number' && typeof a.plazoAnos === 'number') {
|
||||||
|
siguiente[tipo] = {
|
||||||
|
tasaAnual: a.tasaAnual,
|
||||||
|
plazoAnos: a.plazoAnos,
|
||||||
|
...(tipo === 'SEMI_CONTADO' && typeof a.pctContado === 'number'
|
||||||
|
? { pctContado: a.pctContado }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return siguiente
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON corrupto: se conservan los ajustes actuales (defaults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const avisoEsquema = nueva
|
||||||
|
? ''
|
||||||
|
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.`
|
||||||
setAviso(
|
setAviso(
|
||||||
esDisponible(u)
|
esDisponible(u)
|
||||||
? `Cotización ${c.folio ?? ''} retomada.`
|
? `Cotización ${c.folio ?? ''} retomada.${avisoEsquema}`
|
||||||
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`,
|
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.${avisoEsquema}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,67 +695,6 @@ export default function CotizadorPage() {
|
|||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="enganche" className={etiquetaCampo}>
|
|
||||||
Enganche (%) — opcional
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="enganche"
|
|
||||||
type="number"
|
|
||||||
min={2.5}
|
|
||||||
max={ENGANCHE_MAXIMO_PCT}
|
|
||||||
step={0.5}
|
|
||||||
placeholder="Según propuesta"
|
|
||||||
value={enganchePct ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const crudo = e.target.value
|
|
||||||
if (crudo === '') {
|
|
||||||
setEnganchePct(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const v = Number(crudo)
|
|
||||||
setEnganchePct(Number.isFinite(v) ? v : null)
|
|
||||||
}}
|
|
||||||
className={errorEnganche ? campoInputError : campoInput}
|
|
||||||
/>
|
|
||||||
{errorEnganche ? (
|
|
||||||
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
|
|
||||||
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
|
|
||||||
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="mensualidades" className={etiquetaCampo}>
|
|
||||||
Mensualidades
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="mensualidades"
|
|
||||||
value={numMensualidades ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value
|
|
||||||
setNumMensualidades(v === '' ? null : Number(v))
|
|
||||||
}}
|
|
||||||
className={campoInput}
|
|
||||||
>
|
|
||||||
<option value="">Calcular automáticamente</option>
|
|
||||||
{[6, 12, 14, 18, 24].map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
{n} mensualidades
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{numMeses !== null && (
|
|
||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Se usarán {numMeses} mensualidades para el cálculo
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BuscadorCliente
|
<BuscadorCliente
|
||||||
onSeleccion={(lead) => {
|
onSeleccion={(lead) => {
|
||||||
setValue('nombre', lead.nombre, { shouldValidate: true })
|
setValue('nombre', lead.nombre, { shouldValidate: true })
|
||||||
@@ -763,33 +778,43 @@ export default function CotizadorPage() {
|
|||||||
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Panel de propuestas */}
|
{/* Panel del esquema único FRASA 2026 */}
|
||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
{!unidad ? (
|
{!unidad || !resultado ? (
|
||||||
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]">
|
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]">
|
||||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
Selecciona una unidad para ver las propuestas comerciales.
|
Selecciona una unidad para ver el esquema de cotización.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<>
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<EsquemaPanel
|
||||||
{propuestas.map((p, i) => (
|
esquema={resultado}
|
||||||
<ProposalCard
|
fechaEntrega={unidad?.fechaEntrega ?? null}
|
||||||
key={p.tipo}
|
maxMensualidades={unidad ? mensualidadesParaUnidad(unidad) : MENSUALIDADES_MAX}
|
||||||
numero={i + 1}
|
engancheBase={resultado.enganche}
|
||||||
titulo={p.titulo}
|
engancheTotal={resultado.enganche + engancheAdicional}
|
||||||
propuesta={p}
|
onEngancheTotal={(v) =>
|
||||||
/>
|
setEngancheAdicional(
|
||||||
))}
|
Math.min(Math.max(0, v - resultado.enganche), engancheAdicionalMaximo),
|
||||||
</div>
|
)
|
||||||
<ComparadorPropuestas
|
}
|
||||||
precioLista={precioLista}
|
engancheMax={resultado.enganche + engancheAdicionalMaximo}
|
||||||
apartado={apartadoEfectivo}
|
numMensualidades={numMensualidades}
|
||||||
propuestas={propuestas}
|
onNumMensualidades={setNumMensualidades}
|
||||||
titulos={propuestas.map((p) => p.titulo)}
|
escrituracionDiferida={escrituracionDiferida}
|
||||||
|
onEscrituracionDiferida={setEscrituracionDiferida}
|
||||||
/>
|
/>
|
||||||
</div>
|
<OpcionesCredito
|
||||||
|
opciones={opcionesCredito}
|
||||||
|
ajustes={ajustesCredito}
|
||||||
|
onCambiarAjustes={(tipo, ajustes) =>
|
||||||
|
setAjustesCredito((prev) => ({ ...prev, [tipo]: ajustes }))
|
||||||
|
}
|
||||||
|
elegida={opcionElegida}
|
||||||
|
onElegir={setOpcionElegida}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -802,44 +827,24 @@ export default function CotizadorPage() {
|
|||||||
error={errorMias}
|
error={errorMias}
|
||||||
onRetomar={handleRetomar}
|
onRetomar={handleRetomar}
|
||||||
/>
|
/>
|
||||||
<ComparadorUnidades
|
|
||||||
unidades={unidades}
|
|
||||||
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
|
|
||||||
apartado={apartadoEfectivo}
|
|
||||||
acabados={acabados}
|
|
||||||
reglas={reglas}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */}
|
{/* Barra fija de cierre: resumen del esquema + acciones, siempre visibles */}
|
||||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
|
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
|
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
{propuestaActiva ? (
|
{resultado ? (
|
||||||
<>
|
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
|
||||||
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
|
<span className="truncate">
|
||||||
<span className="truncate">
|
Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '}
|
||||||
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
|
{formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '}
|
||||||
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
|
{formatoMoneda(resultado.mensualidad)}
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<select
|
</span>
|
||||||
aria-label="Cambiar propuesta elegida"
|
|
||||||
value={Math.min(propuestaIndex, propuestas.length - 1)}
|
|
||||||
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
|
||||||
>
|
|
||||||
{propuestas.map((p, i) => (
|
|
||||||
<option key={p.tipo} value={i}>
|
|
||||||
#{i + 1} {p.titulo}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
Selecciona una unidad para elegir propuesta, guardar y generar el PDF.
|
Selecciona una unidad para ver el esquema, guardar y generar el PDF.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{aviso && (
|
{aviso && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Unidad, TipoPropuesta } from '../types'
|
import type { Unidad } from '../types'
|
||||||
import { INVENTARIO_SEED } from '../data/unidades'
|
import { INVENTARIO_SEED } from '../data/unidades'
|
||||||
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
|
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
|
||||||
import { conAuth, manejar401 } from './auth'
|
import { conAuth, manejar401 } from './auth'
|
||||||
@@ -72,22 +72,12 @@ export async function getReglasCotizador(): Promise<ReglasDesarrollo> {
|
|||||||
|
|
||||||
// ---------- Cotizaciones guardadas (historial del vendedor) ----------
|
// ---------- Cotizaciones guardadas (historial del vendedor) ----------
|
||||||
|
|
||||||
/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */
|
/**
|
||||||
export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO'
|
* Tipo de propuesta tal como viene del API. Las cotizaciones nuevas usan el
|
||||||
|
* esquema único FRASA_2026; los valores legacy (BANCARIO, …) quedan en las
|
||||||
export const TIPO_A_API: Record<TipoPropuesta, TipoPropuestaApi> = {
|
* filas viejas para no romper reportes ni el historial.
|
||||||
CREDITO_BANCARIO: 'BANCARIO',
|
*/
|
||||||
CREDITO_INFONAVIT_COFINAVIT: 'INFONAVIT',
|
export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' | 'FRASA_2026'
|
||||||
CONTADO: 'CONTADO',
|
|
||||||
SEMI_CONTADO: 'SEMI_CONTADO',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TIPO_DE_API: Record<TipoPropuestaApi, TipoPropuesta> = {
|
|
||||||
BANCARIO: 'CREDITO_BANCARIO',
|
|
||||||
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
|
|
||||||
CONTADO: 'CONTADO',
|
|
||||||
SEMI_CONTADO: 'SEMI_CONTADO',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CotizacionGuardada {
|
export interface CotizacionGuardada {
|
||||||
id: string
|
id: string
|
||||||
@@ -97,16 +87,30 @@ export interface CotizacionGuardada {
|
|||||||
clienteNombre: string
|
clienteNombre: string
|
||||||
clienteTelefono: string | null
|
clienteTelefono: string | null
|
||||||
clienteEmail: string | null
|
clienteEmail: string | null
|
||||||
|
/** Esquema único FRASA 2026 (las viejas traen su tipoPropuesta legacy) */
|
||||||
|
esquema?: string
|
||||||
tipoPropuesta: TipoPropuestaApi
|
tipoPropuesta: TipoPropuestaApi
|
||||||
acabados: boolean
|
acabados: boolean
|
||||||
apartado: number
|
apartado: number
|
||||||
descuento: number
|
descuento: number
|
||||||
precioLista: number
|
precioLista: number
|
||||||
precioFinal: number
|
precioFinal: number
|
||||||
|
/** Lo que se paga a la firma por el enganche de firma (esquema nuevo) */
|
||||||
|
saldoFirma?: number | null
|
||||||
|
/** Enganche del 7.5%, neto del excedente del apartado (esquema nuevo) */
|
||||||
|
engancheDiferible?: number | null
|
||||||
|
/** Gastos de escrituración 5.5% (esquema nuevo) */
|
||||||
|
escrituracion?: number | null
|
||||||
|
/** true = escrituración diferida con el enganche (esquema nuevo) */
|
||||||
|
escrituracionDiferida?: boolean
|
||||||
|
/** Enganche adicional (anticipo extra; esquema nuevo, 0 por default) */
|
||||||
|
engancheAdicional?: number | null
|
||||||
enganche: number
|
enganche: number
|
||||||
numMensualidades: number
|
numMensualidades: number
|
||||||
mensualidad: number | null
|
mensualidad: number | null
|
||||||
pagoFinal: number
|
pagoFinal: number
|
||||||
|
/** JSON {elegida, ajustes} de las opciones de crédito (filas nuevas) */
|
||||||
|
opcionesCredito?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,14 +119,19 @@ export interface GuardarCotizacionPayload {
|
|||||||
clienteNombre: string
|
clienteNombre: string
|
||||||
clienteEmail?: string
|
clienteEmail?: string
|
||||||
clienteTelefono?: string
|
clienteTelefono?: string
|
||||||
tipoPropuesta: TipoPropuestaApi
|
|
||||||
acabados: boolean
|
acabados: boolean
|
||||||
|
/** Apartado: cantidad libre (sin él, el API usa su default) */
|
||||||
apartado?: number
|
apartado?: number
|
||||||
descuento?: number
|
/** Mensualidades del diferimiento (1..36; sin ellas, el API usa 14) */
|
||||||
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
|
|
||||||
numMensualidades?: number
|
numMensualidades?: number
|
||||||
|
/** true = la escrituración (5.5%) se difiere con el enganche */
|
||||||
|
escrituracionDiferida?: boolean
|
||||||
|
/** Enganche adicional (anticipo extra >= 0); el total no puede pasar del 90% del precio */
|
||||||
|
engancheAdicional?: number
|
||||||
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
|
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
|
||||||
oportunidadId?: string
|
oportunidadId?: string
|
||||||
|
/** JSON serializado {elegida, ajustes} de las 4 opciones de crédito; el API lo guarda crudo */
|
||||||
|
opcionesCredito?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +185,15 @@ export async function getMisCotizaciones(): Promise<CotizacionGuardada[]> {
|
|||||||
return (await resp.json()) as CotizacionGuardada[]
|
return (await resp.json()) as CotizacionGuardada[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /cotizaciones/:id — una cotización del vendedor (retomar desde URL) */
|
||||||
|
export async function getCotizacion(id: string): Promise<CotizacionGuardada | null> {
|
||||||
|
const resp = await fetch(`${API_URL}/cotizaciones/${encodeURIComponent(id)}`, conAuth())
|
||||||
|
manejar401(resp)
|
||||||
|
if (resp.status === 404) return null
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
return (await resp.json()) as CotizacionGuardada
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Búsqueda de clientes para el prellenado del cotizador ----------
|
// ---------- Búsqueda de clientes para el prellenado del cotizador ----------
|
||||||
|
|
||||||
export interface LeadResumen {
|
export interface LeadResumen {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
actualizarUnidad,
|
||||||
crearUnidades,
|
crearUnidades,
|
||||||
eliminarUnidad,
|
eliminarUnidad,
|
||||||
estatusDe,
|
estatusDe,
|
||||||
@@ -147,6 +148,76 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
|
|||||||
|
|
||||||
const modelos = desarrollo.modelos ?? []
|
const modelos = desarrollo.modelos ?? []
|
||||||
|
|
||||||
|
// Edición de unidad existente (precios, m², ubicación, modelo y estatus)
|
||||||
|
const [editando, setEditando] = useState<UnidadDesarrollo | null>(null)
|
||||||
|
const [formEdicion, setFormEdicion] = useState({
|
||||||
|
precioLista: '',
|
||||||
|
precioAcabados: '',
|
||||||
|
supConstruccion: '',
|
||||||
|
supExcedente: '',
|
||||||
|
supEstacionamiento: '',
|
||||||
|
manzana: '',
|
||||||
|
lote: '',
|
||||||
|
nivel: '',
|
||||||
|
modeloId: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const abrirEdicion = (unidad: UnidadDesarrollo) => {
|
||||||
|
setEditando(unidad)
|
||||||
|
setFormEdicion({
|
||||||
|
precioLista: String(unidad.precioSinAcabados ?? unidad.precioLista ?? ''),
|
||||||
|
precioAcabados: String(unidad.precioConAcabados ?? ''),
|
||||||
|
supConstruccion: String(unidad.supConstruccion ?? ''),
|
||||||
|
supExcedente: String(unidad.supExcedente ?? ''),
|
||||||
|
supEstacionamiento: String(unidad.supEstacionamiento ?? ''),
|
||||||
|
manzana: (unidad.extras?.['manzana'] as string | undefined) ?? '',
|
||||||
|
lote: (unidad.extras?.['lote'] as string | undefined) ?? '',
|
||||||
|
nivel: unidad.nivel ?? '',
|
||||||
|
modeloId: modelos.find((m) => m.nombre === unidad.modelo)?.id ?? '',
|
||||||
|
status: estatusDe(unidad) ?? '',
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
setExito('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cambiarEdicion = (campo: keyof typeof formEdicion) => (valor: string) =>
|
||||||
|
setFormEdicion((prev) => ({ ...prev, [campo]: valor }))
|
||||||
|
|
||||||
|
const guardarEdicion = async () => {
|
||||||
|
if (!editando) return
|
||||||
|
setError('')
|
||||||
|
setExito('')
|
||||||
|
const lista = aNumero(formEdicion.precioLista)
|
||||||
|
if (lista === null) {
|
||||||
|
setError('El precio de lista es obligatorio.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEnviando(true)
|
||||||
|
try {
|
||||||
|
await actualizarUnidad(desarrollo.id, editando.codigo, {
|
||||||
|
precioLista: lista,
|
||||||
|
...(aNumero(formEdicion.precioAcabados) !== null ? { precioAcabados: aNumero(formEdicion.precioAcabados)! } : {}),
|
||||||
|
...(aNumero(formEdicion.supConstruccion) !== null ? { supConstruccion: aNumero(formEdicion.supConstruccion)! } : {}),
|
||||||
|
...(aNumero(formEdicion.supExcedente) !== null ? { supExcedente: aNumero(formEdicion.supExcedente)! } : {}),
|
||||||
|
...(aNumero(formEdicion.supEstacionamiento) !== null ? { supEstacionamiento: aNumero(formEdicion.supEstacionamiento)! } : {}),
|
||||||
|
manzana: formEdicion.manzana.trim(),
|
||||||
|
lote: formEdicion.lote.trim(),
|
||||||
|
nivel: formEdicion.nivel.trim(),
|
||||||
|
...(formEdicion.modeloId !== '' ? { modeloId: formEdicion.modeloId } : {}),
|
||||||
|
...(formEdicion.status !== '' ? { status: formEdicion.status } : {}),
|
||||||
|
})
|
||||||
|
setExito(`Unidad ${editando.codigo} actualizada.`)
|
||||||
|
setEditando(null)
|
||||||
|
await cargarUnidades()
|
||||||
|
await onRefrescar()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error && err.message ? err.message : 'No se pudo actualizar la unidad.')
|
||||||
|
} finally {
|
||||||
|
setEnviando(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const guardarGenerador = async () => {
|
const guardarGenerador = async () => {
|
||||||
setError('')
|
setError('')
|
||||||
setExito('')
|
setExito('')
|
||||||
@@ -711,6 +782,14 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<BadgeEstatus estatus={estatusDe(unidad)} />
|
<BadgeEstatus estatus={estatusDe(unidad)} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => abrirEdicion(unidad)}
|
||||||
|
aria-label={`Editar unidad ${unidad.codigo}`}
|
||||||
|
className={BOTON_SECUNDARIO}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void borrarUnidad(unidad)}
|
onClick={() => void borrarUnidad(unidad)}
|
||||||
@@ -756,6 +835,113 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Modal de edición de unidad */}
|
||||||
|
{editando && (
|
||||||
|
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
|
||||||
|
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<h3 className="text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Editar unidad <span className="font-mono">{editando.codigo}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio de lista *</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
value={formEdicion.precioLista}
|
||||||
|
onChange={(e) => cambiarEdicion('precioLista')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio con acabados</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
value={formEdicion.precioAcabados}
|
||||||
|
onChange={(e) => cambiarEdicion('precioAcabados')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. construcción (m²)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={formEdicion.supConstruccion}
|
||||||
|
onChange={(e) => cambiarEdicion('supConstruccion')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. excedente (m²)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={formEdicion.supExcedente}
|
||||||
|
onChange={(e) => cambiarEdicion('supExcedente')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. estacionamiento (m²)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={formEdicion.supEstacionamiento}
|
||||||
|
onChange={(e) => cambiarEdicion('supEstacionamiento')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Modelo</span>
|
||||||
|
<select value={formEdicion.modeloId} onChange={(e) => cambiarEdicion('modeloId')(e.target.value)} className={INPUT}>
|
||||||
|
<option value="">Sin modelo</option>
|
||||||
|
{modelos.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.nombre}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Manzana</span>
|
||||||
|
<input value={formEdicion.manzana} onChange={(e) => cambiarEdicion('manzana')(e.target.value)} className={INPUT} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Lote</span>
|
||||||
|
<input value={formEdicion.lote} onChange={(e) => cambiarEdicion('lote')(e.target.value)} className={INPUT} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Nivel</span>
|
||||||
|
<input value={formEdicion.nivel} onChange={(e) => cambiarEdicion('nivel')(e.target.value)} className={INPUT} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Estatus</span>
|
||||||
|
<select value={formEdicion.status} onChange={(e) => cambiarEdicion('status')(e.target.value)} className={INPUT}>
|
||||||
|
<option value="">Sin cambio</option>
|
||||||
|
{ESTATUS_UNIDAD.map((estatus) => (
|
||||||
|
<option key={estatus} value={estatus}>{estatus}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setEditando(null)} disabled={enviando} className={BOTON_SECUNDARIO}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void guardarEdicion()} disabled={enviando} className={BOTON_PRIMARIO}>
|
||||||
|
{enviando ? 'Guardando…' : 'Guardar cambios'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,13 +64,16 @@ export const ETIQUETA_FUENTE_N = {
|
|||||||
porUnidad: 'Capturado por unidad',
|
porUnidad: 'Capturado por unidad',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Estatus posibles del inventario (los mismos que maneja el cotizador) */
|
/**
|
||||||
|
* Estatus posibles del inventario (los mismos que maneja el cotizador).
|
||||||
|
* Un par canónico APARTADO/VENDIDO: las variantes femeninas APARTADA/VENDIDA
|
||||||
|
* quedaron normalizadas en la base de datos; CLASE_ESTATUS las conserva para
|
||||||
|
* pintar bien cualquier fila histórica que aún las traiga.
|
||||||
|
*/
|
||||||
export const ESTATUS_UNIDAD = [
|
export const ESTATUS_UNIDAD = [
|
||||||
'DISPONIBLE',
|
'DISPONIBLE',
|
||||||
'APARTADO',
|
'APARTADO',
|
||||||
'APARTADA',
|
|
||||||
'VENDIDO',
|
'VENDIDO',
|
||||||
'VENDIDA',
|
|
||||||
'ESCRITURADO',
|
'ESCRITURADO',
|
||||||
'BLOQUEADO',
|
'BLOQUEADO',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ export const MODULOS: Modulo[] = [
|
|||||||
claves: ['chat'],
|
claves: ['chat'],
|
||||||
url: '/ai/',
|
url: '/ai/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'whatsapp',
|
||||||
|
nombre: 'WhatsApp',
|
||||||
|
descripcion: 'Bandeja compartida de WhatsApp: conversaciones asignadas y round-robin',
|
||||||
|
claves: ['whatsapp'],
|
||||||
|
url: '/wa-crm/',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tableros',
|
id: 'tableros',
|
||||||
nombre: 'Tableros',
|
nombre: 'Tableros',
|
||||||
|
|||||||
@@ -1,197 +1,18 @@
|
|||||||
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
/**
|
||||||
import {
|
* Módulo de Soporte: acceso directo al formulario oficial de solicitudes
|
||||||
DEPARTAMENTOS_SOPORTE,
|
* internas (Monday WorkForms). Ya no se registra nada en este sistema: el
|
||||||
ESTADOS_SOPORTE,
|
* botón abre el formulario oficial en una pestaña nueva.
|
||||||
MAX_ADJUNTOS_SOPORTE,
|
*/
|
||||||
PRIORIDADES_SOPORTE,
|
|
||||||
cambiarEstadoSolicitud,
|
const URL_FORMULARIO_SOPORTE = 'https://wkf.ms/3T5NyTY'
|
||||||
crearSolicitudSoporte,
|
|
||||||
descargarArchivoSolicitud,
|
|
||||||
listarSolicitudesSoporte,
|
|
||||||
nombreArchivoSoporte,
|
|
||||||
validarAdjuntoSoporte,
|
|
||||||
type EstadoSoporte,
|
|
||||||
type SolicitudSoporte,
|
|
||||||
} from '../services/soporte'
|
|
||||||
import { getSesion } from '../services/auth'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onVolver: () => void
|
onVolver: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClase =
|
|
||||||
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
|
|
||||||
|
|
||||||
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
|
||||||
|
|
||||||
const ETIQUETA_ESTADO: Record<string, string> = {
|
|
||||||
ABIERTA: 'Abierta',
|
|
||||||
EN_PROCESO: 'En proceso',
|
|
||||||
CERRADA: 'Cerrada',
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPCIONES_ESTADO = [
|
|
||||||
{ valor: '', etiqueta: 'Todos los estatus' },
|
|
||||||
...ESTADOS_SOPORTE.map((e) => ({ valor: e, etiqueta: ETIQUETA_ESTADO[e] ?? e })),
|
|
||||||
]
|
|
||||||
|
|
||||||
const OPCIONES_DEPARTAMENTO = [
|
|
||||||
{ valor: '', etiqueta: 'Todos los departamentos' },
|
|
||||||
...DEPARTAMENTOS_SOPORTE.map((d) => ({ valor: d, etiqueta: d })),
|
|
||||||
]
|
|
||||||
|
|
||||||
const CLASE_ESTADO: Record<string, string> = {
|
|
||||||
ABIERTA: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
|
|
||||||
EN_PROCESO: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
|
|
||||||
CERRADA: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fecha de hoy en formato YYYY-MM-DD (hora local) */
|
|
||||||
function hoyISO(): string {
|
|
||||||
const hoy = new Date()
|
|
||||||
const mes = String(hoy.getMonth() + 1).padStart(2, '0')
|
|
||||||
const dia = String(hoy.getDate()).padStart(2, '0')
|
|
||||||
return `${hoy.getFullYear()}-${mes}-${dia}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SoportePage({ onVolver }: Props) {
|
export function SoportePage({ onVolver }: Props) {
|
||||||
const sesion = getSesion()
|
|
||||||
const puedeGestionar = sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager'
|
|
||||||
const [titulo, setTitulo] = useState('')
|
|
||||||
const [descripcion, setDescripcion] = useState('')
|
|
||||||
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
|
|
||||||
const [prioridad, setPrioridad] = useState<string>('Media')
|
|
||||||
const [responsable, setResponsable] = useState('')
|
|
||||||
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
|
|
||||||
const [fechaLimite, setFechaLimite] = useState('')
|
|
||||||
const [notas, setNotas] = useState('')
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [archivos, setArchivos] = useState<File[]>([])
|
|
||||||
const [errorArchivos, setErrorArchivos] = useState('')
|
|
||||||
const [enviando, setEnviando] = useState(false)
|
|
||||||
const [aviso, setAviso] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
|
|
||||||
const [cargando, setCargando] = useState(true)
|
|
||||||
const [filtroEstado, setFiltroEstado] = useState('')
|
|
||||||
const [filtroDepartamento, setFiltroDepartamento] = useState('')
|
|
||||||
const [ocupado, setOcupado] = useState('')
|
|
||||||
|
|
||||||
const cargar = useCallback(async () => {
|
|
||||||
setCargando(true)
|
|
||||||
try {
|
|
||||||
setSolicitudes(
|
|
||||||
await listarSolicitudesSoporte({
|
|
||||||
...(filtroEstado ? { estado: filtroEstado } : {}),
|
|
||||||
...(filtroDepartamento ? { departamento: filtroDepartamento } : {}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// La lista es secundaria: el form sigue usable si el listado falla
|
|
||||||
} finally {
|
|
||||||
setCargando(false)
|
|
||||||
}
|
|
||||||
}, [filtroEstado, filtroDepartamento])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void cargar()
|
|
||||||
}, [cargar])
|
|
||||||
|
|
||||||
const agregarArchivos = (lista: FileList | null) => {
|
|
||||||
if (lista === null) return
|
|
||||||
const nuevos: File[] = []
|
|
||||||
const rechazados: string[] = []
|
|
||||||
for (const archivo of Array.from(lista)) {
|
|
||||||
const problema = validarAdjuntoSoporte(archivo)
|
|
||||||
if (problema) rechazados.push(problema)
|
|
||||||
else nuevos.push(archivo)
|
|
||||||
}
|
|
||||||
const combinados = [...archivos, ...nuevos]
|
|
||||||
if (combinados.length > MAX_ADJUNTOS_SOPORTE) {
|
|
||||||
rechazados.push(`Máximo ${MAX_ADJUNTOS_SOPORTE} archivos por solicitud.`)
|
|
||||||
}
|
|
||||||
setArchivos(combinados.slice(0, MAX_ADJUNTOS_SOPORTE))
|
|
||||||
setErrorArchivos(rechazados.join(' '))
|
|
||||||
}
|
|
||||||
|
|
||||||
const enviar = async (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setAviso('')
|
|
||||||
setError('')
|
|
||||||
if (!titulo.trim() || !descripcion.trim()) {
|
|
||||||
setError('El título y la descripción son obligatorios.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnviando(true)
|
|
||||||
try {
|
|
||||||
const creada = await crearSolicitudSoporte({
|
|
||||||
titulo: titulo.trim(),
|
|
||||||
descripcion: descripcion.trim(),
|
|
||||||
departamento: departamento as (typeof DEPARTAMENTOS_SOPORTE)[number],
|
|
||||||
prioridad: prioridad as (typeof PRIORIDADES_SOPORTE)[number],
|
|
||||||
...(responsable.trim() ? { responsable: responsable.trim() } : {}),
|
|
||||||
fechaSolicitud,
|
|
||||||
...(fechaLimite ? { fechaLimite } : {}),
|
|
||||||
...(notas.trim() ? { notas: notas.trim() } : {}),
|
|
||||||
...(sesion?.usuario.nombre ? { solicitante: sesion.usuario.nombre } : {}),
|
|
||||||
...(email.trim() ? { email: email.trim() } : {}),
|
|
||||||
...(archivos.length > 0 ? { archivos } : {}),
|
|
||||||
})
|
|
||||||
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
|
|
||||||
setTitulo('')
|
|
||||||
setDescripcion('')
|
|
||||||
setResponsable('')
|
|
||||||
setNotas('')
|
|
||||||
setArchivos([])
|
|
||||||
setErrorArchivos('')
|
|
||||||
setFechaLimite('')
|
|
||||||
await cargar()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo registrar la solicitud.')
|
|
||||||
} finally {
|
|
||||||
setEnviando(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const descargar = async (solicitud: SolicitudSoporte, indice: number) => {
|
|
||||||
const ruta = solicitud.archivos[indice] ?? ''
|
|
||||||
setError('')
|
|
||||||
setOcupado(`${solicitud.id}:${indice}`)
|
|
||||||
try {
|
|
||||||
const blob = await descargarArchivoSolicitud(solicitud.id, indice)
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const enlace = document.createElement('a')
|
|
||||||
enlace.href = url
|
|
||||||
enlace.download = nombreArchivoSoporte(ruta)
|
|
||||||
document.body.appendChild(enlace)
|
|
||||||
enlace.click()
|
|
||||||
enlace.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo descargar el archivo.')
|
|
||||||
} finally {
|
|
||||||
setOcupado('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cambiarEstado = async (solicitud: SolicitudSoporte, estado: string) => {
|
|
||||||
setAviso('')
|
|
||||||
setError('')
|
|
||||||
setOcupado(solicitud.id)
|
|
||||||
try {
|
|
||||||
const actualizada = await cambiarEstadoSolicitud(solicitud.id, estado as EstadoSoporte)
|
|
||||||
setSolicitudes((prev) => prev.map((s) => (s.id === actualizada.id ? actualizada : s)))
|
|
||||||
setAviso(`"${actualizada.titulo}" ahora está ${ETIQUETA_ESTADO[actualizada.estado] ?? actualizada.estado}.`)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo cambiar el estado.')
|
|
||||||
} finally {
|
|
||||||
setOcupado('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
|
<div className="mx-auto max-w-2xl px-4 py-10">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -204,281 +25,31 @@ export function SoportePage({ onVolver }: Props) {
|
|||||||
Soporte
|
Soporte
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Registra una solicitud para los departamentos internos y dale seguimiento desde esta misma
|
|
||||||
pantalla.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Formulario */}
|
<div className="mt-8 rounded-2xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
<form onSubmit={(e) => void enviar(e)} noValidate className="mt-8 max-w-3xl">
|
<p className="text-4xl" aria-hidden="true">
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
🛠️
|
||||||
<label htmlFor="soporte-titulo" className="block sm:col-span-2">
|
</p>
|
||||||
<span className={labelClase}>Título<span className="text-red-700"> *</span></span>
|
<h2 className="mt-4 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
<input
|
Solicitudes internas
|
||||||
id="soporte-titulo"
|
|
||||||
type="text"
|
|
||||||
value={titulo}
|
|
||||||
onChange={(e) => setTitulo(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-descripcion" className="block sm:col-span-2">
|
|
||||||
<span className={labelClase}>Descripción<span className="text-red-700"> *</span></span>
|
|
||||||
<textarea
|
|
||||||
id="soporte-descripcion"
|
|
||||||
rows={4}
|
|
||||||
value={descripcion}
|
|
||||||
onChange={(e) => setDescripcion(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-departamento" className="block">
|
|
||||||
<span className={labelClase}>Departamento</span>
|
|
||||||
<select
|
|
||||||
id="soporte-departamento"
|
|
||||||
value={departamento}
|
|
||||||
onChange={(e) => setDepartamento(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
>
|
|
||||||
{DEPARTAMENTOS_SOPORTE.map((d) => (
|
|
||||||
<option key={d} value={d}>{d}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-prioridad" className="block">
|
|
||||||
<span className={labelClase}>Prioridad</span>
|
|
||||||
<select
|
|
||||||
id="soporte-prioridad"
|
|
||||||
value={prioridad}
|
|
||||||
onChange={(e) => setPrioridad(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
>
|
|
||||||
{PRIORIDADES_SOPORTE.map((p) => (
|
|
||||||
<option key={p} value={p}>{p}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-responsable" className="block">
|
|
||||||
<span className={labelClase}>Responsable</span>
|
|
||||||
<input
|
|
||||||
id="soporte-responsable"
|
|
||||||
type="text"
|
|
||||||
value={responsable}
|
|
||||||
onChange={(e) => setResponsable(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-fecha-solicitud" className="block">
|
|
||||||
<span className={labelClase}>Fecha de solicitud</span>
|
|
||||||
<input
|
|
||||||
id="soporte-fecha-solicitud"
|
|
||||||
type="date"
|
|
||||||
value={fechaSolicitud}
|
|
||||||
onChange={(e) => setFechaSolicitud(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-email" className="block">
|
|
||||||
<span className={labelClase}>Correo del solicitante</span>
|
|
||||||
<input
|
|
||||||
id="soporte-email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="nombre@frasa.mx"
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-fecha-limite" className="block">
|
|
||||||
<span className={labelClase}>Fecha límite</span>
|
|
||||||
<input
|
|
||||||
id="soporte-fecha-limite"
|
|
||||||
type="date"
|
|
||||||
value={fechaLimite}
|
|
||||||
onChange={(e) => setFechaLimite(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-archivos" className="block">
|
|
||||||
<span className={labelClase}>Archivos (PDF, JPG o PNG · máx 10 MB)</span>
|
|
||||||
<input
|
|
||||||
id="soporte-archivos"
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="application/pdf,image/jpeg,image/png"
|
|
||||||
onChange={(e) => agregarArchivos(e.target.files)}
|
|
||||||
className={`${inputClase} file:mr-3 file:rounded-md file:border-0 file:bg-[#F1F5F9] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-[#374151]`}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-notas" className="block sm:col-span-2">
|
|
||||||
<span className={labelClase}>Notas</span>
|
|
||||||
<textarea
|
|
||||||
id="soporte-notas"
|
|
||||||
rows={2}
|
|
||||||
value={notas}
|
|
||||||
onChange={(e) => setNotas(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{archivos.length > 0 && (
|
|
||||||
<ul className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{archivos.map((archivo, i) => (
|
|
||||||
<li
|
|
||||||
key={`${archivo.name}-${i}`}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-1.5 text-xs text-[#374151] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
|
|
||||||
>
|
|
||||||
{archivo.name}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Quitar ${archivo.name}`}
|
|
||||||
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
|
|
||||||
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{errorArchivos && (
|
|
||||||
<p role="alert" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
|
|
||||||
{errorArchivos}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{aviso && (
|
|
||||||
<p role="status" className="mt-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-xs font-medium text-green-700">
|
|
||||||
{aviso}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={enviando}
|
|
||||||
className="mt-6 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{enviando ? 'Registrando…' : 'Enviar solicitud'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Listado de solicitudes */}
|
|
||||||
<section aria-label={puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'} className="mt-12 max-w-3xl">
|
|
||||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
|
||||||
{puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
|
||||||
{/* Filtros del listado */}
|
Las solicitudes de soporte y los reportes a otros departamentos ahora se registran en el
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
formulario oficial. Al abrirlo podrás describir tu solicitud, adjuntar archivos y elegir
|
||||||
<label htmlFor="soporte-filtro-estado" className="block">
|
el departamento responsable.
|
||||||
<span className={labelClase}>Filtrar por estatus</span>
|
</p>
|
||||||
<select
|
<a
|
||||||
id="soporte-filtro-estado"
|
href={URL_FORMULARIO_SOPORTE}
|
||||||
value={filtroEstado}
|
target="_blank"
|
||||||
onChange={(e) => setFiltroEstado(e.target.value)}
|
rel="noopener noreferrer"
|
||||||
className={inputClase}
|
className="mt-6 inline-block rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||||
>
|
>
|
||||||
{OPCIONES_ESTADO.map((o) => (
|
Abrir formulario de soporte ↗
|
||||||
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
|
</a>
|
||||||
))}
|
<p className="mt-4 text-xs text-[#94A3B8]">
|
||||||
</select>
|
Se abre en una pestaña nueva: {URL_FORMULARIO_SOPORTE}
|
||||||
</label>
|
</p>
|
||||||
<label htmlFor="soporte-filtro-departamento" className="block">
|
</div>
|
||||||
<span className={labelClase}>Filtrar por departamento</span>
|
</div>
|
||||||
<select
|
|
||||||
id="soporte-filtro-departamento"
|
|
||||||
value={filtroDepartamento}
|
|
||||||
onChange={(e) => setFiltroDepartamento(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
>
|
|
||||||
{OPCIONES_DEPARTAMENTO.map((o) => (
|
|
||||||
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cargando ? (
|
|
||||||
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
|
|
||||||
) : solicitudes.length === 0 ? (
|
|
||||||
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
{filtroEstado || filtroDepartamento
|
|
||||||
? 'Ninguna solicitud coincide con los filtros.'
|
|
||||||
: 'Aún no hay solicitudes registradas.'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-4 space-y-3">
|
|
||||||
{solicitudes.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.id}
|
|
||||||
className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{s.titulo}</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
|
|
||||||
>
|
|
||||||
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
|
|
||||||
</span>
|
|
||||||
{puedeGestionar && (
|
|
||||||
<select
|
|
||||||
aria-label={`Cambiar estatus de ${s.titulo}`}
|
|
||||||
value={s.estado}
|
|
||||||
disabled={ocupado === s.id}
|
|
||||||
onChange={(e) => void cambiarEstado(s, e.target.value)}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
|
|
||||||
>
|
|
||||||
{ESTADOS_SOPORTE.map((e) => (
|
|
||||||
<option key={e} value={e}>{ETIQUETA_ESTADO[e] ?? e}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'}
|
|
||||||
{s.solicitanteEmail ? ` (${s.solicitanteEmail})` : ''} ·{' '}
|
|
||||||
{new Date(s.fechaSolicitud).toLocaleDateString('es-MX')}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-[#374151] dark:text-[#CBD5E1]">{s.descripcion}</p>
|
|
||||||
{s.archivos.length > 0 && (
|
|
||||||
<ul className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{s.archivos.map((ruta, i) => (
|
|
||||||
<li key={`${ruta}-${i}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void descargar(s, i)}
|
|
||||||
disabled={ocupado === `${s.id}:${i}`}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-[#F9FAFB] px-2.5 py-1 text-xs text-[#374151] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
|
|
||||||
>
|
|
||||||
⬇ {nombreArchivoSoporte(ruta)}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
|
|||||||
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
|
{ 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: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
||||||
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
{ 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' },
|
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
||||||
],
|
],
|
||||||
VENTAS: [
|
VENTAS: [
|
||||||
@@ -55,13 +64,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
|
|||||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||||
],
|
],
|
||||||
ESCRITURACION: [
|
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: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||||
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export async function getAuditoria(filtros?: {
|
|||||||
}): Promise<AuditoriaItem[]> {
|
}): Promise<AuditoriaItem[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (filtros?.entidad) params.set('entidad', filtros.entidad)
|
if (filtros?.entidad) params.set('entidad', filtros.entidad)
|
||||||
if (filtros?.usuario) params.set('usuario', filtros.usuario)
|
if (filtros?.usuario) params.set('usuarioId', filtros.usuario)
|
||||||
if (filtros?.desde) params.set('desde', filtros.desde)
|
if (filtros?.desde) params.set('desde', filtros.desde)
|
||||||
if (filtros?.hasta) params.set('hasta', filtros.hasta)
|
if (filtros?.hasta) params.set('hasta', filtros.hasta)
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
|
|||||||
@@ -382,3 +382,29 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cambios editables de una unidad (PATCH /desarrollos/:id/unidades/:codigo) */
|
||||||
|
export interface CambiosUnidad {
|
||||||
|
manzana?: string
|
||||||
|
lote?: string
|
||||||
|
nivel?: string
|
||||||
|
modeloId?: string
|
||||||
|
precioLista?: number
|
||||||
|
precioAcabados?: number
|
||||||
|
supConstruccion?: number
|
||||||
|
supExcedente?: number
|
||||||
|
supEstacionamiento?: number
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m², modelo y estatus */
|
||||||
|
export async function actualizarUnidad(
|
||||||
|
desarrolloId: string,
|
||||||
|
codigo: string,
|
||||||
|
cambios: CambiosUnidad,
|
||||||
|
): Promise<UnidadDesarrollo> {
|
||||||
|
return pedir<UnidadDesarrollo>(
|
||||||
|
`${rutaDesarrollo(desarrolloId)}/unidades/${encodeURIComponent(codigo)}`,
|
||||||
|
initJson('PATCH', cambios),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react'
|
import { X, User, Calendar, CheckSquare, FileText, Users, Search, ClipboardList } from 'lucide-react'
|
||||||
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
|
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia, AfluenciaPerfil } from '../lib/mi-dia'
|
||||||
import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia'
|
import { formatoFechaCorta, formatoFechaHora, urlWhatsApp } from '../lib/mi-dia'
|
||||||
import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
|
import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
|
||||||
|
import { fetchAfluencia } from '../services/mi-dia'
|
||||||
import { getSesion } from '../services/auth'
|
import { getSesion } from '../services/auth'
|
||||||
|
|
||||||
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
|
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
|
||||||
@@ -21,7 +22,33 @@ function esOportunidad(item: LeadItem): item is OportunidadMiDia {
|
|||||||
return 'modeloInteres' in item
|
return 'modeloInteres' in item
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros'
|
type TabId = 'info' | 'cita' | 'afluencia' | 'tareas' | 'notas' | 'miembros'
|
||||||
|
|
||||||
|
/** Etiquetas legibles de los campos del perfil de afluencia (bitácora) */
|
||||||
|
const ETIQUETAS_AFLUENCIA: Record<string, string> = {
|
||||||
|
nombre: 'Nombre',
|
||||||
|
telefono: 'Teléfono',
|
||||||
|
email: 'Email',
|
||||||
|
origen: 'Origen',
|
||||||
|
desarrollo: 'Desarrollo',
|
||||||
|
modelo: 'Modelo',
|
||||||
|
medioContacto: 'Medio de contacto',
|
||||||
|
numDependientes: 'No. de dependientes',
|
||||||
|
edad: 'Edad',
|
||||||
|
estadoCivil: 'Estado civil',
|
||||||
|
tipoEmpleo: 'Tipo de empleo',
|
||||||
|
ocupacion: 'Ocupación',
|
||||||
|
rangoSalarial: 'Rango salarial',
|
||||||
|
credito: 'Crédito',
|
||||||
|
tipoEconomia: 'Tipo de economía',
|
||||||
|
califica: 'Califica',
|
||||||
|
loQuierePara: 'Lo quiere para',
|
||||||
|
notas: 'Notas',
|
||||||
|
unidadCodigo: 'Unidad',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claves internas que no se muestran como campo del perfil */
|
||||||
|
const CLAVES_AFLUENCIA_OCULTAS = ['fechaCita', 'oportunidadId']
|
||||||
|
|
||||||
const CLASE_ACCION_RAPIDA =
|
const CLASE_ACCION_RAPIDA =
|
||||||
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
|
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
|
||||||
@@ -29,6 +56,7 @@ const CLASE_ACCION_RAPIDA =
|
|||||||
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
||||||
{ id: 'info', titulo: 'Información', icono: User },
|
{ id: 'info', titulo: 'Información', icono: User },
|
||||||
{ id: 'cita', titulo: 'Cita', icono: Calendar },
|
{ id: 'cita', titulo: 'Cita', icono: Calendar },
|
||||||
|
{ id: 'afluencia', titulo: 'Afluencia', icono: ClipboardList },
|
||||||
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
|
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
|
||||||
{ id: 'notas', titulo: 'Notas', icono: FileText },
|
{ id: 'notas', titulo: 'Notas', icono: FileText },
|
||||||
{ id: 'miembros', titulo: 'Miembros', icono: Users },
|
{ id: 'miembros', titulo: 'Miembros', icono: Users },
|
||||||
@@ -63,6 +91,20 @@ export default function DetalleLeadModal({
|
|||||||
// --- Cita ---
|
// --- Cita ---
|
||||||
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
|
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
|
||||||
const [guardandoCita, setGuardandoCita] = useState(false)
|
const [guardandoCita, setGuardandoCita] = useState(false)
|
||||||
|
// --- Afluencia (pestaña de la bitácora) ---
|
||||||
|
const [afluencia, setAfluencia] = useState<AfluenciaPerfil | null>(null)
|
||||||
|
const [cargandoAfluencia, setCargandoAfluencia] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== 'afluencia') return
|
||||||
|
let vivo = true
|
||||||
|
setCargandoAfluencia(true)
|
||||||
|
fetchAfluencia(item.id)
|
||||||
|
.then((a) => { if (vivo) setAfluencia(a) })
|
||||||
|
.catch(() => { if (vivo) setAfluencia(null) })
|
||||||
|
.finally(() => { if (vivo) setCargandoAfluencia(false) })
|
||||||
|
return () => { vivo = false }
|
||||||
|
}, [tab, item.id])
|
||||||
|
|
||||||
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
|
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
|
||||||
async function guardarCita(e: FormEvent) {
|
async function guardarCita(e: FormEvent) {
|
||||||
@@ -81,8 +123,11 @@ export default function DetalleLeadModal({
|
|||||||
const cargarNotas = useCallback(async () => {
|
const cargarNotas = useCallback(async () => {
|
||||||
setCargandoNotas(true)
|
setCargandoNotas(true)
|
||||||
try {
|
try {
|
||||||
const todas = await listarRecordatorios()
|
|
||||||
const clave = calcularClienteClave(item.nombre, (item as any).telefono)
|
const clave = calcularClienteClave(item.nombre, (item as any).telefono)
|
||||||
|
// Notas de la bitácora: se piden por lead/cliente al API, que devuelve
|
||||||
|
// las notas de TODOS los usuarios (el seguimiento del cliente es
|
||||||
|
// compartido y cualquier usuario debe verlo completo).
|
||||||
|
const todas = await listarRecordatorios({ leadId: item.id, clienteClave: clave })
|
||||||
setNotas(todas.filter((r) => r.leadId === item.id || (r.clienteClave && r.clienteClave === clave)))
|
setNotas(todas.filter((r) => r.leadId === item.id || (r.clienteClave && r.clienteClave === clave)))
|
||||||
} catch { setNotaError('Error al cargar notas.') }
|
} catch { setNotaError('Error al cargar notas.') }
|
||||||
finally { setCargandoNotas(false) }
|
finally { setCargandoNotas(false) }
|
||||||
@@ -164,6 +209,9 @@ export default function DetalleLeadModal({
|
|||||||
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
|
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
|
||||||
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
|
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
|
||||||
<InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
|
<InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
|
||||||
|
{'creadoEn' in item && item.creadoEn ? (
|
||||||
|
<InfoCampo etiqueta="Fecha de alta" valor={formatoFechaHora(item.creadoEn).slice(0, 10)} />
|
||||||
|
) : null}
|
||||||
{esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
|
{esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
|
||||||
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />}
|
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />}
|
||||||
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />}
|
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />}
|
||||||
@@ -228,6 +276,35 @@ export default function DetalleLeadModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'afluencia' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Afluencia</h3>
|
||||||
|
{afluencia?.afluenciaCompleta && (
|
||||||
|
<span className="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
Afluencia completa
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cargandoAfluencia ? (
|
||||||
|
<p className="text-sm text-[#94A3B8]">Cargando afluencia…</p>
|
||||||
|
) : !afluencia || Object.keys(afluencia.perfil).length === 0 ? (
|
||||||
|
<p className="text-sm text-[#94A3B8]">
|
||||||
|
Aún no se ha capturado la afluencia de este cliente. Se llena desde el botón
|
||||||
|
"Afluencia →" de Mis oportunidades.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{Object.entries(afluencia.perfil)
|
||||||
|
.filter(([clave]) => !CLAVES_AFLUENCIA_OCULTAS.includes(clave))
|
||||||
|
.map(([clave, valor]) => (
|
||||||
|
<InfoCampo key={clave} etiqueta={ETIQUETAS_AFLUENCIA[clave] ?? clave} valor={valor} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'tareas' && (
|
{tab === 'tareas' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>
|
||||||
@@ -248,7 +325,7 @@ export default function DetalleLeadModal({
|
|||||||
onChange={() => void completarTarea(n.id)}
|
onChange={() => void completarTarea(n.id)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span>
|
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span>
|
||||||
<span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</span>
|
<span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaHora(n.createdAt)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -308,7 +385,7 @@ export default function DetalleLeadModal({
|
|||||||
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-4 py-3">
|
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-4 py-3">
|
||||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
|
||||||
{n.descripcion && <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>}
|
{n.descripcion && <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>}
|
||||||
<p className="mt-1.5 text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</p>
|
<p className="mt-1.5 text-[10px] text-[#94A3B8]">{formatoFechaHora(n.createdAt)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -141,14 +141,15 @@ export function aplicarFiltros(
|
|||||||
if (!valor.includes(busqueda)) return false
|
if (!valor.includes(busqueda)) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desarrollo
|
// Desarrollo (comparación normalizada: el catálogo y los datos pueden
|
||||||
|
// variar en mayúsculas/espacios — era la causa principal de filtros rotos)
|
||||||
if (filtros.desarrollo !== '' && accessor.desarrollo) {
|
if (filtros.desarrollo !== '' && accessor.desarrollo) {
|
||||||
if (accessor.desarrollo(item) !== filtros.desarrollo) return false
|
if (accessor.desarrollo(item).trim().toUpperCase() !== filtros.desarrollo.trim().toUpperCase()) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origen
|
// Origen (misma normalización que desarrollo)
|
||||||
if (filtros.origen !== '' && accessor.origen) {
|
if (filtros.origen !== '' && accessor.origen) {
|
||||||
if (accessor.origen(item) !== filtros.origen) return false
|
if (accessor.origen(item).trim().toUpperCase() !== filtros.origen.trim().toUpperCase()) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estado (comparación case-insensitive: los labels pueden variar en mayúsculas)
|
// Estado (comparación case-insensitive: los labels pueden variar en mayúsculas)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface KanbanViewProps<T extends { id: string; nombre: string; estado?: strin
|
|||||||
obtenerColumna: (item: T) => string
|
obtenerColumna: (item: T) => string
|
||||||
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
|
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
|
||||||
renderCard?: (item: T) => React.ReactNode
|
renderCard?: (item: T) => React.ReactNode
|
||||||
|
/** Click en la tarjeta: abre la bitácora/detalle del registro */
|
||||||
|
onVerItem?: (item: T) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
|
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
|
||||||
@@ -21,6 +23,7 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
|
|||||||
obtenerColumna,
|
obtenerColumna,
|
||||||
onCambiarEstado,
|
onCambiarEstado,
|
||||||
renderCard,
|
renderCard,
|
||||||
|
onVerItem,
|
||||||
}: KanbanViewProps<T>) {
|
}: KanbanViewProps<T>) {
|
||||||
const [arrastrando, setArrastrando] = useState<string | null>(null)
|
const [arrastrando, setArrastrando] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -70,7 +73,11 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => void onDragStart(e, item.id)}
|
onDragStart={(e) => void onDragStart(e, item.id)}
|
||||||
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''}`}
|
onClick={onVerItem ? () => onVerItem(item) : undefined}
|
||||||
|
role={onVerItem ? 'button' : undefined}
|
||||||
|
tabIndex={onVerItem ? 0 : undefined}
|
||||||
|
onKeyDown={onVerItem ? (e) => { if (e.key === 'Enter') onVerItem(item) } : undefined}
|
||||||
|
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''} ${onVerItem ? 'cursor-pointer hover:border-accent' : ''}`}
|
||||||
>
|
>
|
||||||
{renderCard ? renderCard(item) : (
|
{renderCard ? renderCard(item) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const apartadoFake: ApartadoMiDia = {
|
|||||||
unidadCodigo: 'D-101',
|
unidadCodigo: 'D-101',
|
||||||
estado: 'En proceso',
|
estado: 'En proceso',
|
||||||
fechaLimite: '2026-09-15',
|
fechaLimite: '2026-09-15',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,12 @@ describe('urlCotizadorParaOportunidad', () => {
|
|||||||
etapa: 'Afluencia',
|
etapa: 'Afluencia',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modeloInteres: 'SOHO',
|
modeloInteres: 'SOHO',
|
||||||
|
afluenciaCompleta: true,
|
||||||
vendedor: 'Martin Zavala',
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: 'U-9',
|
unidadCodigo: 'U-9',
|
||||||
|
origen: 'FACEBOOK',
|
||||||
updatedAt: '2026-09-04T10:00:00Z',
|
updatedAt: '2026-09-04T10:00:00Z',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +122,12 @@ describe('urlAfluenciaParaOportunidad', () => {
|
|||||||
etapa: 'Agendo Cita',
|
etapa: 'Agendo Cita',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modeloInteres: 'SOHO',
|
modeloInteres: 'SOHO',
|
||||||
|
afluenciaCompleta: false,
|
||||||
vendedor: 'Martin Zavala',
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: '',
|
unidadCodigo: '',
|
||||||
|
origen: '',
|
||||||
updatedAt: '2026-09-04T10:00:00Z',
|
updatedAt: '2026-09-04T10:00:00Z',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface LeadMiDia {
|
|||||||
/** Vendedor asignado (nombre); '' si no tiene */
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
vendedor: string
|
vendedor: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
@@ -23,6 +25,8 @@ export interface ActividadMiDia {
|
|||||||
tipo: string
|
tipo: string
|
||||||
fecha: string // 'YYYY-MM-DD'
|
fecha: string // 'YYYY-MM-DD'
|
||||||
estado: string
|
estado: string
|
||||||
|
/** Captura (lead/oportunidad/apartado) ligada a la actividad; null si no aplica */
|
||||||
|
leadCapturaId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApartadoPorVencer {
|
export interface ApartadoPorVencer {
|
||||||
@@ -66,10 +70,16 @@ export interface OportunidadMiDia {
|
|||||||
/** Vendedor asignado (nombre); '' si no tiene */
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
vendedor: string
|
vendedor: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
|
/** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */
|
||||||
|
origen: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
||||||
fechaCita?: string
|
fechaCita?: string
|
||||||
|
/** ¿Afluencia con perfil completo? Sin esto NO puede avanzar a 'Aparto' */
|
||||||
|
afluenciaCompleta: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apartado asignado al vendedor (tab "Mis apartados") */
|
/** Apartado asignado al vendedor (tab "Mis apartados") */
|
||||||
@@ -85,9 +95,31 @@ export interface ApartadoMiDia {
|
|||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
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 {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
@@ -100,6 +132,8 @@ export interface ResumenMiDia {
|
|||||||
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
|
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
|
||||||
misApartados: ApartadoMiDia[]
|
misApartados: ApartadoMiDia[]
|
||||||
todosApartados: ApartadoMiDia[]
|
todosApartados: ApartadoMiDia[]
|
||||||
|
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
|
||||||
|
descartados: DescartadoMiDia[]
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: number
|
leadsNuevosSemana: number
|
||||||
actividadesAbiertas: number
|
actividadesAbiertas: number
|
||||||
@@ -125,7 +159,7 @@ export interface ResumenMiDia {
|
|||||||
export const VOCABULARIO = {
|
export const VOCABULARIO = {
|
||||||
// Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del
|
// Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del
|
||||||
// API + NUEVO/DESCARTADO)
|
// API + NUEVO/DESCARTADO)
|
||||||
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 del board; el API las valida tal cual)
|
// Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
|
||||||
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
||||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
||||||
@@ -271,6 +305,17 @@ export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
|
|||||||
return `/quote/?${params.toString()}`
|
return `/quote/?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perfil de afluencia capturado de un lead/oportunidad/apartado
|
||||||
|
* (GET /api/afluencias/:leadId). Solo valores de texto no vacíos.
|
||||||
|
*/
|
||||||
|
export interface AfluenciaPerfil {
|
||||||
|
leadId: string
|
||||||
|
nombre: string
|
||||||
|
perfil: Record<string, string>
|
||||||
|
afluenciaCompleta: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
|
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
|
||||||
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil
|
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil
|
||||||
@@ -301,6 +346,18 @@ export function formatoFechaCorta(fecha: string): string {
|
|||||||
return `${d}/${m}`
|
return `${d}/${m}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ISO → 'dd/mm/aaaa hh:mm' (hora local): bitácoras y notas con hora exacta */
|
||||||
|
export function formatoFechaHora(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0')
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${dd}/${m}/${y} ${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
/** Texto de urgencia de un apartado según los días que faltan */
|
/** Texto de urgencia de un apartado según los días que faltan */
|
||||||
export function textoDiasRestantes(dias: number): string {
|
export function textoDiasRestantes(dias: number): string {
|
||||||
if (dias <= 0) return 'Vence hoy'
|
if (dias <= 0) return 'Vence hoy'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user