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

@@ -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 }
}