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