feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API
- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor, column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes, webhook con handshake challenge, Prisma SQLite dev, 55 tests - cotizador: inventario desde /api/unidades con fallback a seed - alta-clientes: POST /api/clientes + upload multipart de documentos - cobranza: persistencia via API con respaldo offline en localStorage - .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
8
api-gateway/.dockerignore
Normal file
8
api-gateway/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
uploads
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
.env
|
||||
*.log
|
||||
.git
|
||||
28
api-gateway/Dockerfile
Normal file
28
api-gateway/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# ---- Build ----
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npx prisma generate && npm run build
|
||||
|
||||
# ---- Runner ----
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
# Aplica migraciones pendientes antes de arrancar
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
|
||||
66
api-gateway/README.md
Normal file
66
api-gateway/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# FRASA API Gateway
|
||||
|
||||
Backend Node.js que conecta las 3 apps web del ecosistema FRASA (cotizador :3001, alta-clientes :3002, cobranza :3003) con Monday.com y da persistencia real (Prisma + SQLite en dev, Postgres 16 en prod).
|
||||
|
||||
## Stack
|
||||
|
||||
Express 5 · TypeScript estricto · zod · Prisma (SQLite dev) · multer 2 · vitest
|
||||
|
||||
## Cómo correr en dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma migrate dev # crea/actualiza prisma/dev.db
|
||||
npm run dev # tsx watch en :3000
|
||||
```
|
||||
|
||||
Las credenciales de Monday y los board IDs se leen del `.env` raíz del ecosistema (`../.env`); el servicio también acepta un `.env` local (Prisma CLI lo requiere para `DATABASE_URL`). El token NUNCA se imprime en logs.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.service con fetch mockeado
|
||||
```
|
||||
|
||||
## Endpoints (prefijo /api)
|
||||
|
||||
| Método | Ruta | Descripción |
|
||||
|---|---|---|
|
||||
| GET | `/api/health` | `{status, monday: 'up'|'down'}` |
|
||||
| GET | `/api/unidades` | Inventario desde Monday (`?disponibles=true` filtra) |
|
||||
| GET | `/api/unidades/:codigo` | Una unidad (404 si no existe) |
|
||||
| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday |
|
||||
| GET | `/api/cotizaciones` · `/api/cotizaciones/:id` | Consulta |
|
||||
| POST | `/api/clientes` | Alta de cliente: persiste payload completo, crea Contacto y Apartado en Monday |
|
||||
| GET | `/api/clientes/:id` | Cliente con documentos |
|
||||
| POST | `/api/clientes/:id/documentos` | Multipart (campo `archivos`, máx 10, pdf/jpg/png, 10 MB) |
|
||||
| GET | `/api/clientes/:id/documentos` | Lista documentos |
|
||||
| POST | `/api/planes` | Crea plan de pago con calendario generado |
|
||||
| GET | `/api/planes` · `/api/planes/:id` | Con saldo/status calculados |
|
||||
| GET | `/api/planes/cobranza-del-dia` | Urgencias de cobranza (`?diasAnticipacion=7`) |
|
||||
| POST | `/api/planes/:id/abonos` | Aplica abono (estricto al centavo) |
|
||||
| POST | `/api/planes/:id/notas` | Agrega nota a la bitácora |
|
||||
| POST | `/api/planes/:id/cancelar` | Cancela el plan |
|
||||
| POST | `/api/webhooks/monday` | Handshake `challenge` + log de eventos |
|
||||
|
||||
## Notas de arquitectura
|
||||
|
||||
- **Monday service** (`src/services/monday.service.ts`): resuelve column IDs por TÍTULO en runtime (los slugs autogenerados nunca se hardcodean), paginación por cursor, retry con backoff ante 429/complejidad, `column_values` siempre via `JSON.stringify` en variables, cache de unidades 5 min con invalidación tras escrituras.
|
||||
- **Cálculos del cotizador** (`src/services/cotizacion.service.ts`): port exacto del Excel de producción (quirks incluidos: Infonavit usa precio de lista en mensualidades; enganche bancario sobre precio lista). NO corregir.
|
||||
- **Cobranza** (`src/services/cobranza.service.ts`): espejo de la lógica del frontend; los controllers mapean Prisma → dominio, ejecutan las funciones puras y persisten.
|
||||
- **Dinero como `Float`** por ahora — TODO prod: `Decimal` en Postgres.
|
||||
- **Webhooks de Monday no están firmados** → el payload se trata como no confiable; antes de actuar, re-consultar vía API (por ahora solo se loggea).
|
||||
|
||||
## TODO producción (pendientes deliberados)
|
||||
|
||||
- Auth (JWT ya hay vars en .env), helmet, rate-limit — es red interna por ahora.
|
||||
- Postgres 16 (`provider = "postgresql"`, `Decimal`), Redis para cache de unidades.
|
||||
- CORS restringido a los orígenes de las apps.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t frasa-api-gateway .
|
||||
docker run -p 3000:3000 --env-file ../.env frasa-api-gateway
|
||||
# El arranque corre `prisma migrate deploy` antes de levantar el server
|
||||
```
|
||||
3237
api-gateway/package-lock.json
generated
Normal file
3237
api-gateway/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
api-gateway/package.json
Normal file
35
api-gateway/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frasa-api-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "API Gateway del ecosistema FRASA: conecta las apps web con Monday.com y da persistencia",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.14.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"prisma": "^6.14.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
121
api-gateway/prisma/migrations/20260821224530_init/migration.sql
Normal file
121
api-gateway/prisma/migrations/20260821224530_init/migration.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Cotizacion" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"unidadCodigo" TEXT NOT NULL,
|
||||
"clienteNombre" TEXT NOT NULL,
|
||||
"clienteEmail" TEXT,
|
||||
"clienteTelefono" TEXT,
|
||||
"tipoPropuesta" TEXT NOT NULL,
|
||||
"acabados" BOOLEAN NOT NULL,
|
||||
"apartado" REAL NOT NULL,
|
||||
"descuento" REAL NOT NULL,
|
||||
"precioLista" REAL NOT NULL,
|
||||
"precioFinal" REAL NOT NULL,
|
||||
"enganche" REAL NOT NULL,
|
||||
"numMensualidades" INTEGER NOT NULL,
|
||||
"mensualidad" REAL,
|
||||
"pagoFinal" REAL NOT NULL,
|
||||
"mondayItemId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Cliente" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"nombreCompleto" TEXT NOT NULL,
|
||||
"curp" TEXT NOT NULL,
|
||||
"rfc" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"celular" TEXT,
|
||||
"proyecto" TEXT NOT NULL,
|
||||
"unidadCodigo" TEXT,
|
||||
"payloadJson" TEXT NOT NULL,
|
||||
"firmaPng" TEXT,
|
||||
"mondayContactoId" TEXT,
|
||||
"mondayApartadoId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Documento" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"clienteId" TEXT NOT NULL,
|
||||
"tipo" TEXT NOT NULL,
|
||||
"nombreArchivo" TEXT NOT NULL,
|
||||
"ruta" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"tamano" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Documento_clienteId_fkey" FOREIGN KEY ("clienteId") REFERENCES "Cliente" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlanPago" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"ventaRef" TEXT,
|
||||
"clienteNombre" TEXT NOT NULL,
|
||||
"clienteTelefono" TEXT,
|
||||
"unidadCodigo" TEXT NOT NULL,
|
||||
"proyecto" TEXT NOT NULL,
|
||||
"fechaInicio" DATETIME NOT NULL,
|
||||
"engancheTotal" REAL NOT NULL,
|
||||
"numPagos" INTEGER NOT NULL,
|
||||
"mensualidad" REAL NOT NULL,
|
||||
"tasaMoraMensual" REAL NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVO',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Pago" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"planId" TEXT NOT NULL,
|
||||
"numPago" INTEGER NOT NULL,
|
||||
"fechaVencimiento" DATETIME NOT NULL,
|
||||
"monto" REAL NOT NULL,
|
||||
"montoPagado" REAL NOT NULL DEFAULT 0,
|
||||
CONSTRAINT "Pago_planId_fkey" FOREIGN KEY ("planId") REFERENCES "PlanPago" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AplicacionPago" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"pagoId" TEXT NOT NULL,
|
||||
"fechaPago" DATETIME NOT NULL,
|
||||
"monto" REAL NOT NULL,
|
||||
"referencia" TEXT NOT NULL,
|
||||
CONSTRAINT "AplicacionPago_pagoId_fkey" FOREIGN KEY ("pagoId") REFERENCES "Pago" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NotaPlan" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"planId" TEXT NOT NULL,
|
||||
"fecha" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"texto" TEXT NOT NULL,
|
||||
CONSTRAINT "NotaPlan_planId_fkey" FOREIGN KEY ("planId") REFERENCES "PlanPago" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Cotizacion_unidadCodigo_idx" ON "Cotizacion"("unidadCodigo");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Cliente_curp_idx" ON "Cliente"("curp");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Documento_clienteId_idx" ON "Documento"("clienteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlanPago_unidadCodigo_idx" ON "PlanPago"("unidadCodigo");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Pago_planId_idx" ON "Pago"("planId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Pago_fechaVencimiento_idx" ON "Pago"("fechaVencimiento");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AplicacionPago_pagoId_idx" ON "AplicacionPago"("pagoId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotaPlan_planId_idx" ON "NotaPlan"("planId");
|
||||
3
api-gateway/prisma/migrations/migration_lock.toml
Normal file
3
api-gateway/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
123
api-gateway/prisma/schema.prisma
Normal file
123
api-gateway/prisma/schema.prisma
Normal file
@@ -0,0 +1,123 @@
|
||||
// FRASA API Gateway — persistencia local
|
||||
// Desarrollo: SQLite. Producción: Postgres 16 (cambiar provider y DATABASE_URL).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite" // prod: "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Cotizacion {
|
||||
id String @id @default(uuid())
|
||||
unidadCodigo String
|
||||
clienteNombre String
|
||||
clienteEmail String?
|
||||
clienteTelefono String?
|
||||
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO
|
||||
acabados Boolean
|
||||
apartado Float // TODO prod: Decimal en Postgres
|
||||
descuento Float // fracción (0.02 = 2%)
|
||||
precioLista Float // TODO prod: Decimal en Postgres
|
||||
precioFinal Float
|
||||
enganche Float
|
||||
numMensualidades Int
|
||||
mensualidad Float? // null => N/A (Contado)
|
||||
pagoFinal Float
|
||||
mondayItemId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([unidadCodigo])
|
||||
}
|
||||
|
||||
model Cliente {
|
||||
id String @id @default(uuid())
|
||||
nombreCompleto String
|
||||
curp String
|
||||
rfc String
|
||||
email String?
|
||||
celular String?
|
||||
proyecto String
|
||||
unidadCodigo String?
|
||||
// Payload completo del alta como JSON serializado (SQLite no tiene Json nativo;
|
||||
// en Postgres puede migrarse a la columna Json sin cambiar el contrato).
|
||||
payloadJson String
|
||||
firmaPng String? // base64 opcional
|
||||
mondayContactoId String?
|
||||
mondayApartadoId String?
|
||||
createdAt DateTime @default(now())
|
||||
documentos Documento[]
|
||||
|
||||
@@index([curp])
|
||||
}
|
||||
|
||||
model Documento {
|
||||
id String @id @default(uuid())
|
||||
clienteId String
|
||||
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
|
||||
tipo String // INE | CONSTANCIA_RFC | CURP | COMPROBANTE_DOMICILIO | ACTA_NACIMIENTO | OTRO
|
||||
nombreArchivo String
|
||||
ruta String
|
||||
mime String
|
||||
tamano Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([clienteId])
|
||||
}
|
||||
|
||||
model PlanPago {
|
||||
id String @id @default(uuid())
|
||||
ventaRef String?
|
||||
clienteNombre String
|
||||
clienteTelefono String?
|
||||
unidadCodigo String
|
||||
proyecto String
|
||||
fechaInicio DateTime // se normaliza a medianoche local
|
||||
engancheTotal Float // TODO prod: Decimal en Postgres
|
||||
numPagos Int
|
||||
mensualidad Float
|
||||
tasaMoraMensual Float @default(0)
|
||||
status String @default("ACTIVO") // ACTIVO | CANCELADO (LIQUIDADO se deriva)
|
||||
createdAt DateTime @default(now())
|
||||
notas NotaPlan[]
|
||||
pagos Pago[]
|
||||
|
||||
@@index([unidadCodigo])
|
||||
}
|
||||
|
||||
model Pago {
|
||||
id String @id @default(uuid())
|
||||
planId String
|
||||
plan PlanPago @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
numPago Int
|
||||
fechaVencimiento DateTime
|
||||
monto Float
|
||||
montoPagado Float @default(0)
|
||||
aplicaciones AplicacionPago[]
|
||||
|
||||
@@index([planId])
|
||||
@@index([fechaVencimiento])
|
||||
}
|
||||
|
||||
model AplicacionPago {
|
||||
id String @id @default(uuid())
|
||||
pagoId String
|
||||
pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade)
|
||||
fechaPago DateTime
|
||||
monto Float
|
||||
referencia String
|
||||
|
||||
@@index([pagoId])
|
||||
}
|
||||
|
||||
model NotaPlan {
|
||||
id String @id @default(uuid())
|
||||
planId String
|
||||
plan PlanPago @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
fecha DateTime @default(now())
|
||||
texto String
|
||||
|
||||
@@index([planId])
|
||||
}
|
||||
22
api-gateway/src/app.ts
Normal file
22
api-gateway/src/app.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { apiRouter } from './routes/index.js'
|
||||
import { errorHandler } from './middleware/errorHandler.js'
|
||||
import { requestLogger } from './middleware/requestLogger.js'
|
||||
|
||||
export function crearApp(): express.Express {
|
||||
const app = express()
|
||||
|
||||
app.use(cors()) // CORS abierto solo para dev (red interna)
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.use(requestLogger)
|
||||
|
||||
app.use('/api', apiRouter)
|
||||
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Ruta no encontrada' })
|
||||
})
|
||||
app.use(errorHandler)
|
||||
|
||||
return app
|
||||
}
|
||||
34
api-gateway/src/config/index.ts
Normal file
34
api-gateway/src/config/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import path from 'node:path'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
// Carga .env local si existe y luego el .env raíz del ecosistema (no pisa vars ya definidas)
|
||||
dotenv.config({ path: [path.resolve(process.cwd(), '.env'), path.resolve(process.cwd(), '..', '.env')] })
|
||||
|
||||
function env(name: string, fallback?: string): string {
|
||||
const v = process.env[name] ?? fallback
|
||||
if (v === undefined) throw new Error(`Falta la variable de entorno ${name}`)
|
||||
return v
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: Number(env('PORT', '3000')),
|
||||
nodeEnv: env('NODE_ENV', 'development'),
|
||||
databaseUrl: env('DATABASE_URL', 'file:./dev.db'),
|
||||
uploadsDir: path.resolve(process.cwd(), env('UPLOADS_DIR', './uploads')),
|
||||
monday: {
|
||||
apiKey: env('MONDAY_API_KEY'),
|
||||
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
|
||||
apiVersion: '2024-10',
|
||||
boards: {
|
||||
contactos: env('MONDAY_BOARD_CONTACTOS'),
|
||||
desarrollos: env('MONDAY_BOARD_DESARROLLOS'),
|
||||
unidades: env('MONDAY_BOARD_UNIDADES'),
|
||||
leads: env('MONDAY_BOARD_LEADS'),
|
||||
oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'),
|
||||
apartado: env('MONDAY_BOARD_APARTADO'),
|
||||
ventas: env('MONDAY_BOARD_VENTAS'),
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export type AppConfig = typeof config
|
||||
191
api-gateway/src/controllers/clientes.controller.ts
Normal file
191
api-gateway/src/controllers/clientes.controller.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import multer from 'multer'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { NotFoundError, BadRequestError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Alta de clientes ----------
|
||||
|
||||
const crearClienteSchema = z
|
||||
.object({
|
||||
datosGenerales: z
|
||||
.object({
|
||||
nombreCompleto: z.string().min(1),
|
||||
curp: z.string().min(1),
|
||||
rfc: z.string().min(1),
|
||||
email: z.string().optional(),
|
||||
celular: z.string().optional(),
|
||||
direccion: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
viviendaCredito: z
|
||||
.object({
|
||||
proyecto: z.string().min(1),
|
||||
ubicacion: z.string().min(1),
|
||||
valorVenta: z.union([z.string(), z.number()]).optional(),
|
||||
enganche: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(),
|
||||
})
|
||||
.passthrough() // el resto del payload pasa tal cual a payloadJson
|
||||
|
||||
const aNumero = (v: string | number | undefined): number => {
|
||||
if (v === undefined) return 0
|
||||
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const hoyMas = (dias: number): string => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + dias)
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dia = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
|
||||
export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
const body = crearClienteSchema.parse(req.body)
|
||||
const dg = body.datosGenerales
|
||||
const vc = body.viviendaCredito
|
||||
|
||||
const cliente = await prisma.cliente.create({
|
||||
data: {
|
||||
nombreCompleto: dg.nombreCompleto,
|
||||
curp: dg.curp,
|
||||
rfc: dg.rfc,
|
||||
email: dg.email ?? null,
|
||||
celular: dg.celular ?? null,
|
||||
proyecto: vc.proyecto,
|
||||
unidadCodigo: vc.ubicacion, // la "ubicación" del formato es el código de unidad (U-101, etc.)
|
||||
payloadJson: JSON.stringify(body),
|
||||
firmaPng: body.firma?.firmaDataUrl ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
|
||||
let mondayContactoId: string | null = null
|
||||
let mondayApartadoId: string | null = null
|
||||
try {
|
||||
mondayContactoId = await mondayService.crearContacto({
|
||||
nombre: dg.nombreCompleto,
|
||||
estadoLabel: 'CLIENTE',
|
||||
...(dg.celular !== undefined ? { telefono: dg.celular } : {}),
|
||||
...(dg.email !== undefined ? { email: dg.email } : {}),
|
||||
curp: dg.curp,
|
||||
rfc: dg.rfc,
|
||||
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
|
||||
})
|
||||
|
||||
const valorVenta = aNumero(vc.valorVenta)
|
||||
if (valorVenta > 0) {
|
||||
const enganche = aNumero(vc.enganche)
|
||||
mondayApartadoId = await mondayService.crearApartado({
|
||||
clienteNombre: dg.nombreCompleto,
|
||||
unidadCodigo: vc.ubicacion,
|
||||
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
||||
fechaApartado: hoyMas(0),
|
||||
fechaLimite: hoyMas(15),
|
||||
estadoLabel: 'VIGENTE',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.cliente.update({
|
||||
where: { id: cliente.id },
|
||||
data: { mondayContactoId, mondayApartadoId },
|
||||
})
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudo replicar el cliente en Monday:', err)
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
|
||||
}
|
||||
|
||||
/** GET /api/clientes/:id */
|
||||
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
|
||||
const cliente = await prisma.cliente.findUnique({
|
||||
where: { id: String(req.params['id']) },
|
||||
include: { documentos: true },
|
||||
})
|
||||
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||
res.json(cliente)
|
||||
}
|
||||
|
||||
// ---------- Documentos ----------
|
||||
|
||||
const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
||||
const EXT_POR_MIME: Record<string, string> = {
|
||||
'application/pdf': 'pdf',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const dir = path.join(config.uploadsDir, String(req.params['id']))
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
cb(null, dir)
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||
const ext = EXT_POR_MIME[file.mimetype] ?? 'bin'
|
||||
cb(null, `${tipo}_${randomUUID()}.${ext}`)
|
||||
},
|
||||
})
|
||||
|
||||
export const uploadDocumentos = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!MIME_PERMITIDOS.has(file.mimetype)) {
|
||||
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`))
|
||||
return
|
||||
}
|
||||
cb(null, true)
|
||||
},
|
||||
})
|
||||
|
||||
/** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */
|
||||
export async function subirDocumentos(req: Request, res: Response): Promise<void> {
|
||||
const clienteId = String(req.params['id'])
|
||||
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
|
||||
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||
|
||||
const archivos = (req.files ?? []) as Express.Multer.File[]
|
||||
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||
const documentos = await Promise.all(
|
||||
archivos.map((f) =>
|
||||
prisma.documento.create({
|
||||
data: {
|
||||
clienteId,
|
||||
tipo,
|
||||
nombreArchivo: f.originalname,
|
||||
ruta: path.relative(config.uploadsDir, f.path),
|
||||
mime: f.mimetype,
|
||||
tamano: f.size,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
res.status(201).json(documentos)
|
||||
}
|
||||
|
||||
/** GET /api/clientes/:id/documentos */
|
||||
export async function listarDocumentos(req: Request, res: Response): Promise<void> {
|
||||
const clienteId = String(req.params['id'])
|
||||
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
|
||||
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||
const documentos = await prisma.documento.findMany({
|
||||
where: { clienteId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json(documentos)
|
||||
}
|
||||
91
api-gateway/src/controllers/cotizaciones.controller.ts
Normal file
91
api-gateway/src/controllers/cotizaciones.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
const crearCotizacionSchema = z.object({
|
||||
unidadCodigo: z.string().min(1),
|
||||
acabados: z.boolean(),
|
||||
apartado: z.number().positive().optional(),
|
||||
clienteNombre: z.string().min(1),
|
||||
clienteEmail: z.string().email().optional(),
|
||||
clienteTelefono: z.string().min(7).optional(),
|
||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
||||
})
|
||||
|
||||
const hoyISO = (): string => {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dia = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
|
||||
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
|
||||
const body = crearCotizacionSchema.parse(req.body)
|
||||
|
||||
const unidad = await mondayService.getUnidad(body.unidadCodigo)
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
||||
|
||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
||||
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento)
|
||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
||||
|
||||
const cotizacion = await prisma.cotizacion.create({
|
||||
data: {
|
||||
unidadCodigo: unidad.codigo,
|
||||
clienteNombre: body.clienteNombre,
|
||||
clienteEmail: body.clienteEmail ?? null,
|
||||
clienteTelefono: body.clienteTelefono ?? null,
|
||||
tipoPropuesta: body.tipoPropuesta,
|
||||
acabados: body.acabados,
|
||||
apartado,
|
||||
descuento: propuesta.descuento,
|
||||
precioLista,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
enganche: propuesta.enganche,
|
||||
numMensualidades: propuesta.numMensualidades,
|
||||
mensualidad: propuesta.mensualidad,
|
||||
pagoFinal: propuesta.pagoFinal,
|
||||
},
|
||||
})
|
||||
|
||||
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
|
||||
let mondayItemId: string | null = null
|
||||
try {
|
||||
mondayItemId = await mondayService.crearOportunidad({
|
||||
clienteNombre: body.clienteNombre,
|
||||
unidadCodigo: unidad.codigo,
|
||||
tipoPropuestaLabel: body.tipoPropuesta,
|
||||
precioLista,
|
||||
descuentoPct: propuesta.descuento * 100,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
fechaCotizacion: hoyISO(),
|
||||
estadoLabel: 'COTIZADA',
|
||||
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
|
||||
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
||||
})
|
||||
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudo crear la oportunidad en Monday:', err)
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, mondayItemId, propuesta })
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones */
|
||||
export async function listarCotizaciones(_req: Request, res: Response): Promise<void> {
|
||||
const cotizaciones = await prisma.cotizacion.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
res.json(cotizaciones)
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones/:id */
|
||||
export async function obtenerCotizacion(req: Request, res: Response): Promise<void> {
|
||||
const cotizacion = await prisma.cotizacion.findUnique({ where: { id: String(req.params['id']) } })
|
||||
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
|
||||
res.json(cotizacion)
|
||||
}
|
||||
8
api-gateway/src/controllers/health.controller.ts
Normal file
8
api-gateway/src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
|
||||
/** GET /api/health — ping barato a Monday */
|
||||
export async function health(_req: Request, res: Response): Promise<void> {
|
||||
const mondayUp = await mondayService.ping()
|
||||
res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' })
|
||||
}
|
||||
236
api-gateway/src/controllers/planes.controller.ts
Normal file
236
api-gateway/src/controllers/planes.controller.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import {
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
cobranzaDelDia,
|
||||
construirNota,
|
||||
diasAtraso,
|
||||
generarCalendario,
|
||||
pagadoTotal,
|
||||
parseFechaLocal,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
totalMora,
|
||||
validarAbono,
|
||||
type PlanPago as PlanPagoDominio,
|
||||
} from '../services/cobranza.service.js'
|
||||
import { BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
|
||||
|
||||
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
|
||||
|
||||
const aFechaISO = (d: Date): string => {
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dia = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
type PlanCompleto = PlanPago & {
|
||||
pagos: (Pago & { aplicaciones: AplicacionPago[] })[]
|
||||
notas: NotaPlan[]
|
||||
}
|
||||
|
||||
function aDominio(plan: PlanCompleto): PlanPagoDominio {
|
||||
return {
|
||||
id: plan.id,
|
||||
ventaRef: plan.ventaRef,
|
||||
clienteNombre: plan.clienteNombre,
|
||||
clienteTelefono: plan.clienteTelefono,
|
||||
unidadCodigo: plan.unidadCodigo,
|
||||
proyecto: plan.proyecto,
|
||||
fechaInicio: aFechaISO(plan.fechaInicio),
|
||||
engancheTotal: plan.engancheTotal,
|
||||
numPagos: plan.numPagos,
|
||||
mensualidad: plan.mensualidad,
|
||||
tasaMoraMensual: plan.tasaMoraMensual,
|
||||
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
pagos: plan.pagos
|
||||
.slice()
|
||||
.sort((a, b) => a.numPago - b.numPago)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
numPago: p.numPago,
|
||||
fechaVencimiento: aFechaISO(p.fechaVencimiento),
|
||||
monto: p.monto,
|
||||
montoPagado: p.montoPagado,
|
||||
aplicaciones: p.aplicaciones.map((a) => ({
|
||||
id: a.id,
|
||||
fechaPago: a.fechaPago.toISOString(),
|
||||
monto: a.monto,
|
||||
referencia: a.referencia,
|
||||
})),
|
||||
})),
|
||||
notas: plan.notas.map((n) => ({ id: n.id, fecha: n.fecha.toISOString(), texto: n.texto })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Plan con los campos calculados que la app de cobranza muestra */
|
||||
function conCalculados(plan: PlanPagoDominio) {
|
||||
const hoy = new Date()
|
||||
return {
|
||||
...plan,
|
||||
status: statusPlan(plan),
|
||||
saldo: saldoPlan(plan),
|
||||
pagadoTotal: pagadoTotal(plan),
|
||||
avance: avancePlan(plan),
|
||||
alCorriente: alCorriente(plan, hoy),
|
||||
diasAtraso: diasAtraso(plan, hoy),
|
||||
totalMora: totalMora(plan, hoy),
|
||||
pagos: plan.pagos.map((p) => ({ ...p, status: statusPago(p, hoy) })),
|
||||
}
|
||||
}
|
||||
|
||||
const includeCompleto = { pagos: { include: { aplicaciones: true } }, notas: true } as const
|
||||
|
||||
async function cargarPlan(id: string): Promise<PlanCompleto> {
|
||||
const plan = await prisma.planPago.findUnique({ where: { id }, include: includeCompleto })
|
||||
if (!plan) throw new NotFoundError('Plan de pago no encontrado')
|
||||
return plan
|
||||
}
|
||||
|
||||
// ---------- Endpoints ----------
|
||||
|
||||
const crearPlanSchema = z.object({
|
||||
ventaRef: z.string().optional(),
|
||||
clienteNombre: z.string().min(1),
|
||||
clienteTelefono: z.string().optional(),
|
||||
unidadCodigo: z.string().min(1),
|
||||
proyecto: z.string().min(1),
|
||||
fechaInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'fechaInicio debe ser YYYY-MM-DD'),
|
||||
engancheTotal: z.number().positive(),
|
||||
numPagos: z.number().int().min(1),
|
||||
tasaMoraMensual: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
/** POST /api/planes — genera el calendario de pagos y lo persiste */
|
||||
export async function crearPlan(req: Request, res: Response): Promise<void> {
|
||||
const body = crearPlanSchema.parse(req.body)
|
||||
let calendario
|
||||
try {
|
||||
calendario = generarCalendario(body.fechaInicio, body.engancheTotal, body.numPagos)
|
||||
} catch (err) {
|
||||
throw new BadRequestError(err instanceof Error ? err.message : 'Parámetros inválidos')
|
||||
}
|
||||
|
||||
const plan = await prisma.planPago.create({
|
||||
data: {
|
||||
ventaRef: body.ventaRef ?? null,
|
||||
clienteNombre: body.clienteNombre,
|
||||
clienteTelefono: body.clienteTelefono ?? null,
|
||||
unidadCodigo: body.unidadCodigo,
|
||||
proyecto: body.proyecto,
|
||||
fechaInicio: parseFechaLocal(body.fechaInicio),
|
||||
engancheTotal: body.engancheTotal,
|
||||
numPagos: body.numPagos,
|
||||
mensualidad: calendario.mensualidad,
|
||||
tasaMoraMensual: body.tasaMoraMensual ?? 0,
|
||||
pagos: {
|
||||
create: calendario.pagos.map((p) => ({
|
||||
numPago: p.numPago,
|
||||
fechaVencimiento: parseFechaLocal(p.fechaVencimiento),
|
||||
monto: p.monto,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: includeCompleto,
|
||||
})
|
||||
res.status(201).json(conCalculados(aDominio(plan)))
|
||||
}
|
||||
|
||||
/** GET /api/planes — con saldo/status calculados */
|
||||
export async function listarPlanes(_req: Request, res: Response): Promise<void> {
|
||||
const planes = await prisma.planPago.findMany({
|
||||
include: includeCompleto,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json(planes.map((p) => conCalculados(aDominio(p))))
|
||||
}
|
||||
|
||||
/** GET /api/planes/cobranza-del-dia — lista plana de urgencias (antes de /:id) */
|
||||
export async function cobranzaHoy(req: Request, res: Response): Promise<void> {
|
||||
const dias = Number(req.query['diasAnticipacion'] ?? 7)
|
||||
const planes = await prisma.planPago.findMany({ include: includeCompleto })
|
||||
const items = cobranzaDelDia(
|
||||
planes.map(aDominio),
|
||||
new Date(),
|
||||
Number.isFinite(dias) ? dias : 7,
|
||||
)
|
||||
res.json(items)
|
||||
}
|
||||
|
||||
/** GET /api/planes/:id */
|
||||
export async function obtenerPlan(req: Request, res: Response): Promise<void> {
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
res.json(conCalculados(aDominio(plan)))
|
||||
}
|
||||
|
||||
const abonoSchema = z.object({
|
||||
pagoId: z.string().min(1),
|
||||
fechaPago: z.string().min(1),
|
||||
monto: z.number(),
|
||||
referencia: z.string().min(1),
|
||||
})
|
||||
|
||||
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
|
||||
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = abonoSchema.parse(req.body)
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
const dominio = aDominio(plan)
|
||||
|
||||
let resultado
|
||||
try {
|
||||
resultado = validarAbono(dominio, body)
|
||||
} catch (err) {
|
||||
throw new BadRequestError(err instanceof Error ? err.message : 'Abono inválido')
|
||||
}
|
||||
const { pago, aplicacion } = resultado
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.aplicacionPago.create({
|
||||
data: {
|
||||
pagoId: pago.id,
|
||||
fechaPago: new Date(body.fechaPago),
|
||||
monto: aplicacion.monto,
|
||||
referencia: aplicacion.referencia,
|
||||
},
|
||||
}),
|
||||
prisma.pago.update({
|
||||
where: { id: pago.id },
|
||||
data: { montoPagado: round2(pago.montoPagado + aplicacion.monto) },
|
||||
}),
|
||||
])
|
||||
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.status(201).json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
|
||||
const notaSchema = z.object({ texto: z.string() })
|
||||
|
||||
/** POST /api/planes/:id/notas */
|
||||
export async function agregarNota(req: Request, res: Response): Promise<void> {
|
||||
const { texto } = notaSchema.parse(req.body)
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
let nota
|
||||
try {
|
||||
nota = construirNota(texto)
|
||||
} catch (err) {
|
||||
throw new BadRequestError(err instanceof Error ? err.message : 'Nota inválida')
|
||||
}
|
||||
const creada = await prisma.notaPlan.create({
|
||||
data: { planId: plan.id, texto: nota.texto },
|
||||
})
|
||||
res.status(201).json(creada)
|
||||
}
|
||||
|
||||
/** POST /api/planes/:id/cancelar */
|
||||
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
await prisma.planPago.update({ where: { id: plan.id }, data: { status: 'CANCELADO' } })
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
17
api-gateway/src/controllers/unidades.controller.ts
Normal file
17
api-gateway/src/controllers/unidades.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE */
|
||||
export async function listarUnidades(req: Request, res: Response): Promise<void> {
|
||||
const unidades = await mondayService.getUnidades()
|
||||
const disponibles = req.query['disponibles'] === 'true'
|
||||
res.json(disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades)
|
||||
}
|
||||
|
||||
/** GET /api/unidades/:codigo — 404 si no existe */
|
||||
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
|
||||
const unidad = await mondayService.getUnidad(String(req.params['codigo']))
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
|
||||
res.json(unidad)
|
||||
}
|
||||
36
api-gateway/src/controllers/webhooks.controller.ts
Normal file
36
api-gateway/src/controllers/webhooks.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
|
||||
const eventoSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
boardId: z.union([z.string(), z.number()]).optional(),
|
||||
itemId: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/monday
|
||||
* Handshake: Monday envía { challenge } al registrar el webhook y exige que
|
||||
* se responda tal cual. Para eventos reales solo se loggea y se responde 200.
|
||||
*
|
||||
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Antes de
|
||||
* actuar sobre un evento hay que re-consultar el estado vía API (por ahora
|
||||
* solo se registra el evento).
|
||||
*/
|
||||
export function webhookMonday(req: Request, res: Response): void {
|
||||
const body = req.body as Record<string, unknown>
|
||||
|
||||
if (typeof body?.['challenge'] === 'string') {
|
||||
res.json({ challenge: body['challenge'] })
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = eventoSchema.safeParse(body)
|
||||
if (parsed.success) {
|
||||
const { type, boardId, itemId } = parsed.data
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[webhook monday] tipo=${type ?? 'desconocido'} board=${boardId ?? '?'} item=${itemId ?? '?'}`)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
9
api-gateway/src/index.ts
Normal file
9
api-gateway/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { crearApp } from './app.js'
|
||||
import { config } from './config/index.js'
|
||||
|
||||
const app = crearApp()
|
||||
|
||||
app.listen(config.port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`)
|
||||
})
|
||||
23
api-gateway/src/middleware/errorHandler.ts
Normal file
23
api-gateway/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { ZodError } from 'zod'
|
||||
import { MulterError } from 'multer'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/** Error handler central: zod → 400 con issues, AppError → su status, resto → 500 */
|
||||
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void {
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({ error: 'Validación fallida', issues: err.issues })
|
||||
return
|
||||
}
|
||||
if (err instanceof MulterError) {
|
||||
res.status(400).json({ error: `Error de carga de archivos: ${err.message}` })
|
||||
return
|
||||
}
|
||||
if (err instanceof AppError) {
|
||||
res.status(err.status).json({ error: err.message })
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[500] ${req.method} ${req.originalUrl}:`, err)
|
||||
res.status(500).json({ error: 'Error interno del servidor' })
|
||||
}
|
||||
11
api-gateway/src/middleware/requestLogger.ts
Normal file
11
api-gateway/src/middleware/requestLogger.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
/** Logger de requests simple: método, ruta, status y duración */
|
||||
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
||||
const inicio = Date.now()
|
||||
res.on('finish', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - inicio}ms`)
|
||||
})
|
||||
next()
|
||||
}
|
||||
51
api-gateway/src/routes/index.ts
Normal file
51
api-gateway/src/routes/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express'
|
||||
import { health } from '../controllers/health.controller.js'
|
||||
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
|
||||
import {
|
||||
crearCotizacion,
|
||||
listarCotizaciones,
|
||||
obtenerCotizacion,
|
||||
} from '../controllers/cotizaciones.controller.js'
|
||||
import {
|
||||
crearCliente,
|
||||
listarDocumentos,
|
||||
obtenerCliente,
|
||||
subirDocumentos,
|
||||
uploadDocumentos,
|
||||
} from '../controllers/clientes.controller.js'
|
||||
import {
|
||||
agregarNota,
|
||||
cancelarPlan,
|
||||
cobranzaHoy,
|
||||
crearPlan,
|
||||
listarPlanes,
|
||||
obtenerPlan,
|
||||
registrarAbonoEndpoint,
|
||||
} from '../controllers/planes.controller.js'
|
||||
import { webhookMonday } from '../controllers/webhooks.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
apiRouter.get('/health', health)
|
||||
|
||||
apiRouter.get('/unidades', listarUnidades)
|
||||
apiRouter.get('/unidades/:codigo', obtenerUnidad)
|
||||
|
||||
apiRouter.post('/cotizaciones', crearCotizacion)
|
||||
apiRouter.get('/cotizaciones', listarCotizaciones)
|
||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||
|
||||
apiRouter.post('/clientes', crearCliente)
|
||||
apiRouter.get('/clientes/:id', obtenerCliente)
|
||||
apiRouter.post('/clientes/:id/documentos', uploadDocumentos.array('archivos', 10), subirDocumentos)
|
||||
apiRouter.get('/clientes/:id/documentos', listarDocumentos)
|
||||
|
||||
apiRouter.post('/planes', crearPlan)
|
||||
apiRouter.get('/planes', listarPlanes)
|
||||
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
|
||||
apiRouter.get('/planes/:id', obtenerPlan)
|
||||
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
|
||||
apiRouter.post('/planes/:id/notas', agregarNota)
|
||||
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
|
||||
|
||||
apiRouter.post('/webhooks/monday', webhookMonday)
|
||||
310
api-gateway/src/services/cobranza.service.test.ts
Normal file
310
api-gateway/src/services/cobranza.service.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
cobranzaDelDia,
|
||||
construirNota,
|
||||
diasAtraso,
|
||||
generarCalendario,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
proximoVencimiento,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
sumarMeses,
|
||||
totalMora,
|
||||
validarAbono,
|
||||
type AbonoInput,
|
||||
type Pago,
|
||||
type PlanPago,
|
||||
} from './cobranza.service.js'
|
||||
|
||||
const pagoBase = (override: Partial<Pago> = {}): Pago => ({
|
||||
id: 'p1',
|
||||
numPago: 1,
|
||||
fechaVencimiento: '2026-02-15',
|
||||
monto: 10000,
|
||||
montoPagado: 0,
|
||||
aplicaciones: [],
|
||||
...override,
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper de test: valida con `validarAbono` (la función real del servicio) y
|
||||
* devuelve una copia del plan con el abono aplicado — el equivalente en
|
||||
* memoria de lo que el controller persiste en Prisma.
|
||||
*/
|
||||
function aplicarAbono(plan: PlanPago, abono: AbonoInput): PlanPago {
|
||||
const { pago, aplicacion } = validarAbono(plan, abono)
|
||||
return {
|
||||
...plan,
|
||||
pagos: plan.pagos.map((p) =>
|
||||
p.id === pago.id
|
||||
? { ...p, montoPagado: round2(p.montoPagado + aplicacion.monto), aplicaciones: [...p.aplicaciones, aplicacion] }
|
||||
: p,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function planDePrueba(): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-10', 30000, 3)
|
||||
return {
|
||||
id: 'plan-1',
|
||||
ventaRef: 'VTA-001',
|
||||
clienteNombre: 'Cliente Prueba',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-10',
|
||||
engancheTotal: 30000,
|
||||
numPagos: 3,
|
||||
mensualidad,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('generarCalendario', () => {
|
||||
it('genera 12 pagos cuya suma es exactamente el enganche', () => {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-15', 180000, 12)
|
||||
expect(pagos).toHaveLength(12)
|
||||
expect(mensualidad).toBe(15000)
|
||||
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(180000)
|
||||
expect(pagos[0]?.numPago).toBe(1)
|
||||
expect(pagos[11]?.numPago).toBe(12)
|
||||
expect(pagos[0]?.fechaVencimiento).toBe('2026-01-15')
|
||||
expect(pagos[11]?.fechaVencimiento).toBe('2026-12-15')
|
||||
})
|
||||
|
||||
it('el último pago absorbe el residuo del redondeo (100000 / 3)', () => {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-03-01', 100000, 3)
|
||||
expect(mensualidad).toBe(33333.33)
|
||||
expect(pagos.map((p) => p.monto)).toEqual([33333.33, 33333.33, 33333.34])
|
||||
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(100000)
|
||||
})
|
||||
|
||||
it('ajusta el día cuando el mes no lo tiene (31 de enero → 28 de febrero)', () => {
|
||||
const { pagos } = generarCalendario('2025-01-31', 30000, 3)
|
||||
expect(pagos[0]?.fechaVencimiento).toBe('2025-01-31')
|
||||
expect(pagos[1]?.fechaVencimiento).toBe('2025-02-28')
|
||||
expect(pagos[2]?.fechaVencimiento).toBe('2025-03-31')
|
||||
})
|
||||
|
||||
it('respeta año bisiesto (31 de enero 2024 → 29 de febrero)', () => {
|
||||
expect(sumarMeses(new Date(2024, 0, 31), 1)).toEqual(new Date(2024, 1, 29))
|
||||
})
|
||||
|
||||
it('rechaza parámetros inválidos', () => {
|
||||
expect(() => generarCalendario('2026-01-01', 0, 6)).toThrow()
|
||||
expect(() => generarCalendario('2026-01-01', 1000, 0)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusPago', () => {
|
||||
const hoy = new Date('2026-02-16T00:00:00')
|
||||
|
||||
it('PAGADO cuando montoPagado cubre el monto', () => {
|
||||
expect(statusPago(pagoBase({ montoPagado: 10000 }), hoy)).toBe('PAGADO')
|
||||
})
|
||||
|
||||
it('PARCIAL con abono menor al monto, aunque ya venció', () => {
|
||||
expect(statusPago(pagoBase({ montoPagado: 4000 }), hoy)).toBe('PARCIAL')
|
||||
})
|
||||
|
||||
it('VENCIDO sin abono y hoy posterior al vencimiento', () => {
|
||||
expect(statusPago(pagoBase(), hoy)).toBe('VENCIDO')
|
||||
})
|
||||
|
||||
it('PENDIENTE sin abono y hoy en o antes del vencimiento', () => {
|
||||
expect(statusPago(pagoBase(), new Date('2026-02-15T00:00:00'))).toBe('PENDIENTE')
|
||||
expect(statusPago(pagoBase(), new Date('2026-02-01T00:00:00'))).toBe('PENDIENTE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validarAbono', () => {
|
||||
it('aplica un abono parcial y lo agrega al historial', () => {
|
||||
const plan = planDePrueba()
|
||||
const actualizado = aplicarAbono(plan, {
|
||||
pagoId: plan.pagos[0]!.id,
|
||||
fechaPago: '2026-01-10',
|
||||
monto: 4000,
|
||||
referencia: 'SPEI-123',
|
||||
})
|
||||
expect(actualizado.pagos[0]?.montoPagado).toBe(4000)
|
||||
expect(actualizado.pagos[0]?.aplicaciones).toHaveLength(1)
|
||||
expect(actualizado.pagos[0]?.aplicaciones[0]?.referencia).toBe('SPEI-123')
|
||||
expect(plan.pagos[0]?.montoPagado).toBe(0) // no muta el original
|
||||
})
|
||||
|
||||
it('permite completar un pago con varios abonos', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A1' })
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-11', monto: 6000, referencia: 'A2' })
|
||||
expect(plan.pagos[0]?.montoPagado).toBe(10000)
|
||||
expect(plan.pagos[0]?.aplicaciones).toHaveLength(2)
|
||||
expect(statusPago(plan.pagos[0]!, new Date('2026-01-12T00:00:00'))).toBe('PAGADO')
|
||||
})
|
||||
|
||||
it('rechaza abonos que exceden el restante (estricto al centavo)', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000.01, referencia: 'X1' }),
|
||||
).toThrow(/excede el restante/)
|
||||
})
|
||||
|
||||
it('rechaza abonos menores o iguales a 0', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 0, referencia: 'X1' }),
|
||||
).toThrow(/mayor a 0/)
|
||||
expect(() =>
|
||||
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: -500, referencia: 'X1' }),
|
||||
).toThrow(/mayor a 0/)
|
||||
})
|
||||
|
||||
it('rechaza un pagoId inexistente', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
validarAbono(plan, { pagoId: 'no-existe', fechaPago: '2026-01-10', monto: 100, referencia: 'X1' }),
|
||||
).toThrow(/no encontrado/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saldo, avance y status del plan', () => {
|
||||
it('saldoPlan y pagadoTotal reflejan los abonos', () => {
|
||||
let plan = planDePrueba()
|
||||
expect(saldoPlan(plan)).toBe(30000)
|
||||
expect(pagadoTotal(plan)).toBe(0)
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A1' })
|
||||
expect(saldoPlan(plan)).toBe(20000)
|
||||
expect(pagadoTotal(plan)).toBe(10000)
|
||||
expect(avancePlan(plan)).toBeCloseTo(1 / 3, 5)
|
||||
})
|
||||
|
||||
it('el plan queda LIQUIDADO al cubrir todo el enganche', () => {
|
||||
let plan = planDePrueba()
|
||||
for (const pago of plan.pagos) {
|
||||
plan = aplicarAbono(plan, {
|
||||
pagoId: pago.id,
|
||||
fechaPago: '2026-03-10',
|
||||
monto: pago.monto - pago.montoPagado,
|
||||
referencia: `LIQ-${pago.numPago}`,
|
||||
})
|
||||
}
|
||||
expect(saldoPlan(plan)).toBe(0)
|
||||
expect(statusPlan(plan)).toBe('LIQUIDADO')
|
||||
})
|
||||
|
||||
it('CANCELADO prevalece aunque tenga saldo', () => {
|
||||
const plan = { ...planDePrueba(), status: 'CANCELADO' as const }
|
||||
expect(statusPlan(plan)).toBe('CANCELADO')
|
||||
})
|
||||
|
||||
it('alCorriente es falso cuando hay pagos vencidos', () => {
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false)
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proximoVencimiento y diasAtraso', () => {
|
||||
it('devuelve el primer pago no cubierto con fecha >= hoy', () => {
|
||||
const prox = proximoVencimiento(planDePrueba(), new Date('2026-01-20T00:00:00'))
|
||||
expect(prox?.numPago).toBe(2)
|
||||
})
|
||||
|
||||
it('si no hay futuros, devuelve el vencido más antiguo', () => {
|
||||
const prox = proximoVencimiento(planDePrueba(), new Date('2026-04-01T00:00:00'))
|
||||
expect(prox?.numPago).toBe(1)
|
||||
})
|
||||
|
||||
it('devuelve null cuando todo está cubierto', () => {
|
||||
let plan = planDePrueba()
|
||||
for (const pago of plan.pagos) {
|
||||
plan = aplicarAbono(plan, { pagoId: pago.id, fechaPago: '2026-01-10', monto: pago.monto, referencia: 'X' })
|
||||
}
|
||||
expect(proximoVencimiento(plan, new Date('2026-01-15T00:00:00'))).toBeNull()
|
||||
})
|
||||
|
||||
it('diasAtraso cuenta desde el vencido más antiguo', () => {
|
||||
expect(diasAtraso(planDePrueba(), new Date('2026-01-25T00:00:00'))).toBe(15)
|
||||
expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0)
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido no cuenta como atraso', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
|
||||
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moraDePago y totalMora', () => {
|
||||
const hoy = new Date('2026-03-17T00:00:00') // pago de 10000 vencido el 15-feb → 30 días
|
||||
|
||||
it('es 0 cuando la tasa es 0, cuando no está vencido o cuando ya está cubierto', () => {
|
||||
expect(moraDePago(pagoBase(), 0, hoy)).toBe(0)
|
||||
expect(moraDePago(pagoBase(), 2, new Date('2026-02-15T00:00:00'))).toBe(0)
|
||||
expect(moraDePago(pagoBase({ montoPagado: 10000 }), 2, hoy)).toBe(0)
|
||||
})
|
||||
|
||||
it('cálculo exacto: restante × (tasa/100) / 30 × días vencidos', () => {
|
||||
expect(moraDePago(pagoBase(), 2, hoy)).toBe(200) // 10000 × 0.02 / 30 × 30
|
||||
expect(moraDePago(pagoBase(), 3, new Date('2026-02-25T00:00:00'))).toBe(100) // × 0.03 / 30 × 10
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido genera mora sobre su restante', () => {
|
||||
expect(moraDePago(pagoBase({ montoPagado: 4000 }), 2, hoy)).toBe(120)
|
||||
})
|
||||
|
||||
it('totalMora suma la mora de todos los pagos y usa tasa 0 por defecto', () => {
|
||||
const plan = planDePrueba() // vence 10-ene, 10-feb, 10-mar; hoy 20-mar
|
||||
const hoyPlan = new Date('2026-03-20T00:00:00')
|
||||
expect(totalMora(plan, hoyPlan)).toBe(0)
|
||||
const conTasa: PlanPago = { ...plan, tasaMoraMensual: 3 }
|
||||
expect(totalMora(conTasa, hoyPlan)).toBe(1170) // 690 + 380 + 100
|
||||
})
|
||||
})
|
||||
|
||||
describe('cobranzaDelDia', () => {
|
||||
it('incluye vencidos y los que vencen en los próximos 7 días, ordenados por urgencia', () => {
|
||||
const hoy = new Date('2026-03-05T00:00:00')
|
||||
const items = cobranzaDelDia([planDePrueba()], hoy)
|
||||
expect(items.map((i) => i.pago.numPago)).toEqual([1, 2, 3])
|
||||
expect(items.map((i) => i.atraso)).toEqual([54, 23, 0])
|
||||
})
|
||||
|
||||
it('excluye pagos cubiertos, planes no activos y vencimientos lejanos', () => {
|
||||
const hoy = new Date('2026-01-05T00:00:00')
|
||||
let plan = planDePrueba()
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-05', monto: 10000, referencia: 'A' })
|
||||
const cancelado: PlanPago = { ...planDePrueba(), status: 'CANCELADO' }
|
||||
expect(cobranzaDelDia([plan, cancelado], hoy)).toHaveLength(0)
|
||||
const items40 = cobranzaDelDia([plan, cancelado], hoy, 40)
|
||||
expect(items40.map((i) => i.pago.numPago)).toEqual([2])
|
||||
})
|
||||
|
||||
it('un PARCIAL vencido aparece con sus días de atraso', () => {
|
||||
const hoy = new Date('2026-02-01T00:00:00')
|
||||
let plan = planDePrueba()
|
||||
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A' })
|
||||
const items = cobranzaDelDia([plan], hoy)
|
||||
expect(items[0]?.pago.numPago).toBe(1)
|
||||
expect(items[0]?.atraso).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
describe('construirNota', () => {
|
||||
it('construye la nota con texto limpio, id y fecha', () => {
|
||||
const nota = construirNota(' Cliente promete pago el viernes ')
|
||||
expect(nota.texto).toBe('Cliente promete pago el viernes')
|
||||
expect(nota.id).toBeTruthy()
|
||||
expect(nota.fecha).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rechaza notas de menos de 3 caracteres', () => {
|
||||
expect(() => construirNota(' ok ')).toThrow(/al menos 3/)
|
||||
expect(() => construirNota('')).toThrow(/al menos 3/)
|
||||
})
|
||||
})
|
||||
316
api-gateway/src/services/cobranza.service.ts
Normal file
316
api-gateway/src/services/cobranza.service.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Lógica de cobranza espejo de apps/cobranza/src/lib/cobranza.ts.
|
||||
* Son funciones puras: los controllers mapean los registros de Prisma a
|
||||
* estas formas, ejecutan la lógica y persisten los cambios.
|
||||
*/
|
||||
|
||||
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
|
||||
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
|
||||
|
||||
export interface AplicacionPago {
|
||||
id: string
|
||||
fechaPago: string // ISO
|
||||
monto: number
|
||||
referencia: string
|
||||
}
|
||||
|
||||
export interface Pago {
|
||||
id: string
|
||||
numPago: number
|
||||
fechaVencimiento: string // 'YYYY-MM-DD'
|
||||
monto: number
|
||||
montoPagado: number
|
||||
aplicaciones: AplicacionPago[]
|
||||
}
|
||||
|
||||
export interface NotaPlan {
|
||||
id: string
|
||||
fecha: string // ISO
|
||||
texto: string
|
||||
}
|
||||
|
||||
export interface PlanPago {
|
||||
id: string
|
||||
ventaRef?: string | null
|
||||
clienteNombre: string
|
||||
clienteTelefono?: string | null
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
fechaInicio: string // 'YYYY-MM-DD'
|
||||
engancheTotal: number
|
||||
numPagos: number
|
||||
mensualidad: number
|
||||
tasaMoraMensual?: number
|
||||
status: StatusPlan
|
||||
pagos: Pago[]
|
||||
notas: NotaPlan[]
|
||||
createdAt: string // ISO
|
||||
}
|
||||
|
||||
/** Redondeo monetario a 2 decimales */
|
||||
export const round2 = (x: number): number => Math.round(x * 100) / 100
|
||||
|
||||
/** Tolerancia para comparaciones de dinero (centavos) */
|
||||
export const EPS = 0.01
|
||||
|
||||
const gte = (a: number, b: number): boolean => a >= b - EPS
|
||||
|
||||
/** Parsea 'YYYY-MM-DD' como fecha LOCAL (nunca `new Date(iso)` a secas, que interpreta UTC) */
|
||||
export function parseFechaLocal(iso: string): Date {
|
||||
return new Date(`${iso}T00:00:00`)
|
||||
}
|
||||
|
||||
function aISO(fecha: Date): string {
|
||||
const y = fecha.getFullYear()
|
||||
const m = String(fecha.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(fecha.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/** Último día del mes (mes 0-based) */
|
||||
function ultimoDiaDelMes(anio: number, mes: number): number {
|
||||
return new Date(anio, mes + 1, 0).getDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Suma `meses` conservando el día del mes; si el mes destino no tiene ese día,
|
||||
* usa su último día (p. ej. 31 de enero + 1 mes → 28/29 de febrero).
|
||||
*/
|
||||
export function sumarMeses(fecha: Date, meses: number): Date {
|
||||
const totalMeses = fecha.getMonth() + meses
|
||||
const anio = fecha.getFullYear() + Math.floor(totalMeses / 12)
|
||||
const mes = ((totalMeses % 12) + 12) % 12
|
||||
const dia = Math.min(fecha.getDate(), ultimoDiaDelMes(anio, mes))
|
||||
return new Date(anio, mes, dia)
|
||||
}
|
||||
|
||||
/** Compara solo la parte de fecha (ignora horas) */
|
||||
function compararFechas(a: Date, b: Date): number {
|
||||
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
|
||||
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
|
||||
return da - db
|
||||
}
|
||||
|
||||
export interface CalendarioGenerado {
|
||||
mensualidad: number
|
||||
pagos: Pago[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera N pagos con vencimiento mensual a partir de `fechaInicio`.
|
||||
* mensualidad = round2(engancheTotal / numPagos); el ÚLTIMO pago absorbe
|
||||
* el residuo del redondeo para que la suma sea exactamente `engancheTotal`.
|
||||
*/
|
||||
export function generarCalendario(
|
||||
fechaInicio: string,
|
||||
engancheTotal: number,
|
||||
numPagos: number,
|
||||
): CalendarioGenerado {
|
||||
if (numPagos < 1) throw new Error('numPagos debe ser al menos 1')
|
||||
if (engancheTotal <= 0) throw new Error('engancheTotal debe ser mayor a 0')
|
||||
const inicio = parseFechaLocal(fechaInicio)
|
||||
const mensualidad = round2(engancheTotal / numPagos)
|
||||
const pagos: Pago[] = []
|
||||
for (let i = 0; i < numPagos; i++) {
|
||||
const monto =
|
||||
i < numPagos - 1 ? mensualidad : round2(engancheTotal - mensualidad * (numPagos - 1))
|
||||
pagos.push({
|
||||
id: randomUUID(),
|
||||
numPago: i + 1,
|
||||
fechaVencimiento: aISO(sumarMeses(inicio, i)),
|
||||
monto,
|
||||
montoPagado: 0,
|
||||
aplicaciones: [],
|
||||
})
|
||||
}
|
||||
return { mensualidad, pagos }
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de un pago a la fecha `hoy`.
|
||||
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
|
||||
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
|
||||
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
|
||||
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
|
||||
*/
|
||||
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
|
||||
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
|
||||
if (pago.montoPagado > EPS) return 'PARCIAL'
|
||||
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 ? 'VENCIDO' : 'PENDIENTE'
|
||||
}
|
||||
|
||||
/** Saldo pendiente del plan: suma(monto - montoPagado) */
|
||||
export function saldoPlan(plan: PlanPago): number {
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0))
|
||||
}
|
||||
|
||||
/** Total cobrado (suma de lo aplicado a todos los pagos) */
|
||||
export function pagadoTotal(plan: PlanPago): number {
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + p.montoPagado, 0))
|
||||
}
|
||||
|
||||
/** Fracción de avance 0..1 (pagado / engancheTotal) */
|
||||
export function avancePlan(plan: PlanPago): number {
|
||||
return plan.engancheTotal > 0 ? pagadoTotal(plan) / plan.engancheTotal : 0
|
||||
}
|
||||
|
||||
const restante = (p: Pago): number => round2(p.monto - p.montoPagado)
|
||||
const estaCubierto = (p: Pago): boolean => gte(p.montoPagado, p.monto)
|
||||
|
||||
/**
|
||||
* Primer pago no cubierto con fecha >= hoy; si no hay, el vencido más antiguo.
|
||||
* Devuelve null si el plan está cubierto por completo.
|
||||
*/
|
||||
export function proximoVencimiento(plan: PlanPago, hoy: Date = new Date()): Pago | null {
|
||||
const noCubiertos = plan.pagos.filter((p) => !estaCubierto(p))
|
||||
const futuro = noCubiertos.find((p) => compararFechas(parseFechaLocal(p.fechaVencimiento), hoy) >= 0)
|
||||
if (futuro) return futuro
|
||||
const vencidos = noCubiertos.sort(
|
||||
(a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
)
|
||||
return vencidos[0] ?? null
|
||||
}
|
||||
|
||||
/** Días naturales hasta el próximo vencimiento; null si el plan está cubierto */
|
||||
export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date()): number | null {
|
||||
const prox = proximoVencimiento(plan, hoy)
|
||||
if (!prox) return null
|
||||
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
|
||||
}
|
||||
|
||||
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
|
||||
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const vencidos = plan.pagos
|
||||
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
)
|
||||
const primero = vencidos[0]
|
||||
if (!primero) return 0
|
||||
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
|
||||
}
|
||||
|
||||
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
|
||||
export function statusPlan(plan: PlanPago): StatusPlan {
|
||||
if (plan.status === 'CANCELADO') return 'CANCELADO'
|
||||
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
|
||||
}
|
||||
|
||||
/** Al corriente = sin pagos VENCIDOS */
|
||||
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
|
||||
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mora simple de un pago: restante × (tasa% / 100) / 30 × días vencidos.
|
||||
* Solo aplica a pagos vencidos con saldo; un PARCIAL vencido también genera
|
||||
* mora sobre su restante. Tasa 0 (o negativa) = sin mora.
|
||||
*/
|
||||
export function moraDePago(pago: Pago, tasaMoraMensual: number, hoy: Date = new Date()): number {
|
||||
if (tasaMoraMensual <= 0) return 0
|
||||
const falta = restante(pago)
|
||||
if (falta <= EPS) return 0
|
||||
const diasVencido = Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000)
|
||||
if (diasVencido <= 0) return 0
|
||||
return round2((falta * (tasaMoraMensual / 100) / 30) * diasVencido)
|
||||
}
|
||||
|
||||
/** Mora acumulada del plan a la fecha `hoy` (usa plan.tasaMoraMensual ?? 0) */
|
||||
export function totalMora(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const tasa = plan.tasaMoraMensual ?? 0
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + moraDePago(p, tasa, hoy), 0))
|
||||
}
|
||||
|
||||
export interface CobranzaItem {
|
||||
plan: PlanPago
|
||||
pago: Pago
|
||||
/** Días de atraso (0 si aún no vence) */
|
||||
atraso: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
|
||||
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
||||
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
|
||||
* atraso, el vencimiento más próximo primero.
|
||||
*/
|
||||
export function cobranzaDelDia(
|
||||
planes: PlanPago[],
|
||||
hoy: Date = new Date(),
|
||||
diasAnticipacion = 7,
|
||||
): CobranzaItem[] {
|
||||
const limite = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + diasAnticipacion)
|
||||
const items: CobranzaItem[] = []
|
||||
for (const plan of planes) {
|
||||
if (statusPlan(plan) !== 'ACTIVO') continue
|
||||
for (const pago of plan.pagos) {
|
||||
if (estaCubierto(pago)) continue
|
||||
if (compararFechas(parseFechaLocal(pago.fechaVencimiento), limite) > 0) continue
|
||||
const atraso = Math.max(
|
||||
0,
|
||||
Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000),
|
||||
)
|
||||
items.push({ plan, pago, atraso })
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
if (a.atraso !== b.atraso) return b.atraso - a.atraso
|
||||
return compararFechas(
|
||||
parseFechaLocal(a.pago.fechaVencimiento),
|
||||
parseFechaLocal(b.pago.fechaVencimiento),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida el texto de una nota y devuelve la nota construida.
|
||||
* El texto es requerido (mín. 3 caracteres).
|
||||
*/
|
||||
export function construirNota(texto: string): NotaPlan {
|
||||
const limpio = texto.trim()
|
||||
if (limpio.length < 3) {
|
||||
throw new Error('La nota debe tener al menos 3 caracteres')
|
||||
}
|
||||
return { id: randomUUID(), fecha: new Date().toISOString(), texto: limpio }
|
||||
}
|
||||
|
||||
export interface AbonoInput {
|
||||
pagoId: string
|
||||
fechaPago: string // ISO
|
||||
monto: number
|
||||
referencia: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida un abono contra el plan y devuelve la aplicación construida más el
|
||||
* pago objetivo. Reglas (idénticas al frontend):
|
||||
* - rechaza montos <= 0
|
||||
* - el pago debe existir en el plan
|
||||
* - estricto al centavo: el abono nunca puede exceder el restante del pago
|
||||
*/
|
||||
export function validarAbono(plan: PlanPago, abono: AbonoInput): { pago: Pago; aplicacion: AplicacionPago } {
|
||||
if (round2(abono.monto) <= 0) {
|
||||
throw new Error('El abono debe ser mayor a 0')
|
||||
}
|
||||
const pago = plan.pagos.find((p) => p.id === abono.pagoId)
|
||||
if (!pago) throw new Error('Pago no encontrado en el plan')
|
||||
const falta = restante(pago)
|
||||
if (round2(abono.monto - falta) > 0) {
|
||||
throw new Error(`El abono excede el restante del pago (${falta.toFixed(2)})`)
|
||||
}
|
||||
const aplicacion: AplicacionPago = {
|
||||
id: randomUUID(),
|
||||
fechaPago: abono.fechaPago,
|
||||
monto: round2(abono.monto),
|
||||
referencia: abono.referencia,
|
||||
}
|
||||
return { pago, aplicacion }
|
||||
}
|
||||
|
||||
/** Restante de un pago (expuesto para los controllers) */
|
||||
export function restantePago(p: Pago): number {
|
||||
return restante(p)
|
||||
}
|
||||
141
api-gateway/src/services/cotizacion.service.test.ts
Normal file
141
api-gateway/src/services/cotizacion.service.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
APARTADO_DEFAULT,
|
||||
calcularNumMensualidades,
|
||||
calcularPropuesta,
|
||||
calcularPropuestas,
|
||||
indiceMenorTotal,
|
||||
obtenerPrecioLista,
|
||||
totalPagado,
|
||||
type UnidadCotizable,
|
||||
} from './cotizacion.service.js'
|
||||
|
||||
// Fixture validado contra el Excel real: U-101, con acabados, apartado 20000, N = 14
|
||||
const U101: UnidadCotizable = {
|
||||
codigo: 'U-101',
|
||||
modelo: '1R',
|
||||
fechaEntrega: '2027-10-08',
|
||||
supEstacionamiento: 12.5,
|
||||
supConstruccion: 69.485,
|
||||
supExcedente: 0,
|
||||
precioSinAcabados: 4415000,
|
||||
precioConAcabados: 4579000,
|
||||
status: 'DISPONIBLE',
|
||||
desarrollo: 'SENDA',
|
||||
}
|
||||
const N = 14
|
||||
|
||||
function calcular(descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) {
|
||||
return calcularPropuestas({
|
||||
unidad: U101,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos,
|
||||
numMeses: N,
|
||||
})
|
||||
}
|
||||
|
||||
describe('fixture U-101 (validado contra Excel de producción)', () => {
|
||||
it('precio de lista con acabados', () => {
|
||||
expect(obtenerPrecioLista(U101, true)).toBe(4579000)
|
||||
expect(obtenerPrecioLista(U101, false)).toBe(4415000)
|
||||
})
|
||||
|
||||
it('Propuesta 1 — CRÉDITO BANCARIO (desc 0)', () => {
|
||||
const [p] = calcular()
|
||||
expect(p?.enganche).toBeCloseTo(94475, 2)
|
||||
expect(p?.mensualidadesTotal).toBeCloseTo(343425, 2)
|
||||
expect(p?.mensualidad).toBeCloseTo(24530.357142, 4)
|
||||
expect(p?.pagoFinal).toBeCloseTo(4121100, 2)
|
||||
})
|
||||
|
||||
it('Propuesta 2 — CRÉDITO INFONAVIT-COFINAVIT (desc 0)', () => {
|
||||
const [, p] = calcular()
|
||||
expect(p?.enganche).toBeCloseTo(94475, 2)
|
||||
expect(p?.mensualidadesTotal).toBeCloseTo(114475, 2)
|
||||
expect(p?.mensualidad).toBeCloseTo(8176.785714, 4)
|
||||
expect(p?.pagoFinal).toBeCloseTo(4350050, 2)
|
||||
})
|
||||
|
||||
it('Propuesta 3 — CONTADO (desc 2%)', () => {
|
||||
const [, , p] = calcular()
|
||||
expect(p?.montoDescuento).toBeCloseTo(91580, 2)
|
||||
expect(p?.precioFinal).toBeCloseTo(4487420, 2)
|
||||
expect(p?.enganche).toBeCloseTo(4018678, 2)
|
||||
expect(p?.pagoFinal).toBeCloseTo(448742, 2)
|
||||
expect(p?.mensualidad).toBeNull()
|
||||
expect(p?.mensualidadesTotal).toBeNull()
|
||||
})
|
||||
|
||||
it('Propuesta 4 — SEMI CONTADO (desc 1%)', () => {
|
||||
const [, , , p] = calcular()
|
||||
expect(p?.montoDescuento).toBeCloseTo(45790, 2)
|
||||
expect(p?.precioFinal).toBeCloseTo(4533210, 2)
|
||||
expect(p?.enganche).toBeCloseTo(2246605, 2)
|
||||
expect(p?.mensualidadesTotal).toBeCloseTo(1813284, 2)
|
||||
expect(p?.mensualidad).toBeCloseTo(129520.285714, 4)
|
||||
expect(p?.pagoFinal).toBeCloseTo(453321, 2)
|
||||
})
|
||||
|
||||
it('calcularPropuesta (una sola) reproduce CONTADO con descuento default', () => {
|
||||
const p = calcularPropuesta(U101, 'CONTADO', true, APARTADO_DEFAULT, 0.02)
|
||||
expect(p.montoDescuento).toBeCloseTo(91580, 2)
|
||||
expect(p.precioFinal).toBeCloseTo(4487420, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('comportamiento documentado del Excel', () => {
|
||||
it('Bancario con descuento > 0: el enganche sigue calculándose sobre precio LISTA', () => {
|
||||
const [p] = calcular({ creditoBancario: 0.05, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 })
|
||||
expect(p?.enganche).toBeCloseTo(0.025 * 4579000 - APARTADO_DEFAULT, 2)
|
||||
expect(p?.montoDescuento).toBeCloseTo(228950, 2)
|
||||
expect(p?.precioFinal).toBeCloseTo(4350050, 2)
|
||||
expect(p?.pagoFinal).toBeCloseTo(0.9 * 4350050, 2)
|
||||
const total = 4350050 - APARTADO_DEFAULT - 94475 - 0.9 * 4350050
|
||||
expect(p?.mensualidadesTotal).toBeCloseTo(total, 2)
|
||||
})
|
||||
|
||||
it('Infonavit usa precioLista (no precioFinal) en el total de mensualidades', () => {
|
||||
const [, p] = calcular({ creditoBancario: 0, creditoInfonavit: 0.05, contado: 0.02, semiContado: 0.01 })
|
||||
const total = 4579000 - (p?.pagoFinal ?? 0) - APARTADO_DEFAULT - (p?.enganche ?? 0)
|
||||
expect(p?.mensualidadesTotal).toBeCloseTo(total, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularNumMensualidades', () => {
|
||||
it('calcula meses redondeados con 30.4 días por mes', () => {
|
||||
const hoy = new Date('2026-08-20T00:00:00')
|
||||
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(14)
|
||||
})
|
||||
|
||||
it('nunca devuelve menos de 1', () => {
|
||||
const hoy = new Date('2030-01-01T00:00:00')
|
||||
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('totalPagado', () => {
|
||||
it('bancario: coincide con el precio de lista (desc 0)', () => {
|
||||
const [bancario] = calcular()
|
||||
expect(totalPagado(bancario!, APARTADO_DEFAULT)).toBeCloseTo(4579000, 2)
|
||||
})
|
||||
|
||||
it('contado y semi contado: coinciden con el precio final c/desc', () => {
|
||||
const [, , contado, semi] = calcular()
|
||||
expect(totalPagado(contado!, APARTADO_DEFAULT)).toBeCloseTo(4487420, 2)
|
||||
expect(totalPagado(semi!, APARTADO_DEFAULT)).toBeCloseTo(4533210, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('indiceMenorTotal', () => {
|
||||
it('contado (2% desc) tiene el menor total pagado', () => {
|
||||
const propuestas = calcular()
|
||||
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT))
|
||||
expect(indiceMenorTotal(totales)).toBe(2)
|
||||
})
|
||||
|
||||
it('en empate gana el primero; lista vacía devuelve -1', () => {
|
||||
expect(indiceMenorTotal([100, 100, 200])).toBe(0)
|
||||
expect(indiceMenorTotal([])).toBe(-1)
|
||||
})
|
||||
})
|
||||
245
api-gateway/src/services/cotizacion.service.ts
Normal file
245
api-gateway/src/services/cotizacion.service.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Cálculos replicados fielmente del Excel de producción "COTIZADOR" de
|
||||
* Frasa Desarrollos (port de apps/cotizador/src/lib/calculations.ts).
|
||||
* NO "corregir" las fórmulas: algunas usan precio de lista donde
|
||||
* intuitivamente iría precio final; así está en el Excel y así debe quedar.
|
||||
*/
|
||||
|
||||
export interface UnidadCotizable {
|
||||
codigo: string
|
||||
modelo: string
|
||||
fechaEntrega: string // ISO 'YYYY-MM-DD' (terminación de obra)
|
||||
supEstacionamiento: number
|
||||
supConstruccion: number
|
||||
supExcedente: number
|
||||
precioSinAcabados: number
|
||||
precioConAcabados: number
|
||||
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
|
||||
desarrollo: string
|
||||
}
|
||||
|
||||
export type TipoPropuesta =
|
||||
| 'CREDITO_BANCARIO'
|
||||
| 'CREDITO_INFONAVIT_COFINAVIT'
|
||||
| 'CONTADO'
|
||||
| 'SEMI_CONTADO'
|
||||
|
||||
export interface DescuentosPropuestas {
|
||||
creditoBancario: number
|
||||
creditoInfonavit: number
|
||||
contado: number
|
||||
semiContado: number
|
||||
}
|
||||
|
||||
export const APARTADO_DEFAULT = 20000
|
||||
|
||||
// Descuentos por defecto de cada propuesta (fracción, no porcentaje)
|
||||
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
||||
creditoBancario: 0,
|
||||
creditoInfonavit: 0,
|
||||
contado: 0.02,
|
||||
semiContado: 0.01,
|
||||
}
|
||||
|
||||
export interface PropuestaResultado {
|
||||
tipo: TipoPropuesta
|
||||
descuento: number // fracción (0.02 = 2%)
|
||||
montoDescuento: number
|
||||
precioFinal: number // precio lista - descuento
|
||||
enganche: number // NETO del apartado (el apartado ya se pagó)
|
||||
numMensualidades: number
|
||||
mensualidad: number | null // null => N/A (Contado)
|
||||
mensualidadesTotal: number | null // null => N/A (Contado)
|
||||
pagoFinal: number
|
||||
}
|
||||
|
||||
export interface CotizacionInput {
|
||||
unidad: UnidadCotizable
|
||||
acabados: boolean // true => incluye cocina y closet
|
||||
apartado: number
|
||||
descuentos: DescuentosPropuestas
|
||||
/** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */
|
||||
numMeses?: number
|
||||
}
|
||||
|
||||
const MS_POR_MES = 30.4 * 86400000
|
||||
|
||||
/** Meses (redondeados, mínimo 1) desde hoy hasta la terminación de obra */
|
||||
export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new Date()): number {
|
||||
const entrega = new Date(`${fechaEntrega}T00:00:00`)
|
||||
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
|
||||
}
|
||||
|
||||
function baseComun(precioLista: number, descuento: number) {
|
||||
// Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC
|
||||
const montoDescuento = precioLista * descuento
|
||||
const precioFinal = precioLista - montoDescuento
|
||||
return { montoDescuento, precioFinal }
|
||||
}
|
||||
|
||||
/** PROPUESTA 1 — CRÉDITO BANCARIO */
|
||||
function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
// Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado
|
||||
const enganche = 0.025 * precioLista - apartado
|
||||
// Excel COTIZADOR!M19 — pago final 90% del precio final c/desc
|
||||
const pagoFinal = 0.9 * precioFinal
|
||||
// Excel COTIZADOR!M18 — remanente a mensualidades
|
||||
const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
|
||||
return {
|
||||
tipo: 'CREDITO_BANCARIO',
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: mensualidadesTotal / n,
|
||||
mensualidadesTotal,
|
||||
pagoFinal,
|
||||
}
|
||||
}
|
||||
|
||||
/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */
|
||||
function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
// Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado
|
||||
const enganche = 0.025 * precioLista - apartado
|
||||
// Excel COTIZADOR!M28 — pago final 95% del precio final c/desc
|
||||
const pagoFinal = 0.95 * precioFinal
|
||||
// Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual.
|
||||
const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||
return {
|
||||
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: mensualidadesTotal / n,
|
||||
mensualidadesTotal,
|
||||
pagoFinal,
|
||||
}
|
||||
}
|
||||
|
||||
/** PROPUESTA 3 — CONTADO (sin mensualidades) */
|
||||
function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
// Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado
|
||||
const enganche = 0.9 * precioFinal - apartado
|
||||
// Excel COTIZADOR!M37 — pago final 10% del precio final c/desc
|
||||
const pagoFinal = 0.1 * precioFinal
|
||||
return {
|
||||
tipo: 'CONTADO',
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: null, // N/A
|
||||
mensualidadesTotal: null, // N/A
|
||||
pagoFinal,
|
||||
}
|
||||
}
|
||||
|
||||
/** PROPUESTA 4 — SEMI CONTADO */
|
||||
function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
// Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado
|
||||
const enganche = 0.5 * precioFinal - apartado
|
||||
// Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1)
|
||||
const mensualidadesTotal = precioFinal * 0.4
|
||||
// Excel COTIZADOR!M47 — pago final 10% del precio final c/desc
|
||||
const pagoFinal = 0.1 * precioFinal
|
||||
return {
|
||||
tipo: 'SEMI_CONTADO',
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: mensualidadesTotal / n,
|
||||
mensualidadesTotal,
|
||||
pagoFinal,
|
||||
}
|
||||
}
|
||||
|
||||
/** Precio de lista según si incluye cocina y closet (acabados) */
|
||||
export function obtenerPrecioLista(unidad: UnidadCotizable, acabados: boolean): number {
|
||||
return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados
|
||||
}
|
||||
|
||||
/**
|
||||
* Total pagado por el cliente en una propuesta:
|
||||
* apartado + enganche (neto del apartado) + mensualidades + pago final.
|
||||
*/
|
||||
export function totalPagado(p: PropuestaResultado, apartado: number): number {
|
||||
return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal
|
||||
}
|
||||
|
||||
/** Índice del menor total pagado; en empate gana el primero. -1 si la lista está vacía */
|
||||
export function indiceMenorTotal(totales: number[]): number {
|
||||
if (totales.length === 0) return -1
|
||||
let indice = 0
|
||||
for (let i = 1; i < totales.length; i++) {
|
||||
const actual = totales[i]
|
||||
const mejor = totales[indice]
|
||||
if (actual !== undefined && mejor !== undefined && actual < mejor) indice = i
|
||||
}
|
||||
return indice
|
||||
}
|
||||
|
||||
/** Calcula las 4 propuestas comerciales */
|
||||
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
|
||||
const { unidad, acabados, apartado, descuentos } = input
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = input.numMeses ?? calcularNumMensualidades(unidad.fechaEntrega)
|
||||
return [
|
||||
calcularBancario(precioLista, apartado, descuentos.creditoBancario, n),
|
||||
calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n),
|
||||
calcularContado(precioLista, apartado, descuentos.contado, n),
|
||||
calcularSemiContado(precioLista, apartado, descuentos.semiContado, n),
|
||||
]
|
||||
}
|
||||
|
||||
/** Mapa entre el nombre del API (body) y el nombre interno de la propuesta */
|
||||
export const TIPO_PROPUESTA_API = {
|
||||
BANCARIO: 'CREDITO_BANCARIO',
|
||||
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
|
||||
CONTADO: 'CONTADO',
|
||||
SEMI_CONTADO: 'SEMI_CONTADO',
|
||||
} as const
|
||||
|
||||
export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
|
||||
|
||||
const DESCUENTO_DEFAULT_POR_TIPO: Record<TipoPropuestaApi, number> = {
|
||||
BANCARIO: DESCUENTOS_DEFAULT.creditoBancario,
|
||||
INFONAVIT: DESCUENTOS_DEFAULT.creditoInfonavit,
|
||||
CONTADO: DESCUENTOS_DEFAULT.contado,
|
||||
SEMI_CONTADO: DESCUENTOS_DEFAULT.semiContado,
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar.
|
||||
* `descuento` opcional (fracción); si no viene, usa el default del tipo.
|
||||
*/
|
||||
export function calcularPropuesta(
|
||||
unidad: UnidadCotizable,
|
||||
tipo: TipoPropuestaApi,
|
||||
acabados: boolean,
|
||||
apartado: number = APARTADO_DEFAULT,
|
||||
descuento?: number,
|
||||
): PropuestaResultado {
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = calcularNumMensualidades(unidad.fechaEntrega)
|
||||
const d = descuento ?? DESCUENTO_DEFAULT_POR_TIPO[tipo]
|
||||
switch (tipo) {
|
||||
case 'BANCARIO':
|
||||
return calcularBancario(precioLista, apartado, d, n)
|
||||
case 'INFONAVIT':
|
||||
return calcularInfonavit(precioLista, apartado, d, n)
|
||||
case 'CONTADO':
|
||||
return calcularContado(precioLista, apartado, d, n)
|
||||
case 'SEMI_CONTADO':
|
||||
return calcularSemiContado(precioLista, apartado, d, n)
|
||||
}
|
||||
}
|
||||
235
api-gateway/src/services/monday.service.test.ts
Normal file
235
api-gateway/src/services/monday.service.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MondayService } from './monday.service.js'
|
||||
|
||||
// Columnas con slugs autogenerados (como las reales de Monday); el servicio
|
||||
// debe resolverlas por TÍTULO, nunca hardcodear el id.
|
||||
const COLUMNAS_UNIDADES = [
|
||||
{ id: 'text_mm6eaaa', title: 'Desarrollo', type: 'text' },
|
||||
{ id: 'text_mm6ebbb', title: 'Modelo', type: 'text' },
|
||||
{ id: 'date_mm6eccc', title: 'Fecha entrega', type: 'date' },
|
||||
{ id: 'numeric_mm6eddd', title: 'Sup. construcción', type: 'numbers' },
|
||||
{ id: 'numeric_mm6eeee', title: 'Sup. excedente', type: 'numbers' },
|
||||
{ id: 'numeric_mm6efff', title: 'Sup. estacionamiento', type: 'numbers' },
|
||||
{ id: 'numeric_mm6eggg', title: 'Precio sin acabados', type: 'numbers' },
|
||||
{ id: 'numeric_mm6ehhh', title: 'Precio con acabados', type: 'numbers' },
|
||||
{ id: 'color_mm6eiii', title: 'Disponibilidad', type: 'status' },
|
||||
]
|
||||
|
||||
function itemUnidad(id: string, codigo: string, disponibilidad: string) {
|
||||
return {
|
||||
id,
|
||||
name: codigo,
|
||||
column_values: [
|
||||
{ id: 'text_mm6eaaa', text: 'SENDA', value: null },
|
||||
{ id: 'text_mm6ebbb', text: '1R', value: null },
|
||||
{ id: 'date_mm6eccc', text: '2027-10-08', value: null },
|
||||
{ id: 'numeric_mm6eddd', text: '69.485', value: null },
|
||||
{ id: 'numeric_mm6eeee', text: '0', value: null },
|
||||
{ id: 'numeric_mm6efff', text: '12.5', value: null },
|
||||
{ id: 'numeric_mm6eggg', text: '4415000', value: null },
|
||||
{ id: 'numeric_mm6ehhh', text: '4579000', value: null },
|
||||
{ id: 'color_mm6eiii', text: disponibilidad, value: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const respuestaOk = (data: unknown): Response =>
|
||||
new Response(JSON.stringify({ data }), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
interface LlamadaFetch {
|
||||
body: { query: string; variables: Record<string, unknown> }
|
||||
}
|
||||
|
||||
/** Instala un fetch mockeado que responde según el query; devuelve las llamadas registradas */
|
||||
function mockearFetch(handler: (body: LlamadaFetch['body']) => Response): LlamadaFetch[] {
|
||||
const llamadas: LlamadaFetch[] = []
|
||||
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
|
||||
const body = JSON.parse(init.body) as LlamadaFetch['body']
|
||||
llamadas.push({ body })
|
||||
return handler(body)
|
||||
})
|
||||
return llamadas
|
||||
}
|
||||
|
||||
const crearServicio = (): MondayService =>
|
||||
new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 })
|
||||
|
||||
describe('MondayService', () => {
|
||||
beforeEach(() => vi.useRealTimers())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('resuelve column IDs por TÍTULO y mapea unidades al tipo del cotizador', async () => {
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades).toHaveLength(1)
|
||||
expect(unidades[0]).toEqual({
|
||||
codigo: 'U-101',
|
||||
modelo: '1R',
|
||||
fechaEntrega: '2027-10-08',
|
||||
supConstruccion: 69.485,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 12.5,
|
||||
precioSinAcabados: 4415000,
|
||||
precioConAcabados: 4579000,
|
||||
status: 'DISPONIBLE',
|
||||
desarrollo: 'SENDA',
|
||||
})
|
||||
})
|
||||
|
||||
it('excluye items placeholder sin datos (p. ej. "Task 1" default de Monday)', async () => {
|
||||
const placeholder = {
|
||||
id: '99',
|
||||
name: 'Task 1',
|
||||
column_values: COLUMNAS_UNIDADES.map((c) => ({ id: c.id, text: '', value: null })),
|
||||
}
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE'), placeholder] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades.map((u) => u.codigo)).toEqual(['U-101'])
|
||||
})
|
||||
|
||||
it('pagina con cursor hasta agotar las páginas', async () => {
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
if (body.variables['cursor'] === 'pagina-2') {
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('2', 'U-102', 'APARTADA')] } }],
|
||||
})
|
||||
}
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: 'pagina-2', items: [itemUnidad('1', 'U-101', 'VENDIDA')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades.map((u) => u.codigo)).toEqual(['U-101', 'U-102'])
|
||||
expect(unidades[0]?.status).toBe('VENDIDA')
|
||||
expect(unidades[1]?.status).toBe('APARTADA')
|
||||
// 1 llamada de columnas + 2 de items_page
|
||||
expect(llamadas).toHaveLength(3)
|
||||
expect(llamadas[2]?.body.variables['cursor']).toBe('pagina-2')
|
||||
})
|
||||
|
||||
it('usa el cache de 5 minutos para no repetir llamadas', async () => {
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
const svc = crearServicio()
|
||||
await svc.getUnidades()
|
||||
await svc.getUnidades()
|
||||
expect(llamadas).toHaveLength(2) // columnas + una sola página de items
|
||||
})
|
||||
|
||||
it('reintenta ante HTTP 429 con backoff y logra responder', async () => {
|
||||
let intentos = 0
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
intentos++
|
||||
if (intentos === 1) return new Response('rate limited', { status: 429 })
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades).toHaveLength(1)
|
||||
expect(intentos).toBe(2)
|
||||
})
|
||||
|
||||
it('reintenta ante error de complejidad (error_code COMPLEXITY_BUDGET_EXCEEDED)', async () => {
|
||||
let intentos = 0
|
||||
mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
|
||||
intentos++
|
||||
if (intentos < 3) {
|
||||
return new Response(
|
||||
JSON.stringify({ error_code: 'COMPLEXITY_BUDGET_EXCEEDED', error_message: 'Complexity budget exceeded' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
return respuestaOk({
|
||||
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
|
||||
})
|
||||
})
|
||||
|
||||
const unidades = await crearServicio().getUnidades()
|
||||
expect(unidades).toHaveLength(1)
|
||||
expect(intentos).toBe(3)
|
||||
})
|
||||
|
||||
it('agota los reintentos y lanza error', async () => {
|
||||
mockearFetch(() => new Response('rate limited', { status: 429 }))
|
||||
await expect(crearServicio().getUnidades()).rejects.toThrow(/no respondió/)
|
||||
})
|
||||
|
||||
it('lanza en errores GraphQL no retryables sin reintentar', async () => {
|
||||
let llamadasTotal = 0
|
||||
mockearFetch(() => {
|
||||
llamadasTotal++
|
||||
return new Response(JSON.stringify({ errors: [{ message: 'Invalid board ID' }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
await expect(crearServicio().getUnidades()).rejects.toThrow(/Invalid board ID/)
|
||||
expect(llamadasTotal).toBe(1)
|
||||
})
|
||||
|
||||
it('crearOportunidad envía column_values como JSON.stringify en variables', async () => {
|
||||
const columnasOportunidades = [
|
||||
{ id: 'text_u1', title: 'Unidad', type: 'text' },
|
||||
{ id: 'text_e1', title: 'Cliente email', type: 'text' },
|
||||
{ id: 'text_t1', title: 'Cliente teléfono', type: 'text' },
|
||||
{ id: 'color_tp', title: 'Tipo propuesta', type: 'status' },
|
||||
{ id: 'numeric_pl', title: 'Precio de lista', type: 'numbers' },
|
||||
{ id: 'numeric_dp', title: 'Descuento %', type: 'numbers' },
|
||||
{ id: 'numeric_pf', title: 'Precio final', type: 'numbers' },
|
||||
{ id: 'date_fc', title: 'Fecha cotización', type: 'date' },
|
||||
{ id: 'color_es', title: 'Estado', type: 'status' },
|
||||
]
|
||||
const llamadas = mockearFetch((body) => {
|
||||
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasOportunidades }] })
|
||||
return respuestaOk({ create_item: { id: '987654321' } })
|
||||
})
|
||||
|
||||
const id = await crearServicio().crearOportunidad({
|
||||
clienteNombre: 'Juan Pérez',
|
||||
unidadCodigo: 'U-101',
|
||||
tipoPropuestaLabel: 'BANCARIO',
|
||||
precioLista: 4579000,
|
||||
descuentoPct: 0,
|
||||
precioFinal: 4579000,
|
||||
fechaCotizacion: '2026-08-21',
|
||||
estadoLabel: 'COTIZADA',
|
||||
clienteEmail: 'juan@example.com',
|
||||
})
|
||||
expect(id).toBe('987654321')
|
||||
|
||||
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
||||
expect(mutacion).toBeDefined()
|
||||
const columnValues = mutacion?.body.variables['columnValues']
|
||||
expect(typeof columnValues).toBe('string') // JSON.stringify, nunca interpolado en el query
|
||||
const parsed = JSON.parse(columnValues as string) as Record<string, unknown>
|
||||
expect(parsed['text_u1']).toBe('U-101')
|
||||
expect(parsed['text_e1']).toBe('juan@example.com')
|
||||
expect(parsed['numeric_pl']).toBe(4579000)
|
||||
expect(parsed['color_tp']).toEqual({ label: 'BANCARIO' })
|
||||
expect(parsed['color_es']).toEqual({ label: 'COTIZADA' })
|
||||
expect(parsed['date_fc']).toEqual({ date: '2026-08-21' })
|
||||
expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías
|
||||
})
|
||||
})
|
||||
391
api-gateway/src/services/monday.service.ts
Normal file
391
api-gateway/src/services/monday.service.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { config } from '../config/index.js'
|
||||
import type { UnidadCotizable } from './cotizacion.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/** Unidad tal como la consume el API (y el frontend del cotizador) */
|
||||
export type Unidad = UnidadCotizable
|
||||
|
||||
interface ColumnaMeta {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface ColumnValue {
|
||||
id: string
|
||||
text: string | null
|
||||
value: string | null
|
||||
}
|
||||
|
||||
interface ItemMonday {
|
||||
id: string
|
||||
name: string
|
||||
column_values: ColumnValue[]
|
||||
}
|
||||
|
||||
interface GraphQLErrorItem {
|
||||
message: string
|
||||
extensions?: { code?: string }
|
||||
}
|
||||
|
||||
interface GraphQLResponse<T> {
|
||||
data?: T
|
||||
errors?: GraphQLErrorItem[]
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */
|
||||
class MondayRetryableError extends Error {}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
|
||||
|
||||
export interface MondayServiceOptions {
|
||||
apiKey?: string
|
||||
apiUrl?: string
|
||||
maxReintentos?: number
|
||||
baseDelayMs?: number
|
||||
}
|
||||
|
||||
export class MondayService {
|
||||
private readonly apiKey: string
|
||||
private readonly apiUrl: string
|
||||
private readonly maxReintentos: number
|
||||
private readonly baseDelayMs: number
|
||||
|
||||
// Cache de metadatos de columnas: boardId -> (título -> meta)
|
||||
private readonly columnasCache = new Map<string, Map<string, ColumnaMeta>>()
|
||||
// Cache de unidades + mapa codigo -> itemId (para escrituras)
|
||||
private unidadesCache: { unidades: Unidad[]; itemIds: Map<string, string>; expira: number } | null = null
|
||||
|
||||
constructor(opts: MondayServiceOptions = {}) {
|
||||
this.apiKey = opts.apiKey ?? config.monday.apiKey
|
||||
this.apiUrl = opts.apiUrl ?? config.monday.apiUrl
|
||||
this.maxReintentos = opts.maxReintentos ?? 3
|
||||
this.baseDelayMs = opts.baseDelayMs ?? 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper GraphQL contra la API de Monday.
|
||||
* - Headers Authorization / API-Version.
|
||||
* - Lanza en errores GraphQL.
|
||||
* - Retry con backoff exponencial ante 429, errores de complejidad y 5xx
|
||||
* (máx `maxReintentos` reintentos).
|
||||
*/
|
||||
async graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||
let ultimoError: Error | null = null
|
||||
for (let intento = 0; intento <= this.maxReintentos; intento++) {
|
||||
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
|
||||
try {
|
||||
return await this.graphqlOnce<T>(query, variables)
|
||||
} catch (err) {
|
||||
if (!(err instanceof MondayRetryableError)) throw err
|
||||
ultimoError = err
|
||||
}
|
||||
}
|
||||
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
|
||||
}
|
||||
|
||||
private async graphqlOnce<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.apiKey,
|
||||
'API-Version': config.monday.apiVersion,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
|
||||
if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)')
|
||||
if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`)
|
||||
if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`)
|
||||
|
||||
const body = (await res.json()) as GraphQLResponse<T>
|
||||
|
||||
// Monday devuelve errores de complejidad como 200 con error_code
|
||||
if (body.error_code) {
|
||||
if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) {
|
||||
throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`)
|
||||
}
|
||||
throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`)
|
||||
}
|
||||
if (body.errors?.length) {
|
||||
const msg = body.errors.map((e) => e.message).join('; ')
|
||||
const esComplejidad = body.errors.some(
|
||||
(e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''),
|
||||
)
|
||||
if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`)
|
||||
throw new AppError(502, `Errores GraphQL de Monday: ${msg}`)
|
||||
}
|
||||
if (!body.data) throw new AppError(502, 'Monday respondió sin data')
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */
|
||||
private async columnasDe(boardId: string): Promise<Map<string, ColumnaMeta>> {
|
||||
const cached = this.columnasCache.get(boardId)
|
||||
if (cached) return cached
|
||||
const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>(
|
||||
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
|
||||
{ ids: [boardId] },
|
||||
)
|
||||
const columnas = data.boards[0]?.columns ?? []
|
||||
const mapa = new Map<string, ColumnaMeta>(columnas.map((c) => [c.title, c]))
|
||||
this.columnasCache.set(boardId, mapa)
|
||||
return mapa
|
||||
}
|
||||
|
||||
/** Resuelve el column ID por título exacto; lanza si no existe en el tablero */
|
||||
private async columnaId(boardId: string, titulo: string): Promise<ColumnaMeta> {
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const col = cols.get(titulo)
|
||||
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
|
||||
return col
|
||||
}
|
||||
|
||||
/** Ping barato para /api/health */
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
const data = await this.graphql<{ boards: { id: string }[] }>(
|
||||
`query ($ids: [ID!]) { boards(ids: $ids) { id } }`,
|
||||
{ ids: [config.monday.boards.unidades] },
|
||||
)
|
||||
return (data.boards?.length ?? 0) > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */
|
||||
invalidarCacheUnidades(): void {
|
||||
this.unidadesCache = null
|
||||
}
|
||||
|
||||
/** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */
|
||||
async getUnidades(): Promise<Unidad[]> {
|
||||
const ahora = Date.now()
|
||||
if (this.unidadesCache && this.unidadesCache.expira > ahora) {
|
||||
return this.unidadesCache.unidades
|
||||
}
|
||||
const boardId = config.monday.boards.unidades
|
||||
const cols = await this.columnasDe(boardId)
|
||||
|
||||
const items: ItemMonday[] = []
|
||||
let cursor: string | null = null
|
||||
do {
|
||||
// La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página)
|
||||
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
|
||||
cursor === null
|
||||
? await this.graphql(
|
||||
`query ($ids: [ID!]) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 500) { cursor items { id name column_values { id text value } } }
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId] },
|
||||
)
|
||||
: await this.graphql(
|
||||
`query ($ids: [ID!], $cursor: String!) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } }
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId], cursor },
|
||||
)
|
||||
const page = data.boards[0]?.items_page
|
||||
if (!page) break
|
||||
items.push(...page.items)
|
||||
cursor = page.cursor
|
||||
} while (cursor !== null)
|
||||
|
||||
const itemIds = new Map<string, string>()
|
||||
const unidades: Unidad[] = []
|
||||
for (const item of items) {
|
||||
const unidad = this.mapearUnidad(item, cols)
|
||||
// Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de
|
||||
// Monday) no son inventario: se excluyen del listado
|
||||
if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue
|
||||
itemIds.set(item.name, item.id)
|
||||
unidades.push(unidad)
|
||||
}
|
||||
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
|
||||
return unidades
|
||||
}
|
||||
|
||||
/** Una unidad por código (U-101, U-310...); null si no existe */
|
||||
async getUnidad(codigo: string): Promise<Unidad | null> {
|
||||
const unidades = await this.getUnidades()
|
||||
return unidades.find((u) => u.codigo === codigo) ?? null
|
||||
}
|
||||
|
||||
private mapearUnidad(item: ItemMonday, cols: Map<string, ColumnaMeta>): Unidad {
|
||||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||
const textoDe = (titulo: string): string => {
|
||||
const col = cols.get(titulo)
|
||||
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`)
|
||||
return porId.get(col.id)?.text ?? ''
|
||||
}
|
||||
const numeroDe = (titulo: string): number => {
|
||||
const n = Number(textoDe(titulo))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const status = textoDe('Disponibilidad').toUpperCase()
|
||||
return {
|
||||
codigo: item.name,
|
||||
modelo: textoDe('Modelo'),
|
||||
fechaEntrega: textoDe('Fecha entrega'),
|
||||
supEstacionamiento: numeroDe('Sup. estacionamiento'),
|
||||
supConstruccion: numeroDe('Sup. construcción'),
|
||||
supExcedente: numeroDe('Sup. excedente'),
|
||||
precioSinAcabados: numeroDe('Precio sin acabados'),
|
||||
precioConAcabados: numeroDe('Precio con acabados'),
|
||||
status: status === 'APARTADA' || status === 'VENDIDA' ? status : 'DISPONIBLE',
|
||||
desarrollo: textoDe('Desarrollo'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializa un valor crudo al formato que espera el tipo de columna de Monday.
|
||||
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
|
||||
* template literals dentro del query).
|
||||
*/
|
||||
private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown {
|
||||
switch (col.type) {
|
||||
case 'status':
|
||||
return { label: String(crudo) }
|
||||
case 'date':
|
||||
return { date: String(crudo) }
|
||||
case 'numbers':
|
||||
return typeof crudo === 'number' ? crudo : Number(crudo)
|
||||
case 'email':
|
||||
return { email: String(crudo), text: String(crudo) }
|
||||
case 'phone':
|
||||
return { phone: String(crudo), countryShortName: 'MX' }
|
||||
default:
|
||||
return String(crudo)
|
||||
}
|
||||
}
|
||||
|
||||
private async construirColumnValues(
|
||||
boardId: string,
|
||||
valores: Record<string, string | number | undefined>,
|
||||
): Promise<string> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [titulo, crudo] of Object.entries(valores)) {
|
||||
if (crudo === undefined || crudo === '') continue
|
||||
const col = await this.columnaId(boardId, titulo)
|
||||
out[col.id] = this.valorPorTipo(col, crudo)
|
||||
}
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
|
||||
private async crearItem(boardId: string, nombre: string, columnValues: string): Promise<string> {
|
||||
const data = await this.graphql<{ create_item: { id: string } }>(
|
||||
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
|
||||
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id }
|
||||
}`,
|
||||
{ boardId, itemName: nombre, columnValues },
|
||||
)
|
||||
return data.create_item.id
|
||||
}
|
||||
|
||||
// ---------- Escrituras de negocio ----------
|
||||
|
||||
async crearOportunidad(datos: DatosOportunidad): Promise<string> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Unidad: datos.unidadCodigo,
|
||||
'Cliente email': datos.clienteEmail,
|
||||
'Cliente teléfono': datos.clienteTelefono,
|
||||
'Tipo propuesta': datos.tipoPropuestaLabel,
|
||||
'Precio de lista': datos.precioLista,
|
||||
'Descuento %': datos.descuentoPct,
|
||||
'Precio final': datos.precioFinal,
|
||||
'Fecha cotización': datos.fechaCotizacion,
|
||||
Estado: datos.estadoLabel,
|
||||
})
|
||||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||||
}
|
||||
|
||||
async crearContacto(datos: DatosContacto): Promise<string> {
|
||||
const boardId = config.monday.boards.contactos
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Teléfono: datos.telefono,
|
||||
Email: datos.email,
|
||||
CURP: datos.curp,
|
||||
RFC: datos.rfc,
|
||||
Dirección: datos.direccion,
|
||||
Estado: datos.estadoLabel,
|
||||
})
|
||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||
}
|
||||
|
||||
async crearApartado(datos: DatosApartado): Promise<string> {
|
||||
const boardId = config.monday.boards.apartado
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Unidad: datos.unidadCodigo,
|
||||
'Monto apartado': datos.montoApartado,
|
||||
'Fecha apartado': datos.fechaApartado,
|
||||
'Fecha límite': datos.fechaLimite,
|
||||
Estado: datos.estadoLabel,
|
||||
})
|
||||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||||
}
|
||||
|
||||
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
|
||||
const boardId = config.monday.boards.unidades
|
||||
// Asegura cache fresco para resolver el itemId
|
||||
await this.getUnidades()
|
||||
const itemId = this.unidadesCache?.itemIds.get(codigo)
|
||||
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
|
||||
const col = await this.columnaId(boardId, 'Disponibilidad')
|
||||
await this.graphql(
|
||||
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
||||
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
||||
}`,
|
||||
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
|
||||
)
|
||||
this.invalidarCacheUnidades()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DTOs de escritura ----------
|
||||
|
||||
export interface DatosOportunidad {
|
||||
clienteNombre: string
|
||||
unidadCodigo: string
|
||||
tipoPropuestaLabel: string
|
||||
precioLista: number
|
||||
descuentoPct: number // porcentaje (2 = 2%)
|
||||
precioFinal: number
|
||||
fechaCotizacion: string // 'YYYY-MM-DD'
|
||||
estadoLabel: string // p. ej. 'COTIZADA'
|
||||
clienteEmail?: string
|
||||
clienteTelefono?: string
|
||||
}
|
||||
|
||||
export interface DatosContacto {
|
||||
nombre: string
|
||||
telefono?: string
|
||||
email?: string
|
||||
curp?: string
|
||||
rfc?: string
|
||||
direccion?: string
|
||||
estadoLabel: string // p. ej. 'CLIENTE'
|
||||
}
|
||||
|
||||
export interface DatosApartado {
|
||||
clienteNombre: string
|
||||
unidadCodigo: string
|
||||
montoApartado?: number
|
||||
fechaApartado: string // 'YYYY-MM-DD'
|
||||
fechaLimite: string // 'YYYY-MM-DD'
|
||||
estadoLabel: string // p. ej. 'VIGENTE'
|
||||
}
|
||||
|
||||
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
||||
export const mondayService = new MondayService()
|
||||
|
||||
4
api-gateway/src/services/prisma.ts
Normal file
4
api-gateway/src/services/prisma.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
// Cliente único compartido (Prisma gestiona el pool internamente)
|
||||
export const prisma = new PrismaClient()
|
||||
24
api-gateway/src/utils/errors.ts
Normal file
24
api-gateway/src/utils/errors.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Error con HTTP status para que el error handler lo traduzca a respuesta */
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = 'Recurso no encontrado') {
|
||||
super(404, message)
|
||||
this.name = 'NotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(400, message)
|
||||
this.name = 'BadRequestError'
|
||||
}
|
||||
}
|
||||
23
api-gateway/tsconfig.json
Normal file
23
api-gateway/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": false,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "node_modules", "dist"]
|
||||
}
|
||||
17
api-gateway/vitest.config.ts
Normal file
17
api-gateway/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
// config/index.ts exige estas vars al importarse; los tests nunca llaman a Monday real
|
||||
MONDAY_API_KEY: 'test-key',
|
||||
MONDAY_BOARD_CONTACTOS: '111',
|
||||
MONDAY_BOARD_DESARROLLOS: '222',
|
||||
MONDAY_BOARD_UNIDADES: '333',
|
||||
MONDAY_BOARD_LEADS: '444',
|
||||
MONDAY_BOARD_OPORTUNIDADES: '555',
|
||||
MONDAY_BOARD_APARTADO: '666',
|
||||
MONDAY_BOARD_VENTAS: '777',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user