From 6a7118312170d8dbb477c97aba84d469ffa9e8ca Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Fri, 30 Jan 2026 17:39:51 -0600 Subject: [PATCH] feat(MAI-002,MAI-004,MAI-009): Add missing controllers and services - construction/torre: Service + Controller for vertical buildings - construction/nivel: Service + Controller for floor levels - construction/departamento: Service + Controller for units - purchase/purchase-order-construction: Controller for PO extensions - purchase/supplier-construction: Controller for supplier extensions - quality/checklist: Controller for inspection templates All endpoints follow existing patterns with multi-tenancy support. Co-Authored-By: Claude Opus 4.5 --- .../controllers/departamento.controller.ts | 360 ++++++++++++++ src/modules/construction/controllers/index.ts | 3 + .../controllers/nivel.controller.ts | 245 ++++++++++ .../controllers/torre.controller.ts | 241 ++++++++++ .../services/departamento.service.ts | 361 +++++++++++++++ src/modules/construction/services/index.ts | 3 + .../construction/services/nivel.service.ts | 213 +++++++++ .../construction/services/torre.service.ts | 226 +++++++++ src/modules/purchase/controllers/index.ts | 2 + .../purchase-order-construction.controller.ts | 327 +++++++++++++ .../supplier-construction.controller.ts | 418 +++++++++++++++++ .../controllers/checklist.controller.ts | 438 ++++++++++++++++++ src/modules/quality/controllers/index.ts | 1 + 13 files changed, 2838 insertions(+) create mode 100644 src/modules/construction/controllers/departamento.controller.ts create mode 100644 src/modules/construction/controllers/nivel.controller.ts create mode 100644 src/modules/construction/controllers/torre.controller.ts create mode 100644 src/modules/construction/services/departamento.service.ts create mode 100644 src/modules/construction/services/nivel.service.ts create mode 100644 src/modules/construction/services/torre.service.ts create mode 100644 src/modules/purchase/controllers/purchase-order-construction.controller.ts create mode 100644 src/modules/purchase/controllers/supplier-construction.controller.ts create mode 100644 src/modules/quality/controllers/checklist.controller.ts diff --git a/src/modules/construction/controllers/departamento.controller.ts b/src/modules/construction/controllers/departamento.controller.ts new file mode 100644 index 0000000..259443b --- /dev/null +++ b/src/modules/construction/controllers/departamento.controller.ts @@ -0,0 +1,360 @@ +/** + * Departamento Controller + * API endpoints para gestión de departamentos/unidades + * + * @module Construction (MAI-002) + * @prefix /api/v1/departamentos + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DepartamentoService, CreateDepartamentoDto, UpdateDepartamentoDto, SellDepartamentoDto } from '../services/departamento.service'; + +const router = Router(); +const departamentoService = new DepartamentoService(null as any); + +/** + * GET /api/v1/departamentos + * Lista todos los departamentos del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { nivelId, torreId, prototipoId, status, minPrice, maxPrice, search, page, limit } = req.query; + + const result = await departamentoService.findAll( + { tenantId }, + { + nivelId: nivelId as string, + torreId: torreId as string, + prototipoId: prototipoId as string, + status: status as string, + minPrice: minPrice ? Number(minPrice) : undefined, + maxPrice: maxPrice ? Number(maxPrice) : undefined, + search: search as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/departamentos/statistics + * Estadísticas de departamentos + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { torreId } = req.query; + const stats = await departamentoService.getStatistics({ tenantId }, torreId as string); + + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/departamentos/available + * Departamentos disponibles + */ +router.get('/available', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { torreId } = req.query; + const data = await departamentoService.findAvailable({ tenantId }, torreId as string); + + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/departamentos/by-nivel/:nivelId + * Departamentos por nivel + */ +router.get('/by-nivel/:nivelId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await departamentoService.findByNivel({ tenantId }, req.params.nivelId); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/departamentos/by-torre/:torreId + * Departamentos por torre + */ +router.get('/by-torre/:torreId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await departamentoService.findByTorre({ tenantId }, req.params.torreId); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/departamentos/:id + * Obtiene un departamento por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const departamento = await departamentoService.findById({ tenantId }, req.params.id); + if (!departamento) { + return res.status(404).json({ error: 'Departamento no encontrado' }); + } + + return res.json({ success: true, data: departamento }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/departamentos + * Crea un nuevo departamento + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateDepartamentoDto = req.body; + + if (!dto.nivelId || !dto.code || !dto.unitNumber) { + return res.status(400).json({ error: 'nivelId, code y unitNumber son requeridos' }); + } + + const departamento = await departamentoService.create({ tenantId, userId }, dto); + return res.status(201).json({ success: true, data: departamento }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/departamentos/:id + * Actualiza un departamento + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateDepartamentoDto = req.body; + + const departamento = await departamentoService.update({ tenantId, userId }, req.params.id, dto); + if (!departamento) { + return res.status(404).json({ error: 'Departamento no encontrado' }); + } + + return res.json({ success: true, data: departamento }); + } catch (error: any) { + if (error.message?.includes('Cannot update')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/departamentos/:id/reserve + * Reserva un departamento + */ +router.post('/:id/reserve', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { buyerId } = req.body; + + if (!buyerId) { + return res.status(400).json({ error: 'buyerId es requerido' }); + } + + const departamento = await departamentoService.reserve({ tenantId, userId }, req.params.id, buyerId); + if (!departamento) { + return res.status(404).json({ error: 'Departamento no encontrado' }); + } + + return res.json({ success: true, data: departamento, message: 'Departamento reservado' }); + } catch (error: any) { + if (error.message?.includes('not available')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/departamentos/:id/cancel-reservation + * Cancela la reservación de un departamento + */ +router.post('/:id/cancel-reservation', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const departamento = await departamentoService.cancelReservation({ tenantId, userId }, req.params.id); + + if (!departamento) { + return res.status(404).json({ error: 'Departamento no encontrado' }); + } + + return res.json({ success: true, data: departamento, message: 'Reservación cancelada' }); + } catch (error: any) { + if (error.message?.includes('not reserved')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/departamentos/:id/sell + * Vende un departamento + */ +router.post('/:id/sell', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: SellDepartamentoDto = req.body; + + if (!dto.buyerId || dto.priceFinal === undefined) { + return res.status(400).json({ error: 'buyerId y priceFinal son requeridos' }); + } + + const departamento = await departamentoService.sell({ tenantId, userId }, req.params.id, dto); + if (!departamento) { + return res.status(404).json({ error: 'Departamento no encontrado' }); + } + + return res.json({ success: true, data: departamento, message: 'Departamento vendido' }); + } catch (error: any) { + if (error.message?.includes('not available')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/departamentos/:id/deliver + * Marca un departamento como entregado + */ +router.post('/:id/deliver', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { deliveryDate } = req.body; + + const departamento = await departamentoService.markDelivered( + { tenantId, userId }, + req.params.id, + deliveryDate ? new Date(deliveryDate) : undefined, + ); + + if (!departamento) { + return res.status(404).json({ error: 'Departamento no encontrado' }); + } + + return res.json({ success: true, data: departamento, message: 'Departamento entregado' }); + } catch (error: any) { + if (error.message?.includes('must be sold')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * DELETE /api/v1/departamentos/:id + * Elimina un departamento (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await departamentoService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Departamento no encontrado' }); + } + + return res.json({ success: true, message: 'Departamento eliminado' }); + } catch (error: any) { + if (error.message?.includes('Cannot delete')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +export default router; diff --git a/src/modules/construction/controllers/index.ts b/src/modules/construction/controllers/index.ts index 624f318..7a496a6 100644 --- a/src/modules/construction/controllers/index.ts +++ b/src/modules/construction/controllers/index.ts @@ -9,3 +9,6 @@ export { createEtapaController } from './etapa.controller'; export { createManzanaController } from './manzana.controller'; export { createLoteController } from './lote.controller'; export { createPrototipoController } from './prototipo.controller'; +export { default as torreController } from './torre.controller'; +export { default as nivelController } from './nivel.controller'; +export { default as departamentoController } from './departamento.controller'; diff --git a/src/modules/construction/controllers/nivel.controller.ts b/src/modules/construction/controllers/nivel.controller.ts new file mode 100644 index 0000000..5e4dc6e --- /dev/null +++ b/src/modules/construction/controllers/nivel.controller.ts @@ -0,0 +1,245 @@ +/** + * Nivel Controller + * API endpoints para gestión de niveles/pisos + * + * @module Construction (MAI-002) + * @prefix /api/v1/niveles + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { NivelService, CreateNivelDto, UpdateNivelDto } from '../services/nivel.service'; + +const router = Router(); +const nivelService = new NivelService(null as any); + +/** + * GET /api/v1/niveles + * Lista todos los niveles del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { torreId, search, page, limit } = req.query; + + const result = await nivelService.findAll( + { tenantId }, + { + torreId: torreId as string, + search: search as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/niveles/statistics + * Estadísticas de niveles + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { torreId } = req.query; + const stats = await nivelService.getStatistics({ tenantId }, torreId as string); + + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/niveles/by-torre/:torreId + * Niveles por torre + */ +router.get('/by-torre/:torreId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await nivelService.findByTorre({ tenantId }, req.params.torreId); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/niveles/:id + * Obtiene un nivel por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const nivel = await nivelService.findById({ tenantId }, req.params.id); + if (!nivel) { + return res.status(404).json({ error: 'Nivel no encontrado' }); + } + + return res.json({ success: true, data: nivel }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/niveles + * Crea un nuevo nivel + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateNivelDto = req.body; + + if (!dto.torreId || dto.floorNumber === undefined) { + return res.status(400).json({ error: 'torreId y floorNumber son requeridos' }); + } + + const nivel = await nivelService.create({ tenantId, userId }, dto); + return res.status(201).json({ success: true, data: nivel }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/niveles/bulk + * Crea múltiples niveles para una torre + */ +router.post('/bulk', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { torreId, totalFloors } = req.body; + + if (!torreId || !totalFloors) { + return res.status(400).json({ error: 'torreId y totalFloors son requeridos' }); + } + + if (totalFloors < 1 || totalFloors > 100) { + return res.status(400).json({ error: 'totalFloors debe estar entre 1 y 100' }); + } + + const niveles = await nivelService.createBulk({ tenantId, userId }, torreId, Number(totalFloors)); + return res.status(201).json({ + success: true, + data: niveles, + count: niveles.length, + message: `${niveles.length} niveles creados`, + }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/niveles/:id + * Actualiza un nivel + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateNivelDto = req.body; + + const nivel = await nivelService.update({ tenantId, userId }, req.params.id, dto); + if (!nivel) { + return res.status(404).json({ error: 'Nivel no encontrado' }); + } + + return res.json({ success: true, data: nivel }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/niveles/:id/recalculate-units + * Recalcula el total de unidades de un nivel + */ +router.post('/:id/recalculate-units', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const nivel = await nivelService.updateUnitCount({ tenantId, userId }, req.params.id); + + if (!nivel) { + return res.status(404).json({ error: 'Nivel no encontrado' }); + } + + return res.json({ success: true, data: nivel, message: 'Unidades recalculadas' }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/niveles/:id + * Elimina un nivel (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await nivelService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Nivel no encontrado' }); + } + + return res.json({ success: true, message: 'Nivel eliminado' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/construction/controllers/torre.controller.ts b/src/modules/construction/controllers/torre.controller.ts new file mode 100644 index 0000000..c497b3f --- /dev/null +++ b/src/modules/construction/controllers/torre.controller.ts @@ -0,0 +1,241 @@ +/** + * Torre Controller + * API endpoints para gestión de torres/edificios + * + * @module Construction (MAI-002) + * @prefix /api/v1/torres + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { TorreService, CreateTorreDto, UpdateTorreDto } from '../services/torre.service'; + +const router = Router(); +const torreService = new TorreService(null as any); + +/** + * GET /api/v1/torres + * Lista todas las torres del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { etapaId, status, search, page, limit } = req.query; + + const result = await torreService.findAll( + { tenantId }, + { + etapaId: etapaId as string, + status: status as string, + search: search as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/torres/statistics + * Estadísticas de torres + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { etapaId } = req.query; + const stats = await torreService.getStatistics({ tenantId }, etapaId as string); + + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/torres/by-etapa/:etapaId + * Torres por etapa + */ +router.get('/by-etapa/:etapaId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await torreService.findByEtapa({ tenantId }, req.params.etapaId); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/torres/:id + * Obtiene una torre por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const torre = await torreService.findById({ tenantId }, req.params.id); + if (!torre) { + return res.status(404).json({ error: 'Torre no encontrada' }); + } + + return res.json({ success: true, data: torre }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/torres + * Crea una nueva torre + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateTorreDto = req.body; + + if (!dto.etapaId || !dto.code || !dto.name) { + return res.status(400).json({ error: 'etapaId, code y name son requeridos' }); + } + + const torre = await torreService.create({ tenantId, userId }, dto); + return res.status(201).json({ success: true, data: torre }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/torres/:id + * Actualiza una torre + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateTorreDto = req.body; + + const torre = await torreService.update({ tenantId, userId }, req.params.id, dto); + if (!torre) { + return res.status(404).json({ error: 'Torre no encontrada' }); + } + + return res.json({ success: true, data: torre }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/torres/:id/status + * Actualiza el estado de una torre + */ +router.patch('/:id/status', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { status } = req.body; + + if (!status) { + return res.status(400).json({ error: 'status es requerido' }); + } + + const torre = await torreService.updateStatus({ tenantId, userId }, req.params.id, status); + if (!torre) { + return res.status(404).json({ error: 'Torre no encontrada' }); + } + + return res.json({ success: true, data: torre, message: 'Estado actualizado' }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/torres/:id/recalculate-units + * Recalcula el total de unidades de una torre + */ +router.post('/:id/recalculate-units', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const torre = await torreService.updateUnitCount({ tenantId, userId }, req.params.id); + + if (!torre) { + return res.status(404).json({ error: 'Torre no encontrada' }); + } + + return res.json({ success: true, data: torre, message: 'Unidades recalculadas' }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/torres/:id + * Elimina una torre (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await torreService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Torre no encontrada' }); + } + + return res.json({ success: true, message: 'Torre eliminada' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/construction/services/departamento.service.ts b/src/modules/construction/services/departamento.service.ts new file mode 100644 index 0000000..81a2e44 --- /dev/null +++ b/src/modules/construction/services/departamento.service.ts @@ -0,0 +1,361 @@ +/** + * DepartamentoService - Servicio de departamentos/unidades + * + * Gestión de departamentos/unidades dentro de niveles de construcción vertical. + * + * @module Construction (MAI-002) + */ + +import { Repository } from 'typeorm'; +import { Departamento } from '../entities/departamento.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateDepartamentoDto { + nivelId: string; + prototipoId?: string; + code: string; + unitNumber: string; + areaM2?: number; + status?: string; + priceBase?: number; + priceFinal?: number; +} + +export interface UpdateDepartamentoDto { + prototipoId?: string; + unitNumber?: string; + areaM2?: number; + status?: string; + priceBase?: number; + priceFinal?: number; +} + +export interface SellDepartamentoDto { + buyerId: string; + priceFinal: number; + saleDate?: Date; + deliveryDate?: Date; +} + +export interface DepartamentoFilters { + nivelId?: string; + torreId?: string; + prototipoId?: string; + status?: string; + minPrice?: number; + maxPrice?: number; + search?: string; + page?: number; + limit?: number; +} + +export class DepartamentoService { + constructor( + private readonly repository: Repository, + ) {} + + async findAll( + ctx: ServiceContext, + filters: DepartamentoFilters = {}, + ): Promise<{ data: Departamento[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 50; + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('d') + .leftJoinAndSelect('d.nivel', 'nivel') + .leftJoinAndSelect('nivel.torre', 'torre') + .leftJoinAndSelect('d.prototipo', 'prototipo') + .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('d.deleted_at IS NULL'); + + if (filters.nivelId) { + queryBuilder.andWhere('d.nivel_id = :nivelId', { nivelId: filters.nivelId }); + } + + if (filters.torreId) { + queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId: filters.torreId }); + } + + if (filters.prototipoId) { + queryBuilder.andWhere('d.prototipo_id = :prototipoId', { prototipoId: filters.prototipoId }); + } + + if (filters.status) { + queryBuilder.andWhere('d.status = :status', { status: filters.status }); + } + + if (filters.minPrice !== undefined) { + queryBuilder.andWhere('d.price_final >= :minPrice', { minPrice: filters.minPrice }); + } + + if (filters.maxPrice !== undefined) { + queryBuilder.andWhere('d.price_final <= :maxPrice', { maxPrice: filters.maxPrice }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(d.code ILIKE :search OR d.unit_number ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy('d.code', 'ASC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + relations: ['nivel', 'nivel.torre', 'prototipo'], + }); + } + + async findByCode(ctx: ServiceContext, nivelId: string, code: string): Promise { + return this.repository.findOne({ + where: { + nivelId, + code, + tenantId: ctx.tenantId, + }, + }); + } + + async findByNivel(ctx: ServiceContext, nivelId: string): Promise { + return this.repository + .createQueryBuilder('d') + .leftJoinAndSelect('d.prototipo', 'prototipo') + .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('d.deleted_at IS NULL') + .andWhere('d.nivel_id = :nivelId', { nivelId }) + .orderBy('d.code', 'ASC') + .getMany(); + } + + async findByTorre(ctx: ServiceContext, torreId: string): Promise { + return this.repository + .createQueryBuilder('d') + .leftJoinAndSelect('d.nivel', 'nivel') + .leftJoinAndSelect('d.prototipo', 'prototipo') + .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('d.deleted_at IS NULL') + .andWhere('nivel.torre_id = :torreId', { torreId }) + .orderBy('nivel.floor_number', 'ASC') + .addOrderBy('d.code', 'ASC') + .getMany(); + } + + async findAvailable(ctx: ServiceContext, torreId?: string): Promise { + const queryBuilder = this.repository + .createQueryBuilder('d') + .leftJoinAndSelect('d.nivel', 'nivel') + .leftJoinAndSelect('nivel.torre', 'torre') + .leftJoinAndSelect('d.prototipo', 'prototipo') + .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('d.deleted_at IS NULL') + .andWhere('d.status = :status', { status: 'available' }); + + if (torreId) { + queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId }); + } + + return queryBuilder + .orderBy('torre.code', 'ASC') + .addOrderBy('nivel.floor_number', 'ASC') + .addOrderBy('d.code', 'ASC') + .getMany(); + } + + async create(ctx: ServiceContext, dto: CreateDepartamentoDto): Promise { + const existing = await this.findByCode(ctx, dto.nivelId, dto.code); + if (existing) { + throw new Error(`Departamento with code ${dto.code} already exists in this nivel`); + } + + const departamento = this.repository.create({ + tenantId: ctx.tenantId, + createdBy: ctx.userId, + nivelId: dto.nivelId, + prototipoId: dto.prototipoId, + code: dto.code, + unitNumber: dto.unitNumber, + areaM2: dto.areaM2, + status: dto.status ?? 'available', + priceBase: dto.priceBase, + priceFinal: dto.priceFinal ?? dto.priceBase, + }); + + return this.repository.save(departamento); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateDepartamentoDto): Promise { + const departamento = await this.findById(ctx, id); + if (!departamento) { + return null; + } + + if (departamento.status === 'sold' || departamento.status === 'delivered') { + throw new Error('Cannot update a sold or delivered unit'); + } + + Object.assign(departamento, { + ...dto, + updatedBy: ctx.userId, + }); + + return this.repository.save(departamento); + } + + async sell(ctx: ServiceContext, id: string, dto: SellDepartamentoDto): Promise { + const departamento = await this.findById(ctx, id); + if (!departamento) { + return null; + } + + if (departamento.status !== 'available' && departamento.status !== 'reserved') { + throw new Error('Unit is not available for sale'); + } + + departamento.buyerId = dto.buyerId; + departamento.priceFinal = dto.priceFinal; + departamento.saleDate = dto.saleDate ?? new Date(); + if (dto.deliveryDate) { + departamento.deliveryDate = dto.deliveryDate; + } + departamento.status = 'sold'; + if (ctx.userId) { + departamento.updatedBy = ctx.userId; + } + + return this.repository.save(departamento); + } + + async reserve(ctx: ServiceContext, id: string, buyerId: string): Promise { + const departamento = await this.findById(ctx, id); + if (!departamento) { + return null; + } + + if (departamento.status !== 'available') { + throw new Error('Unit is not available for reservation'); + } + + departamento.buyerId = buyerId; + departamento.status = 'reserved'; + if (ctx.userId) { + departamento.updatedBy = ctx.userId; + } + + return this.repository.save(departamento); + } + + async cancelReservation(ctx: ServiceContext, id: string): Promise { + const departamento = await this.findById(ctx, id); + if (!departamento) { + return null; + } + + if (departamento.status !== 'reserved') { + throw new Error('Unit is not reserved'); + } + + departamento.buyerId = null as any; + departamento.status = 'available'; + if (ctx.userId) { + departamento.updatedBy = ctx.userId; + } + + return this.repository.save(departamento); + } + + async markDelivered(ctx: ServiceContext, id: string, deliveryDate?: Date): Promise { + const departamento = await this.findById(ctx, id); + if (!departamento) { + return null; + } + + if (departamento.status !== 'sold') { + throw new Error('Unit must be sold before delivery'); + } + + departamento.deliveryDate = deliveryDate ?? new Date(); + departamento.status = 'delivered'; + if (ctx.userId) { + departamento.updatedBy = ctx.userId; + } + + return this.repository.save(departamento); + } + + async getStatistics(ctx: ServiceContext, torreId?: string): Promise<{ + totalUnits: number; + available: number; + reserved: number; + sold: number; + delivered: number; + totalValue: number; + soldValue: number; + averagePrice: number; + }> { + const queryBuilder = this.repository + .createQueryBuilder('d') + .leftJoin('d.nivel', 'nivel') + .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('d.deleted_at IS NULL'); + + if (torreId) { + queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId }); + } + + const departamentos = await queryBuilder.getMany(); + + const available = departamentos.filter(d => d.status === 'available'); + const reserved = departamentos.filter(d => d.status === 'reserved'); + const sold = departamentos.filter(d => d.status === 'sold'); + const delivered = departamentos.filter(d => d.status === 'delivered'); + + const totalValue = departamentos.reduce((sum, d) => sum + Number(d.priceFinal || 0), 0); + const soldValue = [...sold, ...delivered].reduce((sum, d) => sum + Number(d.priceFinal || 0), 0); + + return { + totalUnits: departamentos.length, + available: available.length, + reserved: reserved.length, + sold: sold.length, + delivered: delivered.length, + totalValue, + soldValue, + averagePrice: departamentos.length > 0 ? totalValue / departamentos.length : 0, + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const departamento = await this.findById(ctx, id); + if (!departamento) { + return false; + } + + if (departamento.status === 'sold' || departamento.status === 'delivered') { + throw new Error('Cannot delete a sold or delivered unit'); + } + + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), deletedBy: ctx.userId }, + ); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/construction/services/index.ts b/src/modules/construction/services/index.ts index 743750a..be3ec1e 100644 --- a/src/modules/construction/services/index.ts +++ b/src/modules/construction/services/index.ts @@ -9,3 +9,6 @@ export * from './etapa.service'; export * from './manzana.service'; export * from './lote.service'; export * from './prototipo.service'; +export * from './torre.service'; +export * from './nivel.service'; +export * from './departamento.service'; diff --git a/src/modules/construction/services/nivel.service.ts b/src/modules/construction/services/nivel.service.ts new file mode 100644 index 0000000..dd4256b --- /dev/null +++ b/src/modules/construction/services/nivel.service.ts @@ -0,0 +1,213 @@ +/** + * NivelService - Servicio de niveles/pisos + * + * Gestión de niveles/pisos dentro de torres de construcción. + * + * @module Construction (MAI-002) + */ + +import { Repository } from 'typeorm'; +import { Nivel } from '../entities/nivel.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateNivelDto { + torreId: string; + floorNumber: number; + name?: string; + totalUnits?: number; +} + +export interface UpdateNivelDto { + name?: string; + totalUnits?: number; +} + +export interface NivelFilters { + torreId?: string; + search?: string; + page?: number; + limit?: number; +} + +export class NivelService { + constructor( + private readonly repository: Repository, + ) {} + + async findAll( + ctx: ServiceContext, + filters: NivelFilters = {}, + ): Promise<{ data: Nivel[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 50; + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('n') + .leftJoinAndSelect('n.torre', 'torre') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('n.deleted_at IS NULL'); + + if (filters.torreId) { + queryBuilder.andWhere('n.torre_id = :torreId', { torreId: filters.torreId }); + } + + if (filters.search) { + queryBuilder.andWhere('n.name ILIKE :search', { search: `%${filters.search}%` }); + } + + const [data, total] = await queryBuilder + .orderBy('n.floor_number', 'ASC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + relations: ['torre', 'departamentos'], + }); + } + + async findByFloorNumber(ctx: ServiceContext, torreId: string, floorNumber: number): Promise { + return this.repository.findOne({ + where: { + torreId, + floorNumber, + tenantId: ctx.tenantId, + }, + }); + } + + async findByTorre(ctx: ServiceContext, torreId: string): Promise { + return this.repository + .createQueryBuilder('n') + .leftJoinAndSelect('n.departamentos', 'departamentos', 'departamentos.deleted_at IS NULL') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('n.deleted_at IS NULL') + .andWhere('n.torre_id = :torreId', { torreId }) + .orderBy('n.floor_number', 'ASC') + .addOrderBy('departamentos.code', 'ASC') + .getMany(); + } + + async create(ctx: ServiceContext, dto: CreateNivelDto): Promise { + const existing = await this.findByFloorNumber(ctx, dto.torreId, dto.floorNumber); + if (existing) { + throw new Error(`Floor ${dto.floorNumber} already exists in this torre`); + } + + const nivel = this.repository.create({ + tenantId: ctx.tenantId, + createdBy: ctx.userId, + torreId: dto.torreId, + floorNumber: dto.floorNumber, + name: dto.name || `Nivel ${dto.floorNumber}`, + totalUnits: dto.totalUnits ?? 0, + }); + + return this.repository.save(nivel); + } + + async createBulk(ctx: ServiceContext, torreId: string, totalFloors: number): Promise { + const niveles: Nivel[] = []; + + for (let floor = 1; floor <= totalFloors; floor++) { + const existing = await this.findByFloorNumber(ctx, torreId, floor); + if (!existing) { + const nivel = this.repository.create({ + tenantId: ctx.tenantId, + createdBy: ctx.userId, + torreId, + floorNumber: floor, + name: `Nivel ${floor}`, + totalUnits: 0, + }); + niveles.push(nivel); + } + } + + if (niveles.length > 0) { + return this.repository.save(niveles); + } + + return []; + } + + async update(ctx: ServiceContext, id: string, dto: UpdateNivelDto): Promise { + const nivel = await this.findById(ctx, id); + if (!nivel) { + return null; + } + + Object.assign(nivel, { + ...dto, + updatedBy: ctx.userId, + }); + + return this.repository.save(nivel); + } + + async updateUnitCount(ctx: ServiceContext, id: string): Promise { + const nivel = await this.repository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['departamentos'], + }); + + if (!nivel) { + return null; + } + + const activeUnits = nivel.departamentos?.filter(d => !d.deletedAt) || []; + nivel.totalUnits = activeUnits.length; + + if (ctx.userId) { + nivel.updatedBy = ctx.userId; + } + + return this.repository.save(nivel); + } + + async getStatistics(ctx: ServiceContext, torreId?: string): Promise<{ + totalNiveles: number; + totalUnits: number; + averageUnitsPerFloor: number; + }> { + const queryBuilder = this.repository + .createQueryBuilder('n') + .where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('n.deleted_at IS NULL'); + + if (torreId) { + queryBuilder.andWhere('n.torre_id = :torreId', { torreId }); + } + + const niveles = await queryBuilder.getMany(); + + const totalUnits = niveles.reduce((sum, n) => sum + n.totalUnits, 0); + + return { + totalNiveles: niveles.length, + totalUnits, + averageUnitsPerFloor: niveles.length > 0 ? totalUnits / niveles.length : 0, + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), deletedBy: ctx.userId }, + ); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/construction/services/torre.service.ts b/src/modules/construction/services/torre.service.ts new file mode 100644 index 0000000..1437131 --- /dev/null +++ b/src/modules/construction/services/torre.service.ts @@ -0,0 +1,226 @@ +/** + * TorreService - Servicio de torres/edificios + * + * Gestión de torres/edificios verticales dentro de etapas de construcción. + * + * @module Construction (MAI-002) + */ + +import { Repository } from 'typeorm'; +import { Torre } from '../entities/torre.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateTorreDto { + etapaId: string; + code: string; + name: string; + totalFloors?: number; + totalUnits?: number; + status?: string; +} + +export interface UpdateTorreDto { + name?: string; + totalFloors?: number; + totalUnits?: number; + status?: string; +} + +export interface TorreFilters { + etapaId?: string; + status?: string; + search?: string; + page?: number; + limit?: number; +} + +export class TorreService { + constructor( + private readonly repository: Repository, + ) {} + + async findAll( + ctx: ServiceContext, + filters: TorreFilters = {}, + ): Promise<{ data: Torre[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('t') + .leftJoinAndSelect('t.etapa', 'etapa') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL'); + + if (filters.etapaId) { + queryBuilder.andWhere('t.etapa_id = :etapaId', { etapaId: filters.etapaId }); + } + + if (filters.status) { + queryBuilder.andWhere('t.status = :status', { status: filters.status }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(t.name ILIKE :search OR t.code ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy('t.code', 'ASC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + relations: ['etapa', 'niveles'], + }); + } + + async findByCode(ctx: ServiceContext, etapaId: string, code: string): Promise { + return this.repository.findOne({ + where: { + etapaId, + code, + tenantId: ctx.tenantId, + }, + }); + } + + async findByEtapa(ctx: ServiceContext, etapaId: string): Promise { + return this.repository + .createQueryBuilder('t') + .leftJoinAndSelect('t.niveles', 'niveles', 'niveles.deleted_at IS NULL') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.etapa_id = :etapaId', { etapaId }) + .orderBy('t.code', 'ASC') + .addOrderBy('niveles.floor_number', 'ASC') + .getMany(); + } + + async create(ctx: ServiceContext, dto: CreateTorreDto): Promise { + const existing = await this.findByCode(ctx, dto.etapaId, dto.code); + if (existing) { + throw new Error(`Torre with code ${dto.code} already exists in this etapa`); + } + + const torre = this.repository.create({ + tenantId: ctx.tenantId, + createdBy: ctx.userId, + etapaId: dto.etapaId, + code: dto.code, + name: dto.name, + totalFloors: dto.totalFloors ?? 1, + totalUnits: dto.totalUnits ?? 0, + status: dto.status ?? 'draft', + }); + + return this.repository.save(torre); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateTorreDto): Promise { + const torre = await this.findById(ctx, id); + if (!torre) { + return null; + } + + Object.assign(torre, { + ...dto, + updatedBy: ctx.userId, + }); + + return this.repository.save(torre); + } + + async updateStatus(ctx: ServiceContext, id: string, status: string): Promise { + const torre = await this.findById(ctx, id); + if (!torre) { + return null; + } + + torre.status = status; + if (ctx.userId) { + torre.updatedBy = ctx.userId; + } + + return this.repository.save(torre); + } + + async updateUnitCount(ctx: ServiceContext, id: string): Promise { + const torre = await this.repository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['niveles'], + }); + + if (!torre) { + return null; + } + + const totalUnits = torre.niveles?.reduce((sum, nivel) => sum + nivel.totalUnits, 0) || 0; + torre.totalUnits = totalUnits; + + if (ctx.userId) { + torre.updatedBy = ctx.userId; + } + + return this.repository.save(torre); + } + + async getStatistics(ctx: ServiceContext, etapaId?: string): Promise<{ + totalTorres: number; + byStatus: Record; + totalFloors: number; + totalUnits: number; + }> { + const queryBuilder = this.repository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL'); + + if (etapaId) { + queryBuilder.andWhere('t.etapa_id = :etapaId', { etapaId }); + } + + const torres = await queryBuilder.getMany(); + + const byStatus: Record = {}; + let totalFloors = 0; + let totalUnits = 0; + + for (const torre of torres) { + byStatus[torre.status] = (byStatus[torre.status] || 0) + 1; + totalFloors += torre.totalFloors; + totalUnits += torre.totalUnits; + } + + return { + totalTorres: torres.length, + byStatus, + totalFloors, + totalUnits, + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), deletedBy: ctx.userId }, + ); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/purchase/controllers/index.ts b/src/modules/purchase/controllers/index.ts index 22393fb..cb0734d 100644 --- a/src/modules/purchase/controllers/index.ts +++ b/src/modules/purchase/controllers/index.ts @@ -4,3 +4,5 @@ */ export * from './comparativo.controller'; +export { default as purchaseOrderConstructionController } from './purchase-order-construction.controller'; +export { default as supplierConstructionController } from './supplier-construction.controller'; diff --git a/src/modules/purchase/controllers/purchase-order-construction.controller.ts b/src/modules/purchase/controllers/purchase-order-construction.controller.ts new file mode 100644 index 0000000..bd7022a --- /dev/null +++ b/src/modules/purchase/controllers/purchase-order-construction.controller.ts @@ -0,0 +1,327 @@ +/** + * PurchaseOrderConstruction Controller + * API endpoints para extensión de órdenes de compra de construcción + * + * @module Purchase (MAI-004) + * @prefix /api/v1/purchase-orders-construction + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + PurchaseOrderConstructionService, + CreatePurchaseOrderConstructionDto, + UpdatePurchaseOrderConstructionDto, + RegisterReceptionDto, +} from '../services/purchase-order-construction.service'; + +const router = Router(); +const purchaseOrderConstructionService = new PurchaseOrderConstructionService(null as any); + +/** + * GET /api/v1/purchase-orders-construction + * Lista todas las extensiones de OC del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { fraccionamientoId, requisicionId, qualityApproved, receivedFrom, receivedTo, page, limit } = req.query; + + const result = await purchaseOrderConstructionService.findAll( + { tenantId }, + { + fraccionamientoId: fraccionamientoId as string, + requisicionId: requisicionId as string, + qualityApproved: qualityApproved !== undefined ? qualityApproved === 'true' : undefined, + receivedFrom: receivedFrom ? new Date(receivedFrom as string) : undefined, + receivedTo: receivedTo ? new Date(receivedTo as string) : undefined, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/purchase-orders-construction/statistics + * Estadísticas de recepción de OC + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { fraccionamientoId, dateFrom, dateTo } = req.query; + + const stats = await purchaseOrderConstructionService.getReceptionStatistics( + { tenantId }, + fraccionamientoId as string, + dateFrom ? new Date(dateFrom as string) : undefined, + dateTo ? new Date(dateTo as string) : undefined, + ); + + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/purchase-orders-construction/pending-reception + * OC pendientes de recepción + */ +router.get('/pending-reception', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { fraccionamientoId } = req.query; + const data = await purchaseOrderConstructionService.findPendingReception( + { tenantId }, + fraccionamientoId as string, + ); + + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/purchase-orders-construction/pending-quality + * OC pendientes de aprobación de calidad + */ +router.get('/pending-quality', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await purchaseOrderConstructionService.findPendingQualityApproval({ tenantId }); + + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/purchase-orders-construction/by-po/:purchaseOrderId + * Obtiene extensión por ID de orden de compra original + */ +router.get('/by-po/:purchaseOrderId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const extension = await purchaseOrderConstructionService.findByPurchaseOrderId( + { tenantId }, + req.params.purchaseOrderId, + ); + + if (!extension) { + return res.status(404).json({ error: 'Extensión de OC no encontrada' }); + } + + return res.json({ success: true, data: extension }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/purchase-orders-construction/:id + * Obtiene una extensión de OC por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const extension = await purchaseOrderConstructionService.findById({ tenantId }, req.params.id); + if (!extension) { + return res.status(404).json({ error: 'Extensión de OC no encontrada' }); + } + + return res.json({ success: true, data: extension }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/purchase-orders-construction + * Crea una nueva extensión de OC + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreatePurchaseOrderConstructionDto = req.body; + + if (!dto.purchaseOrderId) { + return res.status(400).json({ error: 'purchaseOrderId es requerido' }); + } + + const extension = await purchaseOrderConstructionService.create({ tenantId, userId }, dto); + return res.status(201).json({ success: true, data: extension }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/purchase-orders-construction/:id + * Actualiza una extensión de OC + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdatePurchaseOrderConstructionDto = req.body; + + const extension = await purchaseOrderConstructionService.update({ tenantId, userId }, req.params.id, dto); + if (!extension) { + return res.status(404).json({ error: 'Extensión de OC no encontrada' }); + } + + return res.json({ success: true, data: extension }); + } catch (error: any) { + if (error.message?.includes('Cannot update')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * POST /api/v1/purchase-orders-construction/:id/reception + * Registra la recepción de una OC + */ +router.post('/:id/reception', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: RegisterReceptionDto = { + ...req.body, + receivedById: req.body.receivedById || userId, + }; + + if (!dto.receivedById) { + return res.status(400).json({ error: 'receivedById es requerido' }); + } + + const extension = await purchaseOrderConstructionService.registerReception({ tenantId, userId }, req.params.id, dto); + if (!extension) { + return res.status(404).json({ error: 'Extensión de OC no encontrada' }); + } + + return res.json({ success: true, data: extension, message: 'Recepción registrada' }); + } catch (error: any) { + if (error.message?.includes('already been received')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/purchase-orders-construction/:id/quality + * Actualiza el estado de calidad de una OC + */ +router.patch('/:id/quality', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { approved, notes } = req.body; + + if (approved === undefined) { + return res.status(400).json({ error: 'approved es requerido' }); + } + + const extension = await purchaseOrderConstructionService.updateQualityStatus( + { tenantId, userId }, + req.params.id, + approved, + notes, + ); + + if (!extension) { + return res.status(404).json({ error: 'Extensión de OC no encontrada' }); + } + + return res.json({ success: true, data: extension, message: 'Estado de calidad actualizado' }); + } catch (error: any) { + if (error.message?.includes('before reception')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * DELETE /api/v1/purchase-orders-construction/:id + * Elimina una extensión de OC (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await purchaseOrderConstructionService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Extensión de OC no encontrada' }); + } + + return res.json({ success: true, message: 'Extensión de OC eliminada' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/purchase/controllers/supplier-construction.controller.ts b/src/modules/purchase/controllers/supplier-construction.controller.ts new file mode 100644 index 0000000..2a80bd3 --- /dev/null +++ b/src/modules/purchase/controllers/supplier-construction.controller.ts @@ -0,0 +1,418 @@ +/** + * SupplierConstruction Controller + * API endpoints para extensión de proveedores de construcción + * + * @module Purchase (MAI-004) + * @prefix /api/v1/suppliers-construction + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + SupplierConstructionService, + CreateSupplierConstructionDto, + UpdateSupplierConstructionDto, + EvaluateSupplierDto, +} from '../services/supplier-construction.service'; + +const router = Router(); +const supplierConstructionService = new SupplierConstructionService(null as any); + +/** + * GET /api/v1/suppliers-construction + * Lista todas las extensiones de proveedores del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { isMaterialsSupplier, isServicesSupplier, isEquipmentSupplier, minRating, hasValidDocuments, specialty, page, limit } = req.query; + + const result = await supplierConstructionService.findAll( + { tenantId }, + { + isMaterialsSupplier: isMaterialsSupplier !== undefined ? isMaterialsSupplier === 'true' : undefined, + isServicesSupplier: isServicesSupplier !== undefined ? isServicesSupplier === 'true' : undefined, + isEquipmentSupplier: isEquipmentSupplier !== undefined ? isEquipmentSupplier === 'true' : undefined, + minRating: minRating ? Number(minRating) : undefined, + hasValidDocuments: hasValidDocuments !== undefined ? hasValidDocuments === 'true' : undefined, + specialty: specialty as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/statistics + * Estadísticas de proveedores + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const stats = await supplierConstructionService.getSupplierStatistics({ tenantId }); + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/top-rated + * Proveedores mejor calificados + */ +router.get('/top-rated', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { limit } = req.query; + const data = await supplierConstructionService.findTopRated({ tenantId }, limit ? Number(limit) : 10); + + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/by-specialty/:specialty + * Proveedores por especialidad + */ +router.get('/by-specialty/:specialty', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await supplierConstructionService.findBySpecialty({ tenantId }, req.params.specialty); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/materials + * Proveedores de materiales + */ +router.get('/materials', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await supplierConstructionService.findMaterialsSuppliers({ tenantId }); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/services + * Proveedores de servicios + */ +router.get('/services', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await supplierConstructionService.findServicesSuppliers({ tenantId }); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/equipment + * Proveedores de equipos + */ +router.get('/equipment', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data = await supplierConstructionService.findEquipmentSuppliers({ tenantId }); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/expiring-documents + * Proveedores con documentos por vencer + */ +router.get('/expiring-documents', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { daysAhead } = req.query; + const data = await supplierConstructionService.findWithExpiringDocuments( + { tenantId }, + daysAhead ? Number(daysAhead) : 30, + ); + + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/by-supplier/:supplierId + * Obtiene extensión por ID de proveedor original + */ +router.get('/by-supplier/:supplierId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const extension = await supplierConstructionService.findBySupplierId({ tenantId }, req.params.supplierId); + if (!extension) { + return res.status(404).json({ error: 'Extensión de proveedor no encontrada' }); + } + + return res.json({ success: true, data: extension }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/suppliers-construction/:id + * Obtiene una extensión de proveedor por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const extension = await supplierConstructionService.findById({ tenantId }, req.params.id); + if (!extension) { + return res.status(404).json({ error: 'Extensión de proveedor no encontrada' }); + } + + return res.json({ success: true, data: extension }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/suppliers-construction + * Crea una nueva extensión de proveedor + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateSupplierConstructionDto = req.body; + + if (!dto.supplierId) { + return res.status(400).json({ error: 'supplierId es requerido' }); + } + + const extension = await supplierConstructionService.create({ tenantId, userId }, dto); + return res.status(201).json({ success: true, data: extension }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/suppliers-construction/:id + * Actualiza una extensión de proveedor + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateSupplierConstructionDto = req.body; + + const extension = await supplierConstructionService.update({ tenantId, userId }, req.params.id, dto); + if (!extension) { + return res.status(404).json({ error: 'Extensión de proveedor no encontrada' }); + } + + return res.json({ success: true, data: extension }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/suppliers-construction/:id/evaluate + * Evalúa un proveedor + */ +router.post('/:id/evaluate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: EvaluateSupplierDto = req.body; + + if (dto.qualityRating === undefined || dto.deliveryRating === undefined || dto.priceRating === undefined) { + return res.status(400).json({ error: 'qualityRating, deliveryRating y priceRating son requeridos' }); + } + + const extension = await supplierConstructionService.evaluate({ tenantId, userId }, req.params.id, dto); + if (!extension) { + return res.status(404).json({ error: 'Extensión de proveedor no encontrada' }); + } + + return res.json({ success: true, data: extension, message: 'Evaluación registrada' }); + } catch (error: any) { + if (error.message?.includes('between')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/suppliers-construction/:id/documents + * Actualiza el estado de documentos de un proveedor + */ +router.patch('/:id/documents', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { hasValidDocuments, expiryDate } = req.body; + + if (hasValidDocuments === undefined) { + return res.status(400).json({ error: 'hasValidDocuments es requerido' }); + } + + const extension = await supplierConstructionService.updateDocumentStatus( + { tenantId, userId }, + req.params.id, + hasValidDocuments, + expiryDate ? new Date(expiryDate) : undefined, + ); + + if (!extension) { + return res.status(404).json({ error: 'Extensión de proveedor no encontrada' }); + } + + return res.json({ success: true, data: extension, message: 'Estado de documentos actualizado' }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/suppliers-construction/:id/credit + * Actualiza los términos de crédito de un proveedor + */ +router.patch('/:id/credit', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { creditLimit, paymentDays } = req.body; + + if (creditLimit === undefined || paymentDays === undefined) { + return res.status(400).json({ error: 'creditLimit y paymentDays son requeridos' }); + } + + const extension = await supplierConstructionService.updateCreditTerms( + { tenantId, userId }, + req.params.id, + Number(creditLimit), + Number(paymentDays), + ); + + if (!extension) { + return res.status(404).json({ error: 'Extensión de proveedor no encontrada' }); + } + + return res.json({ success: true, data: extension, message: 'Términos de crédito actualizados' }); + } catch (error: any) { + if (error.message?.includes('cannot be negative')) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * DELETE /api/v1/suppliers-construction/:id + * Elimina una extensión de proveedor (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await supplierConstructionService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Extensión de proveedor no encontrada' }); + } + + return res.json({ success: true, message: 'Extensión de proveedor eliminada' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/quality/controllers/checklist.controller.ts b/src/modules/quality/controllers/checklist.controller.ts new file mode 100644 index 0000000..dea4add --- /dev/null +++ b/src/modules/quality/controllers/checklist.controller.ts @@ -0,0 +1,438 @@ +/** + * Checklist Controller + * API endpoints para plantillas de inspección de calidad + * + * @module Quality (MAI-009) + * @prefix /api/v1/checklists + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + ChecklistService, + CreateChecklistDto, + UpdateChecklistDto, + CreateChecklistItemDto, + UpdateChecklistItemDto, +} from '../services/checklist.service'; +import { ChecklistStage } from '../entities/checklist.entity'; + +const router = Router(); +const checklistService = new ChecklistService(null as any, null as any); + +/** + * GET /api/v1/checklists + * Lista todos los checklists del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { stage, prototypeId, isActive, search, page, limit } = req.query; + + const result = await checklistService.findAll( + { tenantId }, + { + stage: stage as ChecklistStage, + prototypeId: prototypeId as string, + isActive: isActive !== undefined ? isActive === 'true' : undefined, + search: search as string, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }, + ); + + return res.json({ + success: true, + data: result.data, + total: result.total, + page: result.page, + limit: result.limit, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/checklists/statistics + * Estadísticas de checklists + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const stats = await checklistService.getStatistics({ tenantId }); + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/checklists/by-stage/:stage + * Checklists por etapa de construcción + */ +router.get('/by-stage/:stage', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const stage = req.params.stage as ChecklistStage; + const validStages: ChecklistStage[] = [ + 'foundation', 'structure', 'installations', + 'finishes', 'delivery', 'custom', + ]; + + if (!validStages.includes(stage)) { + return res.status(400).json({ error: `Etapa inválida. Válidas: ${validStages.join(', ')}` }); + } + + const data = await checklistService.findByStage({ tenantId }, stage); + return res.json({ success: true, data, count: data.length }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/checklists/by-code/:code + * Buscar checklist por código + */ +router.get('/by-code/:code', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const checklist = await checklistService.findByCode({ tenantId }, req.params.code); + if (!checklist) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, data: checklist }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/checklists/:id + * Obtiene un checklist por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const checklist = await checklistService.findById({ tenantId }, req.params.id); + if (!checklist) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, data: checklist }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/checklists/:id/with-items + * Obtiene un checklist con todos sus items + */ +router.get('/:id/with-items', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const checklist = await checklistService.findWithItems({ tenantId }, req.params.id); + if (!checklist) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, data: checklist }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/checklists + * Crea un nuevo checklist + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateChecklistDto = req.body; + + if (!dto.code || !dto.name || !dto.stage) { + return res.status(400).json({ error: 'code, name y stage son requeridos' }); + } + + const checklist = await checklistService.create({ tenantId, userId }, dto); + return res.status(201).json({ success: true, data: checklist }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/checklists/:id + * Actualiza un checklist + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateChecklistDto = req.body; + + const checklist = await checklistService.update({ tenantId, userId }, req.params.id, dto); + if (!checklist) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, data: checklist }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/checklists/:id/activate + * Activa un checklist + */ +router.post('/:id/activate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const checklist = await checklistService.activate({ tenantId, userId }, req.params.id); + + if (!checklist) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, data: checklist, message: 'Checklist activado' }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/checklists/:id/deactivate + * Desactiva un checklist + */ +router.post('/:id/deactivate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const checklist = await checklistService.deactivate({ tenantId, userId }, req.params.id); + + if (!checklist) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, data: checklist, message: 'Checklist desactivado' }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/checklists/:id/duplicate + * Duplica un checklist con nuevo código y nombre + */ +router.post('/:id/duplicate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { newCode, newName } = req.body; + + if (!newCode || !newName) { + return res.status(400).json({ error: 'newCode y newName son requeridos' }); + } + + const checklist = await checklistService.duplicate({ tenantId, userId }, req.params.id, newCode, newName); + if (!checklist) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.status(201).json({ success: true, data: checklist, message: 'Checklist duplicado' }); + } catch (error: any) { + if (error.message?.includes('already exists')) { + return res.status(409).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * DELETE /api/v1/checklists/:id + * Elimina un checklist (soft delete) + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await checklistService.softDelete({ tenantId, userId }, req.params.id); + + if (!deleted) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, message: 'Checklist eliminado' }); + } catch (error) { + return next(error); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ITEMS ENDPOINTS +// ───────────────────────────────────────────────────────────────────────────── + +/** + * POST /api/v1/checklists/:id/items + * Agrega un item al checklist + */ +router.post('/:id/items', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: CreateChecklistItemDto = req.body; + + if (dto.sequenceNumber === undefined || !dto.category || !dto.description) { + return res.status(400).json({ error: 'sequenceNumber, category y description son requeridos' }); + } + + const item = await checklistService.addItem({ tenantId, userId }, req.params.id, dto); + return res.status(201).json({ success: true, data: item }); + } catch (error: any) { + if (error.message?.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + return next(error); + } +}); + +/** + * PATCH /api/v1/checklists/:checklistId/items/:itemId + * Actualiza un item del checklist + */ +router.patch('/:checklistId/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const dto: UpdateChecklistItemDto = req.body; + + const item = await checklistService.updateItem({ tenantId, userId }, req.params.itemId, dto); + if (!item) { + return res.status(404).json({ error: 'Item no encontrado' }); + } + + return res.json({ success: true, data: item }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/checklists/:checklistId/items/:itemId + * Elimina un item del checklist + */ +router.delete('/:checklistId/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const deleted = await checklistService.removeItem({ tenantId, userId }, req.params.itemId); + + if (!deleted) { + return res.status(404).json({ error: 'Item no encontrado' }); + } + + return res.json({ success: true, message: 'Item eliminado' }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/checklists/:id/items/reorder + * Reordena los items de un checklist + */ +router.post('/:id/items/reorder', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const userId = (req as any).user?.id; + const { itemOrders } = req.body; + + if (!itemOrders || !Array.isArray(itemOrders)) { + return res.status(400).json({ error: 'itemOrders es requerido y debe ser un array' }); + } + + const success = await checklistService.reorderItems({ tenantId, userId }, req.params.id, itemOrders); + if (!success) { + return res.status(404).json({ error: 'Checklist no encontrado' }); + } + + return res.json({ success: true, message: 'Items reordenados' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/quality/controllers/index.ts b/src/modules/quality/controllers/index.ts index 3ece966..e1c7c8f 100644 --- a/src/modules/quality/controllers/index.ts +++ b/src/modules/quality/controllers/index.ts @@ -5,3 +5,4 @@ export * from './inspection.controller'; export * from './ticket.controller'; +export { default as checklistController } from './checklist.controller';