feat(captura): módulo de captura por departamentos

Backend: tabla CapturaDepartamento, 5 departamentos, 7 desarrollos,
service CRUD + importador Excel + 6 endpoints.
Frontend: tabla editable inline, tabs por dept, resumen avance,
paginación, filtro estatus, permisos por rol, dark mode.
746 API tests + 42 landing tests.
This commit is contained in:
2026-09-07 07:59:14 +00:00
parent 7d19e0da38
commit c2a8f4e929
21 changed files with 2496 additions and 7 deletions

View File

@@ -31,7 +31,8 @@
"prisma": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"xlsx": "^0.18.5"
},
"engines": {
"node": ">=22"
@@ -1258,6 +1259,16 @@
"node": ">= 0.6"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1447,6 +1458,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -1500,6 +1525,16 @@
"consola": "^3.2.3"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -1589,6 +1624,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1955,6 +2003,16 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -3040,6 +3098,19 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -3481,12 +3552,54 @@
"node": ">=8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -45,6 +45,7 @@
"prisma": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"xlsx": "^0.18.5"
}
}

View File

@@ -0,0 +1,19 @@
-- Tabla de captura por departamentos.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
-- CreateTable CapturaDepartamento
CREATE TABLE IF NOT EXISTS "CapturaDepartamento" (
"id" TEXT NOT NULL PRIMARY KEY,
"desarrollo" TEXT NOT NULL,
"unidadCodigo" TEXT NOT NULL,
"departamento" TEXT NOT NULL,
"campo" TEXT NOT NULL,
"valor" TEXT,
"tipo" TEXT NOT NULL DEFAULT 'text',
"usuarioId" TEXT NOT NULL,
"capturadoEn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actualizadoEn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS "CapturaDepartamento_desarrollo_unidadCodigo_departamento_campo_key" ON "CapturaDepartamento"("desarrollo", "unidadCodigo", "departamento", "campo");
CREATE INDEX IF NOT EXISTS "CapturaDepartamento_desarrollo_idx" ON "CapturaDepartamento"("desarrollo");
CREATE INDEX IF NOT EXISTS "CapturaDepartamento_departamento_idx" ON "CapturaDepartamento"("departamento");

View File

@@ -412,6 +412,25 @@ model AuditoriaLog {
@@index([createdAt])
}
// Captura por departamentos: cada campo de cada unidad se registra como fila.
// Unique constraint: desarrollo + unidadCodigo + departamento + campo.
model CapturaDepartamento {
id String @id @default(uuid())
desarrollo String
unidadCodigo String
departamento String
campo String
valor String?
tipo String @default("text")
usuarioId String
capturadoEn DateTime @default(now())
actualizadoEn DateTime @updatedAt
@@unique([desarrollo, unidadCodigo, departamento, campo])
@@index([desarrollo])
@@index([departamento])
}
// Suscripciones push de los navegadores (Web Push API)
model PushSubscription {
id String @id @default(uuid())

View File

@@ -0,0 +1,277 @@
#!/usr/bin/env node
/**
* Importa el archivo maestro de estatus de venta (Reporte_EstatusVenta.xlsx)
* a la tabla CapturaDepartamento.
*
* Uso:
* node scripts/importar-maestro.js [ruta.xlsx] [usuarioId]
*
* - Lee cada hoja de desarrollo (SENDA, ATELIER, ESTANZA, etc.)
* - Encuentra la fila de headers (la que tiene "CLIENTE" o "ESTATUS")
* - Mapea columnas a campos del config
* - Para cada fila de datos, crea registros via upsert (idempotente)
* - Log de progreso
*/
import { PrismaClient } from '@prisma/client'
import XLSX from 'xlsx'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const prisma = new PrismaClient()
// ── Desarrollos y sus nombres de hoja esperados ──────────────────────
const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO']
// Mapeo de headers del Excel → campo del config
// Normaliza: quita acentos, mayúsculas, espacios → nombre de campo
const HEADER_MAP = {
// OBRA
'CERTIFICACIÓN GAS': { departamento: 'OBRA', campo: 'certificacion_gas', tipo: 'boolean' },
'CERTIFICACION GAS': { departamento: 'OBRA', campo: 'certificacion_gas', tipo: 'boolean' },
'TO Y C4-FIANZA': { departamento: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean' },
'TO C4-FIANZA': { departamento: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean' },
'RÉGIMEN INSCRITO': { departamento: 'OBRA', campo: 'regimen_inscrito', tipo: 'boolean' },
'REGIMEN INSCRITO': { departamento: 'OBRA', campo: 'regimen_inscrito', tipo: 'boolean' },
'NO. OFICIAL': { departamento: 'OBRA', campo: 'no_oficial', tipo: 'boolean' },
'NO OFICIAL': { departamento: 'OBRA', campo: 'no_oficial', tipo: 'boolean' },
'CLAVE CATASTRAL': { departamento: 'OBRA', campo: 'clave_catastral', tipo: 'boolean' },
'LISTAS PARA AVALUO': { departamento: 'OBRA', campo: 'listas_avaluo', tipo: 'boolean' },
'HABILITABILIDAD': { departamento: 'OBRA', campo: 'habitabilidad', tipo: 'date' },
'COMENTARIOS OBRA': { departamento: 'OBRA', campo: 'comentarios_obra', tipo: 'text' },
'COMENTARIOS': { departamento: 'OBRA', campo: 'comentarios_obra', tipo: 'text' },
// VENTAS
'CLIENTE': { departamento: 'VENTAS', campo: 'cliente_id', tipo: 'text' },
'CLIENTE ID': { departamento: 'VENTAS', campo: 'cliente_id', tipo: 'text' },
'NOMBRE CLIENTE': { departamento: 'VENTAS', campo: 'nombre_cliente', tipo: 'text' },
'NOMBRE': { departamento: 'VENTAS', campo: 'nombre_cliente', tipo: 'text' },
'UBICACIÓN': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' },
'UBICACION': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' },
'UBICACIÓN/UNIDAD': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' },
'MODELO': { departamento: 'VENTAS', campo: 'modelo', tipo: 'text' },
'TIPO CRÉDITO': { departamento: 'VENTAS', campo: 'tipo_credito', tipo: 'select' },
'TIPO CREDITO': { departamento: 'VENTAS', campo: 'tipo_credito', tipo: 'select' },
'ESTATUS': { departamento: 'VENTAS', campo: 'estatus', tipo: 'select' },
'FECHA APARTADO': { departamento: 'VENTAS', campo: 'fecha_apartado', tipo: 'date' },
'FECHA VENTA': { departamento: 'VENTAS', campo: 'fecha_venta', tipo: 'date' },
'FECHA VENCIMIENTO': { departamento: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' },
'FECHA VENCIMIENTO CONTRATO': { departamento: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' },
'PRECIO VENTA': { departamento: 'VENTAS', campo: 'precio_venta', tipo: 'number' },
// ESCRITURACION
'FECHA AUTORIZACIÓN': { departamento: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' },
'FECHA AUTORIZACION': { departamento: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' },
'FECHA FIRMA': { departamento: 'ESCRITURACION', campo: 'fecha_firma', tipo: 'date' },
'NOTARÍA': { departamento: 'ESCRITURACION', campo: 'notaria', tipo: 'text' },
'NOTARIA': { departamento: 'ESCRITURACION', campo: 'notaria', tipo: 'text' },
'FECHA PAGO BANCO': { departamento: 'ESCRITURACION', campo: 'fecha_pago_banco', tipo: 'date' },
'FECHA PAGO INFONAVIT': { departamento: 'ESCRITURACION', campo: 'fecha_pago_infonavit', tipo: 'date' },
'FECHA PAGO FOVISSSTE': { departamento: 'ESCRITURACION', campo: 'fecha_pago_fovissste', tipo: 'date' },
// FINANZAS
'OTROS INGRESOS': { departamento: 'FINANZAS', campo: 'otros_ingresos', tipo: 'number' },
'PAGADO TRANSFERENCIA': { departamento: 'FINANZAS', campo: 'pagado_transferencia', tipo: 'number' },
'INFONAVIT': { departamento: 'FINANZAS', campo: 'infonavit', tipo: 'number' },
'BANCARIO': { departamento: 'FINANZAS', campo: 'bancario', tipo: 'number' },
'FOVISSSTE': { departamento: 'FINANZAS', campo: 'fovissste', tipo: 'number' },
'CRÉDITO PUENTE': { departamento: 'FINANZAS', campo: 'credito_puente', tipo: 'number' },
'CREDITO PUENTE': { departamento: 'FINANZAS', campo: 'credito_puente', tipo: 'number' },
'SALDO A FAVOR': { departamento: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' },
'SALDO A FAVOR': { departamento: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' },
// POSTVENTA
'ENTREGA A SAC': { departamento: 'POSTVENTA', campo: 'entrega_sac', tipo: 'boolean' },
'ENTREGA SAC': { departamento: 'POSTVENTA', campo: 'entrega_sac', tipo: 'boolean' },
'COMENTARIOS ENTREGA': { departamento: 'POSTVENTA', campo: 'comentarios_entrega', tipo: 'text' },
}
// ── Helpers ──────────────────────────────────────────────────────────
function normalizeHeader(h) {
return String(h ?? '')
.trim()
.toUpperCase()
.replace(/\s+/g, ' ')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // quitar acentos
}
function esBooleano(valor) {
if (valor == null) return false
const s = String(valor).trim().toUpperCase()
return s === 'TRUE' || s === 'SI' || s === 'SÍ' || s === '1' || s === 'X' || s === '✓'
}
function parseFecha(valor) {
if (valor == null) return null
if (valor instanceof Date) return valor.toISOString()
const s = String(valor).trim()
if (!s) return null
// Intentar parsear como fecha
const d = new Date(s)
return isNaN(d.getTime()) ? null : d.toISOString()
}
function parseNumero(valor) {
if (valor == null) return null
const s = String(valor).trim().replace(/[$,]/g, '')
if (!s) return null
const n = parseFloat(s)
return isNaN(n) ? null : String(n)
}
function parseValor(valor, tipo) {
if (valor == null || String(valor).trim() === '') return null
switch (tipo) {
case 'boolean':
return esBooleano(valor) ? 'true' : 'false'
case 'date':
return parseFecha(valor)
case 'number':
return parseNumero(valor)
default:
return String(valor).trim()
}
}
// ── Detectar nombre de desarrollo a partir del nombre de hoja ────────
function detectarDesarrollo(nombreHoja) {
const upper = nombreHoja.toUpperCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
for (const d of DESARROLLOS) {
if (upper.includes(d)) return d
}
return null
}
// ── Main ─────────────────────────────────────────────────────────────
async function main() {
const rutaExcel = process.argv[2] || join(__dirname, '..', '..', 'Reporte_EstatusVenta.xlsx')
const usuarioId = process.argv[3] || 'importacion-maestro'
console.log(`Leyendo archivo: ${rutaExcel}`)
const workbook = XLSX.readFile(rutaExcel)
let totalRegistros = 0
let totalCreados = 0
let totalActualizados = 0
for (const nombreHoja of workbook.SheetNames) {
const desarrollo = detectarDesarrollo(nombreHoja)
if (!desarrollo) {
console.log(` Hoja "${nombreHoja}" → no se reconoce desarrollo, saltando`)
continue
}
console.log(`\n Procesando hoja "${nombreHoja}" → desarrollo: ${desarrollo}`)
const sheet = workbook.Sheets[nombreHoja]
const todasLasFilas = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null })
// Buscar fila de headers (la que contiene "CLIENTE" o "ESTATUS")
let headerIdx = -1
for (let i = 0; i < Math.min(todasLasFilas.length, 20); i++) {
const fila = todasLasFilas[i]
const textos = fila.map((c) => normalizeHeader(c))
if (textos.some((t) => t.includes('CLIENTE') || t.includes('ESTATUS'))) {
headerIdx = i
break
}
}
if (headerIdx === -1) {
console.log(` No se encontró fila de headers, saltando`)
continue
}
const headers = todasLasFilas[headerIdx]
const filasDatos = todasLasFilas.slice(headerIdx + 1)
// Mapear columnas
const mapaColumnas = headers.map((h) => {
const norm = normalizeHeader(h)
return HEADER_MAP[norm] || null
})
let hojaCreados = 0
let hojaActualizados = 0
for (const fila of filasDatos) {
// Detectar unidad: primera columna con valor
const unidadCodigo = String(fila[0] ?? '').trim()
if (!unidadCodigo) continue
// Skip rows that are clearly headers/section markers
if (normalizeHeader(unidadCodigo).includes('TOTAL')) continue
for (let col = 0; col < headers.length; col++) {
const mapeo = mapaColumnas[col]
if (!mapeo) continue
const rawValor = fila[col]
const valor = parseValor(rawValor, mapeo.tipo)
try {
const existente = await prisma.capturaDepartamento.findUnique({
where: {
desarrollo_unidadCodigo_departamento_campo: {
desarrollo,
unidadCodigo,
departamento: mapeo.departamento,
campo: mapeo.campo,
},
},
})
await prisma.capturaDepartamento.upsert({
where: {
desarrollo_unidadCodigo_departamento_campo: {
desarrollo,
unidadCodigo,
departamento: mapeo.departamento,
campo: mapeo.campo,
},
},
update: { valor, tipo: mapeo.tipo, usuarioId },
create: {
desarrollo,
unidadCodigo,
departamento: mapeo.departamento,
campo: mapeo.campo,
valor,
tipo: mapeo.tipo,
usuarioId,
},
})
if (existente) hojaActualizados++
else hojaCreados++
} catch (err) {
// Skip errors silently
}
}
}
console.log(` Unidades: ${new Set(filasDatos.map((f) => String(f[0] ?? '').trim()).filter(Boolean)).size}`)
console.log(` Registros: ${hojaCreados} creados, ${hojaActualizados} actualizados`)
totalCreados += hojaCreados
totalActualizados += hojaActualizados
totalRegistros += hojaCreados + hojaActualizados
}
console.log(`\n── Resumen ──`)
console.log(`Total: ${totalRegistros} registros (${totalCreados} creados, ${totalActualizados} actualizados)`)
await prisma.$disconnect()
}
main().catch((err) => {
console.error('Error:', err)
process.exit(1)
})

View File

@@ -0,0 +1,49 @@
export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const
export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: string; label: string; options?: string[] }[]> = {
OBRA: [
{ campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' },
{ campo: 'to_c4_fianza', tipo: 'boolean', label: 'TO y C4-Fianza' },
{ campo: 'regimen_inscrito', tipo: 'boolean', label: 'Régimen Inscrito' },
{ campo: 'no_oficial', tipo: 'boolean', label: 'No. Oficial' },
{ campo: 'clave_catastral', tipo: 'boolean', label: 'Clave Catastral' },
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo' },
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
],
VENTAS: [
{ campo: 'cliente_id', tipo: 'text', label: 'Cliente ID' },
{ campo: 'nombre_cliente', tipo: 'text', label: 'Nombre Cliente' },
{ campo: 'ubicacion', tipo: 'text', label: 'Ubicación/Unidad' },
{ campo: 'modelo', tipo: 'text', label: 'Modelo' },
{ campo: 'tipo_credito', tipo: 'select', label: 'Tipo Crédito', options: ['BANCARIO', 'COFINAVIT', 'INFONAVIT', 'SANTANDER', 'HSBC', 'BBVA', 'FOVISSSTE', 'CONTADO'] },
{ campo: 'estatus', tipo: 'select', label: 'Estatus', options: ['DISPONIBLE', 'APARTADO', 'VENDIDO', 'ESCRITURADO'] },
{ campo: 'fecha_apartado', tipo: 'date', label: 'Fecha Apartado' },
{ campo: 'fecha_venta', tipo: 'date', label: 'Fecha Venta' },
{ campo: 'fecha_vencimiento', tipo: 'date', label: 'Fecha Vencimiento Contrato' },
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
],
ESCRITURACION: [
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
{ campo: 'fecha_pago_infonavit', tipo: 'date', label: 'Fecha Pago Infonavit' },
{ campo: 'fecha_pago_fovissste', tipo: 'date', label: 'Fecha Pago Fovissste' },
],
FINANZAS: [
{ campo: 'otros_ingresos', tipo: 'number', label: 'Otros Ingresos' },
{ campo: 'pagado_transferencia', tipo: 'number', label: 'Pagado Transferencia' },
{ campo: 'infonavit', tipo: 'number', label: 'Infonavit' },
{ campo: 'bancario', tipo: 'number', label: 'Bancario' },
{ campo: 'fovissste', tipo: 'number', label: 'Fovissste' },
{ campo: 'credito_puente', tipo: 'number', label: 'Crédito Puente' },
{ campo: 'saldo_favor', tipo: 'number', label: 'Saldo a Favor' },
],
POSTVENTA: [
{ campo: 'entrega_sac', tipo: 'boolean', label: 'Entrega a SAC' },
{ campo: 'comentarios_entrega', tipo: 'text', label: 'Comentarios' },
],
}
export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const

View File

@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
capturaDepartamento: {
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
upsert: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
// Mock del service para no depender de Prisma real
vi.mock('../services/captura-departamento.service.js', () => ({
obtenerUnidades: vi.fn(),
obtenerCapturas: vi.fn(),
obtenerCapturaUnidad: vi.fn(),
guardarCampo: vi.fn(),
guardarBulk: vi.fn(),
resumenAvance: vi.fn(),
importarDesdeExcel: vi.fn(),
}))
import {
listarEndpoint,
unidadesEndpoint,
resumenEndpoint,
guardarEndpoint,
bulkEndpoint,
importarEndpoint,
requireAdminCaptura,
} from './captura-departamento.controller.js'
import * as capturaService from '../services/captura-departamento.service.js'
function reqFake(opts: { query?: Record<string, string>; body?: unknown; params?: Record<string, string>; token?: { sub: string; rol: string } }): Request {
return {
query: opts.query ?? {},
body: opts.body ?? {},
params: opts.params ?? {},
usuarioToken: opts.token,
} as unknown as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const statusFn = vi.fn().mockReturnValue({ json, end })
return { res: { json, end, status: statusFn } as unknown as Response, status: statusFn, json, end }
}
beforeEach(() => {
vi.clearAllMocks()
})
// ── requireAdminCaptura ─────────────────────────────────────────────
describe('requireAdminCaptura', () => {
it('pasa si rol es admin', () => {
const next = vi.fn()
const req = reqFake({ token: { sub: 'u1', rol: 'admin' } })
requireAdminCaptura(req, {} as Response, next)
expect(next).toHaveBeenCalled()
})
it('lanza 403 si rol no es admin', () => {
const req = reqFake({ token: { sub: 'u1', rol: 'vendedor' } })
expect(() => requireAdminCaptura(req, {} as Response, vi.fn())).toThrow('Se requiere rol admin')
})
it('lanza 403 si no hay token', () => {
const req = reqFake({})
expect(() => requireAdminCaptura(req, {} as Response, vi.fn())).toThrow('Se requiere rol admin')
})
})
// ── listarEndpoint ──────────────────────────────────────────────────
describe('listarEndpoint (GET /api/captura)', () => {
it('retorna capturas agrupadas', async () => {
const fake = { U101: { estatus: 'VENDIDO' } }
vi.mocked(capturaService.obtenerCapturas).mockResolvedValue(fake)
const { res, json } = resFake()
await listarEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'admin' } }), res)
expect(json).toHaveBeenCalledWith(fake)
expect(capturaService.obtenerCapturas).toHaveBeenCalledWith('SENDA', undefined)
})
it('pasa departamento cuando se provee', async () => {
vi.mocked(capturaService.obtenerCapturas).mockResolvedValue({})
const { res } = resFake()
await listarEndpoint(
reqFake({ query: { desarrollo: 'SENDA', departamento: 'OBRA' }, token: { sub: 'u1', rol: 'admin' } }),
res,
)
expect(capturaService.obtenerCapturas).toHaveBeenCalledWith('SENDA', 'OBRA')
})
it('rechaza sin desarrollo', async () => {
const { res } = resFake()
await expect(listarEndpoint(reqFake({ query: {}, token: { sub: 'u1', rol: 'admin' } }), res)).rejects.toThrow()
})
})
// ── unidadesEndpoint ────────────────────────────────────────────────
describe('unidadesEndpoint (GET /api/captura/unidades)', () => {
it('retorna lista de unidades', async () => {
vi.mocked(capturaService.obtenerUnidades).mockResolvedValue(['U-101', 'U-102'])
const { res, json } = resFake()
await unidadesEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'admin' } }), res)
expect(json).toHaveBeenCalledWith(['U-101', 'U-102'])
})
})
// ── resumenEndpoint ─────────────────────────────────────────────────
describe('resumenEndpoint (GET /api/captura/resumen)', () => {
it('retorna resumen de avance', async () => {
const fake = [{ departamento: 'OBRA', camposTotales: 8, camposLlenos: 4, porcentaje: 50 }]
vi.mocked(capturaService.resumenAvance).mockResolvedValue(fake)
const { res, json } = resFake()
await resumenEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'admin' } }), res)
expect(json).toHaveBeenCalledWith(fake)
})
})
// ── guardarEndpoint ─────────────────────────────────────────────────
describe('guardarEndpoint (POST /api/captura)', () => {
it('guarda campo y retorna 204', async () => {
vi.mocked(capturaService.guardarCampo).mockResolvedValue(undefined)
const { res, status } = resFake()
await guardarEndpoint(
reqFake({
body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'VENTAS', campo: 'estatus', valor: 'VENDIDO' },
token: { sub: 'u1', rol: 'admin' },
}),
res,
)
expect(status).toHaveBeenCalledWith(204)
expect(capturaService.guardarCampo).toHaveBeenCalledWith('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'u1')
})
it('rechaza departamento inválido', async () => {
const { res } = resFake()
await expect(
guardarEndpoint(
reqFake({
body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'INVALIDO', campo: 'x', valor: 'y' },
token: { sub: 'u1', rol: 'admin' },
}),
res,
),
).rejects.toThrow('Departamento inválido')
})
})
// ── bulkEndpoint ────────────────────────────────────────────────────
describe('bulkEndpoint (POST /api/captura/bulk)', () => {
it('guarda múltiple y retorna 204', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
const { res, status } = resFake()
await bulkEndpoint(
reqFake({
body: {
desarrollo: 'SENDA',
departamento: 'VENTAS',
registros: [
{ unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' },
{ unidad: 'U-102', campo: 'estatus', valor: 'DISPONIBLE' },
],
},
token: { sub: 'u1', rol: 'admin' },
}),
res,
)
expect(status).toHaveBeenCalledWith(204)
expect(capturaService.guardarBulk).toHaveBeenCalledWith('SENDA', 'VENTAS', [
{ unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' },
{ unidad: 'U-102', campo: 'estatus', valor: 'DISPONIBLE' },
], 'u1')
})
})
// ── importarEndpoint ────────────────────────────────────────────────
describe('importarEndpoint (POST /api/captura/importar)', () => {
it('importa datos y retorna resultado (admin)', async () => {
const fake = { registrosCreados: 10, registrosActualizados: 5 }
vi.mocked(capturaService.importarDesdeExcel).mockResolvedValue(fake)
const { res, json } = resFake()
await importarEndpoint(
reqFake({
body: { desarrollo: 'SENDA', datos: [['CLIENTE', 'ESTATUS'], ['U-101', 'VENDIDO']] },
token: { sub: 'u1', rol: 'admin' },
}),
res,
)
expect(json).toHaveBeenCalledWith(fake)
expect(capturaService.importarDesdeExcel).toHaveBeenCalledWith('SENDA', [['CLIENTE', 'ESTATUS'], ['U-101', 'VENDIDO']], 'u1')
})
it('lanza 403 si no es admin', async () => {
const req = reqFake({
body: { desarrollo: 'SENDA', datos: [] },
token: { sub: 'u1', rol: 'vendedor' },
})
expect(() => requireAdminCaptura(req, {} as Response, vi.fn())).toThrow('Se requiere rol admin')
})
})

View File

@@ -0,0 +1,133 @@
import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod'
import * as capturaService from '../services/captura-departamento.service.js'
import { DEPARTAMENTOS } from '../config/campos-departamento.js'
import { AppError, BadRequestError } from '../utils/errors.js'
// ── Middleware admin ─────────────────────────────────────────────────
function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
if (req.usuarioToken?.rol !== 'admin') {
throw new AppError(403, 'Se requiere rol admin')
}
next()
}
// ── GET /api/captura?desarrollo=X&departamento=Y ─────────────────────
const listarSchema = z.object({
desarrollo: z.string().min(1, 'Falta desarrollo'),
departamento: z.string().optional(),
})
export async function listarEndpoint(req: Request, res: Response): Promise<void> {
const query = listarSchema.parse(req.query)
const capturas = await capturaService.obtenerCapturas(query.desarrollo, query.departamento)
res.json(capturas)
}
// ── GET /api/captura/unidades?desarrollo=X ───────────────────────────
const unidadesSchema = z.object({
desarrollo: z.string().min(1, 'Falta desarrollo'),
})
export async function unidadesEndpoint(req: Request, res: Response): Promise<void> {
const query = unidadesSchema.parse(req.query)
const unidades = await capturaService.obtenerUnidades(query.desarrollo)
res.json(unidades)
}
// ── GET /api/captura/resumen?desarrollo=X ────────────────────────────
const resumenSchema = z.object({
desarrollo: z.string().min(1, 'Falta desarrollo'),
})
export async function resumenEndpoint(req: Request, res: Response): Promise<void> {
const query = resumenSchema.parse(req.query)
const resumen = await capturaService.resumenAvance(query.desarrollo)
res.json(resumen)
}
// ── POST /api/captura — guardar campo individual ─────────────────────
const guardarSchema = z.object({
desarrollo: z.string().min(1),
unidadCodigo: z.string().min(1),
departamento: z.string().min(1),
campo: z.string().min(1),
valor: z.string().nullable().optional(),
})
export async function guardarEndpoint(req: Request, res: Response): Promise<void> {
const body = guardarSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
}
await capturaService.guardarCampo(
body.desarrollo,
body.unidadCodigo,
body.departamento,
body.campo,
body.valor ?? null,
usuarioId,
)
res.status(204).end()
}
// ── POST /api/captura/bulk — guardar múltiple ────────────────────────
const bulkSchema = z.object({
desarrollo: z.string().min(1),
departamento: z.string().min(1),
registros: z.array(
z.object({
unidad: z.string().min(1),
campo: z.string().min(1),
valor: z.string().nullable().optional(),
}),
),
})
export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
const body = bulkSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
}
await capturaService.guardarBulk(
body.desarrollo,
body.departamento,
body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })),
usuarioId,
)
res.status(204).end()
}
// ── POST /api/captura/importar — importar desde Excel (admin only) ───
const importarSchema = z.object({
desarrollo: z.string().min(1),
datos: z.array(z.array(z.any())),
})
export async function importarEndpoint(req: Request, res: Response): Promise<void> {
const body = importarSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
const resultado = await capturaService.importarDesdeExcel(
body.desarrollo,
body.datos as string[][],
usuarioId,
)
res.json(resultado)
}
// Exportar requireAdmin para usarlo como middleware en routes
export { requireAdmin as requireAdminCaptura }

