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:
180
api-gateway/src/services/captura-departamento.service.test.ts
Normal file
180
api-gateway/src/services/captura-departamento.service.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
269
api-gateway/src/services/captura-departamento.service.ts
Normal file
269
api-gateway/src/services/captura-departamento.service.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user