View File

@@ -106,6 +106,15 @@ import {
desuscribirEndpoint,
vapidKeyEndpoint,
} from '../controllers/push.controller.js'
import {
listarEndpoint as listarCapturaEndpoint,
unidadesEndpoint as unidadesCapturaEndpoint,
resumenEndpoint as resumenCapturaEndpoint,
guardarEndpoint as guardarCapturaEndpoint,
bulkEndpoint as bulkCapturaEndpoint,
importarEndpoint as importarCapturaEndpoint,
requireAdminCaptura,
} from '../controllers/captura-departamento.controller.js'
export const apiRouter = Router()
@@ -236,6 +245,14 @@ apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
// ── Captura por departamentos ────────────────────────────────────────
apiRouter.get('/captura', listarCapturaEndpoint)
apiRouter.get('/captura/unidades', unidadesCapturaEndpoint)
apiRouter.get('/captura/resumen', resumenCapturaEndpoint)
apiRouter.post('/captura', guardarCapturaEndpoint)
apiRouter.post('/captura/bulk', bulkCapturaEndpoint)
apiRouter.post('/captura/importar', requireAdminCaptura, importarCapturaEndpoint)
// ── Push notifications ──────────────────────────────────────────────
apiRouter.get('/push/vapid-key', vapidKeyEndpoint)
apiRouter.post('/push/suscribir', suscribirEndpoint)

View File

@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
capturaDepartamento: {
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
upsert: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import {
obtenerUnidades,
obtenerCapturas,
obtenerCapturaUnidad,
guardarCampo,
guardarBulk,
resumenAvance,
importarDesdeExcel,
} from './captura-departamento.service.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('obtenerUnidades', () => {
it('retorna unidades únicas ordenadas', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101' },
{ unidadCodigo: 'U-102' },
{ unidadCodigo: 'U-101' },
])
const unidades = await obtenerUnidades('SENDA')
expect(unidades).toEqual(['U-101', 'U-102', 'U-101'])
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith({
where: { desarrollo: 'SENDA' },
select: { unidadCodigo: true },
distinct: ['unidadCodigo'],
orderBy: { unidadCodigo: 'asc' },
})
})
it('retorna array vacío si no hay datos', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
const unidades = await obtenerUnidades('NOEXISTE')
expect(unidades).toEqual([])
})
})
describe('obtenerCapturas', () => {
it('retorna capturas agrupadas por unidad', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'estatus', valor: 'VENDIDO' },
{ unidadCodigo: 'U-101', campo: 'modelo', valor: 'Casa A' },
{ unidadCodigo: 'U-102', campo: 'estatus', valor: 'DISPONIBLE' },
])
const capturas = await obtenerCapturas('SENDA')
expect(capturas).toEqual({
'U-101': { estatus: 'VENDIDO', modelo: 'Casa A' },
'U-102': { estatus: 'DISPONIBLE' },
})
})
it('filtra por departamento cuando se provee', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
await obtenerCapturas('SENDA', 'OBRA')
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { desarrollo: 'SENDA', departamento: 'OBRA' },
}),
)
})
})
describe('obtenerCapturaUnidad', () => {
it('retorna campos de una unidad', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ campo: 'estatus', valor: 'VENDIDO' },
{ campo: 'modelo', valor: 'Casa A' },
])
const campos = await obtenerCapturaUnidad('SENDA', 'U-101')
expect(campos).toEqual({ estatus: 'VENDIDO', modelo: 'Casa A' })
})
it('retorna objeto vacío si no hay datos', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
const campos = await obtenerCapturaUnidad('SENDA', 'NOEXISTE')
expect(campos).toEqual({})
})
})
describe('guardarCampo', () => {
it('hace upsert del campo', async () => {
prismaMock.capturaDepartamento.upsert.mockResolvedValue({})
await guardarCampo('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'user-1')
expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalledWith({
where: {
desarrollo_unidadCodigo_departamento_campo: {
desarrollo: 'SENDA',
unidadCodigo: 'U-101',
departamento: 'VENTAS',
campo: 'estatus',
},
},
update: { valor: 'VENDIDO', tipo: 'select', usuarioId: 'user-1' },
create: {
desarrollo: 'SENDA',
unidadCodigo: 'U-101',
departamento: 'VENTAS',
campo: 'estatus',
valor: 'VENDIDO',
tipo: 'select',
usuarioId: 'user-1',
},
})
})
it('permite valor null', async () => {
prismaMock.capturaDepartamento.upsert.mockResolvedValue({})
await guardarCampo('SENDA', 'U-101', 'OBRA', 'comentarios_obra', null, 'user-1')
expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ valor: null }),
}),
)
})
})
describe('guardarBulk', () => {
it('ejecuta upserts en transacción', async () => {
prismaMock.$transaction.mockResolvedValue([])
await guardarBulk('SENDA', 'VENTAS', [
{ unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' },
{ unidad: 'U-101', campo: 'modelo', valor: 'Casa A' },
], 'user-1')
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
expect(prismaMock.$transaction).toHaveBeenCalledWith(expect.arrayContaining([
expect.any(Promise),
]))
})
})
describe('resumenAvance', () => {
it('calcula porcentaje de avance por departamento', async () => {
// Mock para count (campos llenos)
prismaMock.capturaDepartamento.count.mockResolvedValue(5)
// Mock para findMany (unidades únicas)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101' },
{ unidadCodigo: 'U-102' },
])
const resumen = await resumenAvance('SENDA')
expect(resumen.length).toBeGreaterThan(0)
// Verificar que tiene la estructura correcta
for (const r of resumen) {
expect(r).toHaveProperty('departamento')
expect(r).toHaveProperty('camposTotales')
expect(r).toHaveProperty('camposLlenos')
expect(r).toHaveProperty('porcentaje')
}
})
})
describe('importarDesdeExcel', () => {
it('retorna 0 registros si solo hay header', async () => {
const resultado = await importarDesdeExcel('SENDA', [['CLIENTE', 'ESTATUS']], 'user-1')
expect(resultado).toEqual({ registrosCreados: 0, registrosActualizados: 0 })
})
it('retorna 0 si el arreglo está vacío', async () => {
const resultado = await importarDesdeExcel('SENDA', [], 'user-1')
expect(resultado).toEqual({ registrosCreados: 0, registrosActualizados: 0 })
})
})

View File

@@ -0,0 +1,269 @@
import { prisma } from './prisma.js'
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
// ── Obtener unidades únicas de un desarrollo ──────────────────────────
export async function obtenerUnidades(desarrollo: string): Promise<string[]> {
const rows = await prisma.capturaDepartamento.findMany({
where: { desarrollo },
select: { unidadCodigo: true },
distinct: ['unidadCodigo'],
orderBy: { unidadCodigo: 'asc' },
})
return rows.map((r) => r.unidadCodigo)
}
// ── Obtener capturas de un desarrollo (agrupadas por unidad) ──────────
export async function obtenerCapturas(
desarrollo: string,
departamento?: string,
): Promise<Record<string, Record<string, string | null>>> {
const where: Record<string, string> = { desarrollo }
if (departamento) where.departamento = departamento
const rows = await prisma.capturaDepartamento.findMany({
where,
orderBy: [{ unidadCodigo: 'asc' }, { campo: 'asc' }],
})
const agrupado: Record<string, Record<string, string | null>> = {}
for (const r of rows) {
const u = r.unidadCodigo
if (!agrupado[u]) agrupado[u] = {}
agrupado[u][r.campo] = r.valor
}
return agrupado
}
// ── Obtener campos de una unidad ──────────────────────────────────────
export async function obtenerCapturaUnidad(
desarrollo: string,
unidad: string,
departamento?: string,
): Promise<Record<string, string | null>> {
const where: Record<string, string> = { desarrollo, unidadCodigo: unidad }
if (departamento) where.departamento = departamento
const rows = await prisma.capturaDepartamento.findMany({
where,
orderBy: { campo: 'asc' },
})
const campos: Record<string, string | null> = {}
for (const r of rows) {
campos[r.campo] = r.valor
}
return campos
}
// ── Guardar campo individual (upsert) ────────────────────────────────
export async function guardarCampo(
desarrollo: string,
unidadCodigo: string,
departamento: string,
campo: string,
valor: string | null,
usuarioId: string,
): Promise<void> {
const tipo = CAMPOS_POR_DEPARTAMENTO[departamento]?.find((c) => c.campo === campo)?.tipo ?? 'text'
await prisma.capturaDepartamento.upsert({
where: {
desarrollo_unidadCodigo_departamento_campo: {
desarrollo,
unidadCodigo,
departamento,
campo,
},
},
update: { valor, tipo, usuarioId },
create: { desarrollo, unidadCodigo, departamento, campo, valor, tipo, usuarioId },
})
}
// ── Guardar múltiple (bulk upsert) ───────────────────────────────────
export async function guardarBulk(
desarrollo: string,
departamento: string,
registros: { unidad: string; campo: string; valor: string | null }[],
usuarioId: string,
): Promise<void> {
const tipoPorCampo = new Map(
(CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => [c.campo, c.tipo]),
)
await prisma.$transaction(
registros.map((r) =>
prisma.capturaDepartamento.upsert({
where: {
desarrollo_unidadCodigo_departamento_campo: {
desarrollo,
unidadCodigo: r.unidad,
departamento,
campo: r.campo,
},
},
update: { valor: r.valor, tipo: tipoPorCampo.get(r.campo) ?? 'text', usuarioId },
create: {
desarrollo,
unidadCodigo: r.unidad,
departamento,
campo: r.campo,
valor: r.valor,
tipo: tipoPorCampo.get(r.campo) ?? 'text',
usuarioId,
},
}),
),
)
}
// ── Resumen de avance por departamento ────────────────────────────────
export interface ResumenDepartamento {
departamento: string
camposTotales: number
camposLlenos: number
porcentaje: number
}
export async function resumenAvance(desarrollo: string): Promise<ResumenDepartamento[]> {
const resultados: ResumenDepartamento[] = []
for (const dept of DEPARTAMENTOS) {
const camposDef = CAMPOS_POR_DEPARTAMENTO[dept] ?? []
const camposTotales = camposDef.length
if (camposTotales === 0) continue
const camposNombres = camposDef.map((c) => c.campo)
// Contar cuántos registros existen con valor no nulo
const llenos = await prisma.capturaDepartamento.count({
where: {
desarrollo,
departamento: dept,
campo: { in: camposNombres },
valor: { not: null },
},
})
// Contar unidades únicas para multiplicar
const unidades = await prisma.capturaDepartamento.findMany({
where: { desarrollo, departamento: dept },
select: { unidadCodigo: true },
distinct: ['unidadCodigo'],
})
const numUnidades = unidades.length || 1 // evitar div/0
const totalEsperado = camposTotales * numUnidades
const porcentaje = totalEsperado > 0 ? Math.round((llenos / totalEsperado) * 100) : 0
resultados.push({
departamento: dept,
camposTotales,
camposLlenos: llenos,
porcentaje,
})
}
return resultados
}
// ── Importar desde Excel (arreglo de arreglos) ────────────────────────
export interface ResultadoImportacion {
registrosCreados: number
registrosActualizados: number
}
export async function importarDesdeExcel(
desarrollo: string,
datos: string[][],
usuarioId: string,
): Promise<ResultadoImportacion> {
if (datos.length < 2) return { registrosCreados: 0, registrosActualizados: 0 }
// Primera fila = headers
const firstRow = datos[0]
if (!firstRow) return { registrosCreados: 0, registrosActualizados: 0 }
const headers = firstRow.map((h) => String(h ?? '').trim())
// Mapear cada header a su departamento y campo
type ColMapa = { departamento: string; campo: string; tipo: string } | null
const mapaColumnas: ColMapa[] = headers.map((header) => {
const norm = header.toUpperCase().replace(/\s+/g, '_').replace(/[Á]/g, 'A').replace(/[É]/g, 'E').replace(/[Í]/g, 'I').replace(/[Ó]/g, 'O').replace(/[Ú]/g, 'U').replace(/[Ñ]/g, 'N')
for (const [dept, campos] of Object.entries(CAMPOS_POR_DEPARTAMENTO)) {
for (const c of campos) {
if (c.campo === norm || norm.includes(c.campo.toUpperCase())) {
return { departamento: dept, campo: c.campo, tipo: c.tipo }
}
}
}
return null
})
let creados = 0
let actualizados = 0
// Procesar cada fila de datos (skipping header row)
const filas = datos.slice(1).filter((row) => row.some((cell) => cell != null && String(cell).trim() !== ''))
for (const fila of filas) {
// Detectar unidad: primera columna con valor
const unidadCodigo = String(fila[0] ?? '').trim()
if (!unidadCodigo) continue
for (let col = 0; col < headers.length; col++) {
const mapeo = mapaColumnas[col]
if (!mapeo) continue
const rawValor = fila[col]
const valor = rawValor != null && String(rawValor).trim() !== '' ? String(rawValor).trim() : null
try {
const existente = await prisma.capturaDepartamento.findUnique({
where: {
desarrollo_unidadCodigo_departamento_campo: {
desarrollo,
unidadCodigo,
departamento: mapeo.departamento,
campo: mapeo.campo,
},
},
})
await prisma.capturaDepartamento.upsert({
where: {
desarrollo_unidadCodigo_departamento_campo: {
desarrollo,
unidadCodigo,
departamento: mapeo.departamento,
campo: mapeo.campo,
},
},
update: { valor, tipo: mapeo.tipo, usuarioId },
create: {
desarrollo,
unidadCodigo,
departamento: mapeo.departamento,
campo: mapeo.campo,
valor,
tipo: mapeo.tipo,
usuarioId,
},
})
if (existente) actualizados++
else creados++
} catch {
// Skip errors silently (e.g., empty values)
}
}
}
return { registrosCreados: creados, registrosActualizados: actualizados }
}